├── .cargo └── config.toml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── compatibility.md │ └── feature_request.md └── workflows │ ├── build.yml │ ├── lint.yml │ └── publish-wiki.yml ├── .gitignore ├── .gitmodules ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── ci └── package-build.sh ├── docs ├── steamdeck │ ├── USAGE.md │ └── comet_shortcut.sh └── wiki │ ├── Configuration.md │ ├── For-Game-Developers.md │ ├── Game-Compatibility.md │ ├── Home.md │ ├── Overlay.md │ └── steamdeck │ └── Usage.md ├── dummy-service ├── README.md ├── communication.c ├── install-dummy-service.bat ├── meson.build ├── meson │ └── x86_64-w64-mingw32.ini └── permissions.c ├── external ├── comet logo.png └── rootCA.pem └── src ├── api.rs ├── api ├── gog.rs ├── gog │ ├── achievements.rs │ ├── components.rs │ ├── leaderboards.rs │ ├── overlay.rs │ ├── stats.rs │ └── users.rs ├── handlers.rs ├── handlers │ ├── communication_service.rs │ ├── context.rs │ ├── error.rs │ ├── overlay_client.rs │ ├── overlay_peer.rs │ ├── overlay_service.rs │ ├── utils.rs │ └── webbroker.rs └── notification_pusher.rs ├── config.rs ├── constants.rs ├── db.rs ├── db └── gameplay.rs ├── import_parsers.rs ├── import_parsers ├── heroic.rs ├── lutris.rs └── wyvern.rs ├── main.rs ├── paths.rs └── proto.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.aarch64-unknown-linux-gnu] 2 | linker = "aarch64-linux-gnu-gcc" -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: imlinguin 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. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **System (please complete the following information):** 24 | - OS: [e.g. Windows] 25 | - Architecture: [e.g x86] 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/compatibility.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Compatibility 3 | about: Create a compatibility report for the game 4 | title: '' 5 | labels: compatibility 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Game** 11 | - Title: [ex. Cyberpunk 2077] 12 | - GOGDB: [ex. https://www.gogdb.org/product/1423049311] 13 | 14 | **Supported features** 15 | - [ ] Leaderboards 16 | - [ ] Achievements 17 | - [ ] Multiplayer 18 | 19 | **Linux** 20 | - [ ] Linux build is available for the game 21 | - [ ] Linux build supports Galaxy features 22 | 23 | **Additional notes** 24 | Optionally, add any notes here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "v*.*.*" 8 | branches: 9 | - main 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | build-dummy: 16 | runs-on: windows-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Install meson 20 | run: python -m pip install meson ninja 21 | - name: Setup 22 | working-directory: dummy-service 23 | run: meson setup build 24 | - name: Build 25 | working-directory: dummy-service 26 | run: meson compile -C build 27 | - name: Copy files 28 | run: copy .\dummy-service\build\*.exe .\dummy-service 29 | - name: Create a dummy-service archive 30 | shell: bash 31 | working-directory: ./dummy-service 32 | run: 7z a ../dummy-service.zip ./*.{exe,bat} 33 | - uses: actions/upload-artifact@v4 34 | with: 35 | name: dummy-GalaxyCommunication.exe 36 | path: | 37 | dummy-service/*.exe 38 | dummy-service.zip 39 | build: 40 | strategy: 41 | matrix: 42 | target: 43 | [ 44 | x86_64-unknown-linux-gnu, 45 | aarch64-unknown-linux-gnu, 46 | x86_64-apple-darwin, 47 | aarch64-apple-darwin, 48 | x86_64-pc-windows-msvc, 49 | aarch64-pc-windows-msvc, 50 | ] 51 | runs-on: ${{ (contains(matrix.target, 'apple-darwin') && 'macos-latest') || 52 | (contains(matrix.target, 'linux-gnu') && 'ubuntu-latest') || 53 | (contains(matrix.target, 'pc-windows') && 'windows-latest') }} 54 | needs: build-dummy 55 | steps: 56 | - uses: actions/checkout@v4 57 | with: 58 | submodules: "recursive" 59 | - if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }} 60 | name: Install arm gcc 61 | run: sudo apt install gcc-aarch64-linux-gnu 62 | - name: Setup target 63 | run: rustup target add ${{ matrix.target }} 64 | - name: Build 65 | run: cargo build --verbose --release --target ${{ matrix.target }} 66 | - name: Download dummy service for packaging 67 | uses: actions/download-artifact@v4 68 | with: 69 | name: dummy-GalaxyCommunication.exe 70 | path: . 71 | 72 | - name: Copy files 73 | shell: bash 74 | run: ci/package-build.sh ${{ matrix.target }} 75 | 76 | - name: Create Steam Deck archive 77 | if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' }} 78 | run: | 79 | 7z a steam-deck.zip comet* docs/steamdeck/ dummy-service/*.{exe,md,bat} && \ 80 | 7z rn steam-deck.zip comet-x86_64-unknown-linux-gnu comet 81 | 82 | - name: Upload artifact 83 | uses: actions/upload-artifact@v4 84 | with: 85 | name: comet-${{ matrix.target }} 86 | path: | 87 | comet* 88 | steam-deck.zip 89 | 90 | draft-release: 91 | permissions: 92 | contents: write 93 | runs-on: ubuntu-latest 94 | if: startsWith(github.ref, 'refs/tags/') 95 | needs: build 96 | steps: 97 | - name: Download builds 98 | uses: actions/download-artifact@v4 99 | with: 100 | merge-multiple: true 101 | 102 | - name: Create draft 103 | uses: softprops/action-gh-release@v2 104 | with: 105 | draft: true 106 | generate_release_notes: true 107 | files: | 108 | comet* 109 | *.zip 110 | 111 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | paths: 8 | - "**.rs" 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | submodules: "recursive" 20 | - name: Check formatting 21 | run: cargo fmt --check -v 22 | - name: Clippy 23 | run: cargo clippy --all-targets --all-features 24 | -------------------------------------------------------------------------------- /.github/workflows/publish-wiki.yml: -------------------------------------------------------------------------------- 1 | name: Publish wiki 2 | on: 3 | push: 4 | branches: [main] 5 | paths: 6 | - docs/wiki/** 7 | - .github/workflows/publish-wiki.yml 8 | concurrency: 9 | group: publish-wiki 10 | cancel-in-progress: true 11 | permissions: 12 | contents: write 13 | jobs: 14 | publish-wiki: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: Andrew-Chen-Wang/github-wiki-action@v4 19 | with: 20 | path: docs/wiki/ 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | 12 | # dummy-service builds 13 | *.exe 14 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "proto"] 2 | path = proto 3 | url = https://github.com/Yepoleb/gog_protocols 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "comet" 3 | version = "0.3.0" 4 | edition = "2021" 5 | description = "" 6 | authors = ["Paweł Lidwin "] 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | reqwest = { version = "0.12", features = ["http2", "charset", "macos-system-configuration", "json", "rustls-tls-manual-roots", "stream"], default-features = false } 12 | tokio = { version = "1", features = ["full"] } 13 | tokio-util = { version = "0.7.10", features = ["compat"] } 14 | tokio-tungstenite = { version = "0.21", features = ["__rustls-tls"] } 15 | futures-util = "0.3" 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_json = "1.0" 18 | protobuf = "3.4" 19 | clap = { version = "4.4", features = ["derive"] } 20 | log = "0.4.19" 21 | env_logger = "0.11.1" 22 | sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] } 23 | lazy_static = "1.4.0" 24 | derive-getters = "0.3.0" 25 | chrono = "0.4.33" 26 | rustls = "0.22" 27 | rustls-pemfile = "2.1.1" 28 | async_zip = { version = "0.0.17", features = ["tokio", "deflate"] } 29 | base64 = "0.22.0" 30 | serde_ini = "0.2.0" 31 | toml = "0.8.19" 32 | sys-locale = "0.3.2" 33 | futures = "0.3.31" 34 | rand = "0.8.5" 35 | 36 | [build-dependencies] 37 | protobuf-codegen = "3.4.0" 38 | protoc-bin-vendored = "3.0.0" 39 | 40 | [profile.release] 41 | strip = true 42 | 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![Build nightly](https://github.com/imLinguin/comet/actions/workflows/build.yml/badge.svg)](https://github.com/imLinguin/comet/actions/workflows/build.yml) 4 | [![Version](https://img.shields.io/github/v/release/imLinguin/comet?label=version)](https://github.com/imLinguin/comet/releases/latest) 5 | [![Static Badge](https://img.shields.io/badge/Steam%20Deck%20Usage%20Guide-darkslategrey?logo=steamdeck)](docs/steamdeck/USAGE.md) 6 | 7 | Open Source implementation of GOG Galaxy's Communication Service 8 | 9 | This project aims to implement calls made by game through SDK. 10 | Note: that means it can't and won't replace Communication Service in official client 11 | 12 | This will provide minimal and platform-agnostic SDK. For use in game launchers like Heroic or Lutris 13 | 14 | Project is continuation of Yepoleb's work https://gitlab.com/Yepoleb/comet/ but in 15 | ~~Python~~ [now in Rust](https://github.com/imLinguin/comet/issues/15) 16 | 17 | ## Supported Requests 18 | 19 | ### Game 20 | 21 | - [x] LIBRARY_INFO_REQUEST 22 | - [x] AUTH_INFO_REQUEST 23 | - [x] GET_USER_STATS_REQUEST 24 | - [x] SUBSCRIBE_TOPIC_REQUEST 25 | - [x] UPDATE_USER_STAT_REQUEST 26 | - [x] DELETE_USER_STATS_REQUEST 27 | - [x] GET_USER_ACHIEVEMENTS_REQUEST 28 | - [x] UNLOCK_USER_ACHIEVEMENT_REQUEST 29 | - [x] CLEAR_USER_ACHIEVEMENT_REQUEST 30 | - [x] DELETE_USER_ACHIEVEMENTS_REQUEST 31 | - [x] GET_LEADERBOARDS_REQUEST 32 | - [x] GET_LEADERBOARDS_BY_KEY_REQUEST 33 | - [x] GET_LEADERBOARD_ENTRIES_GLOBAL_REQUEST 34 | - [x] GET_LEADERBOARD_ENTRIES_AROUND_USER_REQUEST 35 | - [x] GET_LEADERBOARD_ENTRIES_FOR_USERS_REQUEST 36 | - [x] SET_LEADERBOARD_SCORE_REQUEST 37 | - [x] CREATE_LEADERBOARD_REQUEST 38 | - [ ] GET_GLOBAL_STATS_REQUEST 39 | 40 | ### Overlay 41 | 42 | This includes calls made to be forwarded to game process 43 | 44 | - [x] START_GAME_SESSION_REQUEST 45 | - [x] OVERLAY_FRONTEND_INIT_DATA_REQUEST 46 | - [x] OVERLAY_STATE_CHANGE_NOTIFICATION 47 | - [x] ACCESS_TOKEN_REQUEST 48 | - [x] OVERLAY_INITIALIZATION_NOTIFICATION 49 | - [x] NOTIFY_ACHIEVEMENT_UNLOCKED 50 | - [x] SHOW_WEB_PAGE 51 | - [x] VISIBILITY_CHANGE_NOTIFICATION 52 | - [x] SHOW_INVITATION_DIALOG 53 | - [ ] GAME_JOIN_REQUEST_NOTIFICATION 54 | - [ ] GAME_INVITE_SENT_NOTIFICATION 55 | 56 | ## How to use 57 | 58 | Comet integration in game launchers 59 | 60 | - Heroic - ✅ an experimental feature enabled by default (as of 2.15.0) 61 | - Lutris - ❓ planned, no ETA 62 | - Minigalaxy - ❓ open for Pull Requests 63 | 64 | For manual instructions see [running](#running) 65 | 66 | Some client SDK versions require Windows service to be registered, refer to [dummy service](./dummy-service/README.md) 67 | 68 | ### Authentication 69 | 70 | You need to obtain `access_token`, `refresh_token` and `user_id` either manually, or by importing them: 71 | 72 | #### Via [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher) 73 | 74 | Log in to GOG within the launcher. 75 | Use `--from-heroic` for automatic import. 76 | 77 | #### Via [Lutris](https://github.com/lutris/lutris) 78 | 79 | Log in to Lutris's GOG source. 80 | Use `--from-lutris` for automatic import. 81 | 82 | #### Via [wyvern](https://github.com/nicohman/wyvern) (CLI) 83 | 84 | Log in to GOG in wyvern 85 | Use `--from-wyvern` for automatic import. 86 | 87 | #### Via [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) (CLI) 88 | 89 | If GOG authentication has never been performed in Heroic on the current user, create the expected directory: 90 | 91 | ``` 92 | mkdir -p $HOME/.config/heroic/gog_store 93 | ``` 94 | 95 | Then, run the command: 96 | 97 | ``` 98 | ./bin/gogdl --auth-config-path $HOME/.config/heroic/gog_store/auth.json auth --code 99 | ``` 100 | 101 | Obtain the code by logging in using this URL, then copying the code value from the resulting URL: 102 | 103 | https://login.gog.com/auth?client_id=46899977096215655&layout=galaxy&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=code 104 | 105 | ### Running 106 | 107 | ``` 108 | comet --token "" --refresh_token "" --user-id --username 109 | ``` 110 | 111 | Or if you are using Heroic/gogdl 112 | 113 | ``` 114 | comet --from-heroic --username 115 | ``` 116 | 117 | Or Lutris 118 | 119 | ``` 120 | comet --from-lutris --username 121 | ``` 122 | 123 | Or wyvern 124 | 125 | ``` 126 | comet --from-wyvern --username 127 | ``` 128 | 129 | Or use the shortcut script provided for non-Steam shortcuts. See the [Steam Deck Usage Guide](docs/steamdeck/USAGE.md). 130 | 131 | ## Configuration 132 | 133 | You can adjust basic overlay settings with comet configuration file. 134 | File locations: 135 | 136 | - Windows - `%APPDATA%/comet/config.toml` 137 | - Mac - `~/Library/Application Support/comet/config.toml` 138 | - Linux - `$XDG_CONFIG_HOME/comet/config.toml` 139 | 140 | Default configuration file is as follows 141 | 142 | ```toml 143 | [overlay] 144 | notification_volume = 50 # value from 0 to 100 145 | position = "bottom_right" # position where notifications are shown: top_left top_right bottom_left bottom_right 146 | 147 | # Controls chat message notifications 148 | [overlay.notifications.chat] 149 | enabled = true 150 | sound= true 151 | 152 | # Controls notifications when friend becomes online 153 | [overlay.notifications.friend_online] 154 | enabled = true 155 | sound= true 156 | 157 | # Controls notifications when someone sends you a friend invititation 158 | [overlay.notifications.friend_invite] 159 | enabled = true 160 | sound= true 161 | 162 | # Controls notifications when friend starts playing a game 163 | [overlay.notifications.friend_game_start] 164 | enabled = true 165 | sound= true 166 | 167 | # Controls notifications when someone sends you a game invite 168 | [overlay.notifications.game_invite] 169 | enabled = true 170 | sound= true 171 | ``` 172 | 173 | ## Contributing 174 | 175 | Join [Heroic Discord](https://discord.gg/rHJ2uqdquK) and reach out to us on 176 | special [thread](https://discord.com/channels/812703221789097985/1074048840958742648) 177 | 178 | [Here](https://linguin.dev/blog/galaxy-comm-serv-re-setup) you can find a blog post about setting up 179 | environment for tracing the Communication Service calls (involving Proxifier and custom mitmproxy) 180 | 181 | Reverse engineered protobuf definitions are available here: https://github.com/Yepoleb/gog_protocols 182 | 183 | ## Debugging SDK Client 184 | 185 | In order to dump logging from SDK client (the game) download [GalaxyPeer.ini](https://items.gog.com/GalaxyPeer.zip), 186 | when placed next to game .exe it will write GalaxyPeer.log when the game is running. 187 | 188 | > [!WARNING] 189 | > Proceed with caution, the log may contain sensitive information, 190 | > make sure to remove such data before sharing the file with others. 191 | 192 | ## Sponsoring 193 | 194 | If you want to contribute financially you can do so via my [Ko-Fi](https://ko-fi.com/imlinguin). 195 | You can also use any of the options to [support Heroic](https://heroicgameslauncher.com/donate) 196 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use protobuf_codegen::Codegen; 2 | use std::path::PathBuf; 3 | 4 | fn main() { 5 | println!("cargo:rerun-if-changed=proto/galaxy.protocols.communication_service.proto"); 6 | println!("cargo:rerun-if-env-changed=PROTOC"); 7 | let vendored_protoc = protoc_bin_vendored::protoc_bin_path().unwrap(); 8 | let protoc_path = match std::env::var("PROTOC") { 9 | Ok(protoc) => PathBuf::from(&protoc), 10 | Err(_) => vendored_protoc, 11 | }; 12 | Codegen::new() 13 | .protoc() 14 | .protoc_path(protoc_path.as_path()) 15 | .includes(["proto"]) 16 | .input("proto/gog.protocols.pb.proto") 17 | .input("proto/galaxy.protocols.webbroker_service.proto") 18 | .input("proto/galaxy.protocols.overlay_for_peer.proto") 19 | .input("proto/galaxy.protocols.overlay_for_client.proto") 20 | .input("proto/galaxy.protocols.overlay_for_service.proto") 21 | .input("proto/galaxy.protocols.communication_service.proto") 22 | .input("proto/galaxy.common.protocols.peer_to_server.proto") 23 | .input("proto/galaxy.common.protocols.peer_to_peer.proto") 24 | .input("proto/galaxy.common.protocols.peer_common.proto") 25 | .input("proto/galaxy.common.protocols.connection.proto") 26 | .cargo_out_dir("proto") 27 | .run_from_script(); 28 | } 29 | -------------------------------------------------------------------------------- /ci/package-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | target=$1 4 | 5 | if [[ -z $target ]]; then 6 | echo "Target not provided" 7 | exit 1 8 | fi 9 | 10 | if [[ -f "target/$target/release/comet" ]]; then 11 | cp "target/$target/release/comet" "./comet-$target" 12 | else 13 | cp "target/$target/release/comet.exe" "./comet-$target.exe" 14 | fi 15 | 16 | -------------------------------------------------------------------------------- /docs/steamdeck/USAGE.md: -------------------------------------------------------------------------------- 1 | # Using with the Steam Deck 2 | 3 | Using Comet with Valve's Steam Deck (running SteamOS) is possible in both Desktop and Game Mode. Comet will only function with games that do support [GOG Galaxy's achievement system](https://www.gog.com/games?features=achievements) or any other online related functionality like Leaderboards. **If your game does not work out of the box, check Known Issues below.** 4 | 5 | Using `comet_shortcut.sh` will simplify the process of launching GOG games from Heroic or Lutris with Comet in the background. That script works in both Modes. 6 | 7 | This instruction also applies for desktop users. If you don't use any launcher see the project [README](https://github.com/imLinguin/comet#via-gogdl-cli) for manual credentials management. 8 | 9 | 10 | ## Installation steps 11 | 12 | 1. Make sure you are logged into GOG on your launcher of choice. 13 | 2. Download the latest release of Comet from [the latest release](https://github.com/imLinguin/comet/releases/latest) labelled `steam-deck.zip`. 14 | 3. Extract the downloaded archive to a desired place. 15 | > It is recommended to have the `comet` binary put into the `~/Documents` directory. Otherwise: choose any directory where your app of choice has access to. 16 | > Ensure that the file is Executable in Properties > Permissions 17 | 4. Open the `comet_shortcut.sh` file with Kate (right click on the file > Open with Kate), and edit the following values: 18 | - `gog_username` 19 | > Change the `username` value after `=` to your GOG username. If your name includes any special characters make sure to quote the username accordingly 20 | - `path_to_comet` 21 | > Change the `path_to_comet` value after `=` (while keeping the `'` characters in tact) to the full file path of the `comet` binary. 22 | 5. Start any game that has the shortcut script included (see instructions for [Heroic](#use-with-heroic) or [Lutris](#use-with-lutris)) to play the game with achievement support! 23 | 24 | > [!NOTE] 25 | > On the startup comet downloads `GalaxyPeer` libraries (~100 MiB) into `$XDG_DATA_HOME/comet/redist/peer`. 26 | > The download is triggered if there is an update available or if the files aren't downloaded already. 27 | > On Mac, both native and windows libraries are being downloaded 28 | 29 | 30 | ## Use with Heroic 31 | 32 | > [!NOTE] 33 | > Starting with Heroic 2.15.0, comet will be setup automatically for every GOG game by Heroic itself. 34 | 35 | 1. Install Comet and its shortcut script. (See the installation steps above.) 36 | 2. In Heroic Games Launcher, set the `comet_shortcut.sh` as a script that is going to be ran before the game (Game Settings > Advanced > Scripts) 37 | 4. Launch the game through either Desktop or Game Mode! 38 | 5. **Directly exiting the game through Steam will not sync your GOG playtime via Heroic!** Make sure to always exit the game via in-game menu. 39 | 40 | > [!TIP] 41 | > Use Heroic's Add to Steam feature for the best experience 42 | > in accessing your games from the Gaming Mode 43 | 44 | ## Use with Lutris 45 | 46 | If you want to use the same script with Lutris, modify it to include `--from-lutris` instead of `--from-heroic`. You need to be logged in the GOG runner in Lutris. 47 | 48 | Steps to add a script to Lutris game 49 | 50 | 1. Install Comet and its shortcut script. (See the installation steps above.) 51 | 2. Right click on the game and click Configure 52 | 3. Head over to `System options` and enable `Advanced` mode (next to save button) 53 | 4. Scroll down into `Game execution` section and set the path to `comet_shortcut.sh` as Command prefix or Pre-launch script. 54 | 5. Launch the game normally! 55 | 56 | > [!TIP] 57 | > Setting the script as Command prefix will allow Lutris to wait for both comet and game process. 58 | > Thus it's a recommended way 59 | 60 | ## Known Issues 61 | 62 | - **Not all GOG games are supported out of the box** - some games (e.g. [Cuphead](https://www.gog.com/game/cuphead)) do not support the way Comet works on its own, due to some checks performed by SDK used for GOG Galaxy features. 63 | 64 | **To solve it**: you will need to install the `GalaxyCommunications` dummy application. (The necessary files (the `.bat` script and the dummy `.exe`) have been included in the archive together with comet binary.) 65 | 66 | 1. Keep the `comet` Linux artifact items in a directory Heroic has access to, such as `~/Documents` or `~/Desktop`. 67 | 2. Go to Heroic Games Launcher, to the malfunctioning game's settings screen.. 68 | 3. Scroll down the WINE tab of the game's settings screen until you see `RUN EXE ON PREFIX`. 69 | 4. Drag and drop the `install-dummy-service.bat` onto `RUN EXE ON PREFIX` to install the dummy service for the game to detect. 70 | 5. Play the game as you would expect. It should now function with Comet's features! 71 | 72 | - Currently there is no visible feedback on when the achievement has been unlocked, there is a [decky-loader plugin planned](https://github.com/imLinguin/comet/issues/18) 73 | 74 | ## Offline support 75 | 76 | Comet should be able to register achievements and statistics while offline just fine and report them to the server next time you play the game online. 77 | 78 | **Please make sure to report issues if you encounter any.** 79 | 80 | -------------------------------------------------------------------------------- /docs/steamdeck/comet_shortcut.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Comet shortcut script 4 | # 5 | # Meant for usage as a pre-launch script 6 | # Although the script can be used as a wrapper as well 7 | # Heroic Game settings > Advanced > Scripts > Select a script to run before the game is launched 8 | # Make sure the script is in location that's always accessible by Heroic 9 | # such as /home/deck/Documents 10 | 11 | # Variables 12 | 13 | gog_username=username 14 | # Full filepath to comet binary - for microSD cards, look into the /run/media/deck/ directory for specific microSD card folders 15 | path_to_comet='/home/deck/Documents/comet/comet' 16 | # Uncomment if debug logs are wanted to be visible in Comet 17 | #export COMET_LOG=debug 18 | # A timeout after which comet will quit when last client disconnects 19 | export COMET_IDLE_WAIT=5 # comet has a 15 seconds as the default, we make it shorter here, feel free to tweak it to your liking 20 | 21 | # Running Comet as a background app 22 | # If you want to use this script in Lutris change --from-heroic to --from-lutris 23 | exec "$path_to_comet" --from-heroic --username "$gog_username" -q & 24 | 25 | # This part allows using this script as a wrapper 26 | # e.g comet_shortcut.sh wine game.exe 27 | if [ $# -ne 0 ]; then 28 | comet_pid=$! 29 | exec "$@" & 30 | game_pid=$! 31 | echo "Waiting for $comet_pid and $game_pid" 32 | trap 'kill $game_pid; wait $game_pid $comet_pid' SIGINT SIGTERM 33 | wait $game_pid $comet_pid 34 | fi 35 | 36 | -------------------------------------------------------------------------------- /docs/wiki/Configuration.md: -------------------------------------------------------------------------------- 1 | You can adjust basic overlay settings with comet configuration file. 2 | File locations: 3 | 4 | - Windows - `%APPDATA%/comet/config.toml` 5 | - Mac - `~/Library/Application Support/comet/config.toml` 6 | - Linux - `$XDG_CONFIG_HOME/comet/config.toml` 7 | 8 | Default configuration file is as follows 9 | 10 | ```toml 11 | [overlay] 12 | notification_volume = 50 # value from 0 to 100 13 | position = "bottom_right" # position where notifications are shown: top_left top_right bottom_left bottom_right 14 | 15 | # Controls chat message notifications 16 | [overlay.notifications.chat] 17 | enabled = true 18 | sound= true 19 | 20 | # Controls notifications when friend becomes online 21 | [overlay.notifications.friend_online] 22 | enabled = true 23 | sound= true 24 | 25 | # Controls notifications when someone sends you a friend invititation 26 | [overlay.notifications.friend_invite] 27 | enabled = true 28 | sound= true 29 | 30 | # Controls notifications when friend starts playing a game 31 | [overlay.notifications.friend_game_start] 32 | enabled = true 33 | sound= true 34 | 35 | # Controls notifications when someone sends you a game invite 36 | [overlay.notifications.game_invite] 37 | enabled = true 38 | sound= true 39 | ``` -------------------------------------------------------------------------------- /docs/wiki/For-Game-Developers.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This is **not** an official page affiliated with GOG. These are my findings while I was looking into the SDK client libraries that GOG provides. 3 | > While the libraries disclosed below have been proven to work in production, **there is no guarantee.** 4 | > Nevertheless I encourage you to at least try linking your game against them and see the results. 5 | 6 | ## You made it here! 7 | 8 | If you're seeing this page, then you're probably considering shipping a native Linux build of your game on GOG. 9 | Excellent choice! If you've researched the GOG GALAXY SDK then you've probably discovered that it appears to not be available for Linux. 10 | 11 | **But don't leave yet!** It is still possible to use the GOG GALAXY SDK on your Linux build to enable achievements and other online features, and it may even save you some general effort in the process. 12 | This has already been proven to work by several games, including: 13 | 14 | - [Crypt of the NecroDancer](https://www.gog.com/game/crypt_of_the_necrodancer) 15 | - [Indivisible](https://www.gog.com/game/indivisible) 16 | - [Stardew Valley](https://www.gog.com/game/stardew_valley) 17 | - [Streets of Rage 4](https://www.gog.com/game/streets_of_rage_4) 18 | 19 | ## How to get the native Linux library 20 | 21 | In the Developer Portal in `Galaxy Components` > `SDK`, you'll see the following download section: 22 | 23 | ![image](https://github.com/user-attachments/assets/3bdc9728-ad09-4bd4-83c4-09c745433a8a) 24 | 25 | The `Steam-runtime` is what you are looking for. Pick an appropriate bitness and link your app against that. 26 | This build is up-to date like the rest of the SDK packages. 27 | 28 | > [!NOTE] 29 | > There is no GalaxyPeer library for Linux. Instead, it seems to be statically linked within the provided libGalaxy.so library. 30 | 31 | ## Why bother? 32 | 33 | Going out of your way to use the library that GOG is curiously neglecting to mention may feel weird, but there are significant advantages. 34 | 35 | 1. DLC Discovery via GOG GALAXY SDK: https://docs.gog.com/sdk-dlc-discovery 36 | - GOG normally encourages you to build your own solution for DLC Discovery on your Linux build, but now you can continue using the dedicated GOG GALAXY SDK method, just as you would on your Windows build. 37 | 38 | 2. Potentially less work in general 39 | - To ease the burden in supporting multiple storefronts, this library may generally reduce the amount of platform-specific workarounds needed to offset the inconsistent feature sets between platforms on GOG. 40 | 41 | 3. Multiplayer, Leaderboards & more on Linux 42 | - Just like your other builds, your Linux build can now provide multiplayer functionality and player authentication features. While the GOG GALAXY client is not available for Linux, players can use an equivalent client such as `comet` to pick up your game's API calls.\ 43 | *(Remember to keep these online features optional in order for your game to remain DRM-free - see the [DRM and SDK](https://docs.gog.com/sdk/#drm-and-sdk) section of the GOG Dev Docs for more information.)* 44 | 45 | ## So what is `comet`? 46 | 47 | Comet is a drop-in replacement for the GOG GALAXY client that allows players to leverage "GOG GALAXY-only" functionality (multiplayer, achievements, stats and leaderboards) on every platform that GOG games are available for. 48 | 49 | The main goal of this project is to bring the GOG GALAXY feature set to Linux users and let them use the platform to the fullest. 50 | 51 | Comet has already shipped to thousands of users through the [Heroic Games Launcher](https://heroicgameslauncher.com/), with more Linux clients soon to follow. 52 | 53 | ## Not convinced? 54 | 55 | Depending on the features that your game provides, not using this library may be more troublesome than expected; you should evaluate this per your project scope. 56 | 57 | Should you ultimately decide against including the `Steam-runtime` library, your choice will be respected. Linux users who wish to access your game's online features can still use Proton to run the Windows build of your game, so not everything is lost. 58 | 59 | *Have a nice day!* 60 | *from the Comet developers* 61 | -------------------------------------------------------------------------------- /docs/wiki/Game-Compatibility.md: -------------------------------------------------------------------------------- 1 | # Game Compatibility 2 | 3 | As this is a work-in-progress replacement, not all GOG Galaxy feature enabled games may function as expected. You can use `CTRL+F` to search for the game you would like to learn more about its compatibility. 4 | 5 | ## Contribute 6 | 7 | To contribute, follow the Contribution Guidelines mentioned in the [Home Page of the Wiki](Home) and follow the following steps: 8 | 9 |
10 | Steps 11 | 12 | - Copy the last column of the table 13 | - Edit the following information for your tested game compatibility: 14 | - Game Title (with the GOG storefront linked to it as a Hyperlink, in the following format: `[Game's name](GOG url here)`) 15 | - Game Version 16 | - To check the official Game Version name, either find it in-game or follow the following steps on Heroic: 17 | 1. Go to the game's page on Heroic Games Launcher 18 | 2. Hover over the three-point menu button and click on `Modify Installation` 19 | 3. Click on the checkbox next to `Keep the game at specific version` 20 | 4. The selected version should be the one you have currently installed. Note the version and date of said version as Game Version. 21 | - If the Native Linux Version works with Comet 22 | - Some games that do have a native Linux version available (not using Proton/WINE), still contain code for GOG features that remain unused unless used with Comet. If the game you tested does have a native Linux version, please do test if the Linux version does connect with Comet. 23 | - Comet Version 24 | - Go by the version name in the Releases tab, of the version you downloaded. 25 | - Did you use Comet when it had no Releases available, or are you using a build that's not part of the releases? Mention (and possibly link) to the commit of the version you used. 26 | - GalaxyCommunication.exe Service Required 27 | - Test if the game does function with achievements and leaderboards/Comet. If it does not, try to install the service as you can [read in the dummy service documentation]](https://github.com/imLinguin/comet/tree/main/dummy-service/README.md). 28 | - If the service is required, fill in with `🟩 Yes`. If the service is not required, fill in with `🔲 No`. 29 | - GOG Galaxy Features 30 | - Fill in the `🟩` (working), `🔲` (not present in-game) or `❌` (not working) for the following features: 31 | - Achievements 32 | - Leaderboard 33 | - Notes 34 | - Any additional notes you would find important to mention with regards to the game compatibility. For example: possible issues, workarounds, specifics like switchable leaderboards between GOG and a different service. 35 |
36 | 37 | ## Game Compatibility Table 38 | 39 | ### Legend 40 | 41 | 42 | | Icon | Meaning | 43 | |---|---------------------------------------------------------------------------------------| 44 | | 🟩 | Required (GalaxyCommunication.exe requirement), Working (GOG Galaxy Features) | 45 | | 🔲 | Not Available (Native Linux Version, GOG Galaxy Feature), Not Required (GalaxyCommunication.exe requirement) 46 | | ❌ | Not Working (GOG Galaxy Features)| 47 | | ❓| Unknown (GalaxyCommunication.exe requirement; in case of game-specific bugs that prevent Comet/GOG connection) 48 | 49 | About `Native Linux Version Works`: 50 | 51 | While GOG does not officially support GOG Galaxy features (achievements and leaderboards) for Linux versions of GOG games due to the lack of a Linux version of GOG Galaxy, some games that are multi-platform (having Windows and/or macOS versions, besides a native Linux version) still do have code in the Linux version to enable GOG Galaxy features. This connection is unused for Linux versions, but can get used by Comet. **Not all Linux versions do ship with unused/leftover GOG Galaxy connecting code, however.** Do not expect every native Linux version to work with Comet. 52 | 53 | ### Table 54 | 55 | |Game Title|Game Version|Native Linux Version Works|Comet Version|GalaxyCommunication.exe Service Required|GOG Galaxy Features|Notes| 56 | |-----|-----|-----|-----|-----|-----|-----| 57 | |[Absolute Drift](https://www.gog.com/game/absolute_drift)|5f6049d (6/26/2023)|🔲 Not Available|[commit `ed38c3d`](https://github.com/kevin-wijnen/comet/commit/ed38c3d5253893779ba3d7ab828af442652f6044)|🔲 No|🟩 Achievements 🟩 Leaderboard|Achievements do work. Leaderboard support works as of Comet version `ed38c3d`.| 58 | |[Alder's Blood Prologue](https://www.gog.com/game/alders_blood_prologue)|1.0.20a (4/13/2020)|🔲 Not Available|[commit `55e4025`](https://github.com/imLinguin/comet/commit/55e402538df3bff354bf2e1e9a54fa4e5e091122)|🔲 No|🟩 Achievements 🔲 Leaderboard|Achievement connection does work.| 59 | |[Alien Breed: Impact](https://www.gog.com/game/alien_breed_impact)|126 (5/30/2022)|🔲 Not Available|[commit `55e4025`](https://github.com/imLinguin/comet/commit/55e402538df3bff354bf2e1e9a54fa4e5e091122)|🔲 No|🟩 Achievements 🔲 Leaderboard|Achievement connection does work. Did not get to boot the game on Linux properly yet, however.| 60 | |[Crypt of the NecroDancer](https://www.gog.com/game/crypt_of_the_necrodancer)|4.1.0-b5142 (4/3/2024)|🟩 Yes|[commit `ed38c3d`](https://github.com/kevin-wijnen/comet/commit/ed38c3d5253893779ba3d7ab828af442652f6044)|🔲 No|🟩 Achievements 🟩 Leaderboard|Achievements do work. Leaderboard support works as of Comet version `ed38c3d`. Tested with game + all DLCs.| 61 | |[Cuphead](https://www.gog.com/game/cuphead)|1.3.4 (8/19/2022)|🔲 Not Available|[commit `55e4025`](https://github.com/imLinguin/comet/commit/55e402538df3bff354bf2e1e9a54fa4e5e091122)|🟩 Yes|🟩 Achievements 🔲 Leaderboard|GalaxyCommunication.exe service required for game to start communicating with GOG. Otherwise, Achievements won't work. No Leaderboards present in-game.| 62 | |[Cyberpunk 2077](https://www.gog.com/en/game/cyberpunk_2077)|2.21 (1/19/2025)|🔲 Not Available|[`v0.2.0`](https://github.com/imLinguin/comet/releases/tag/v0.2.0)|🔲 No|🟩 Achievements 🔲 Leaderboard|Achievements work in [Heroic v2.16.1](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher/releases/tag/v2.16.1). This was tested with the regular version of the game, not the [Ultimate Edition](https://www.gog.com/en/game/cyberpunk_2077_ultimate_edition) or [Phantom Liberty DLC](https://www.gog.com/en/game/cyberpunk_2077_phantom_liberty).| 63 | |[DOOM + DOOM II](https://www.gog.com/game/doom_doom_ii)|Version 2265 - 8/7/2024|🔲 Not Available|[`v0.1.2`](https://github.com/imLinguin/comet/releases/tag/v0.1.2)|🔲 No|🟩 Achievements 🔲 Leaderboard|Achievements & Multiplayer do work as of `v0.1.2`.| 64 | |[DOOM 3: BFG Edition](https://www.gog.com/game/doom_3)|1.14 (7/14/2017)|🔲 Not Available|[version `0.1.1`](https://github.com/imLinguin/comet/releases/tag/v0.1.1)|🟩 Yes|🟩 Achievements 🔲 Leaderboard|GalaxyCommunication.exe service required for game to start communicating with GOG. Otherwise, Achievements won't work. Mutliplayer isn't supported in GOG version of the game.| 65 | |[DOOM (2016)](https://www.gog.com/game/doom_2016)|Version 20240321-110145-gentle-wolf - 2/25/2025|🔲 Not Available|[`v0.1.2`](https://github.com/imLinguin/comet/releases/tag/v0.1.2)|🔲 No|🟩 Achievements 🔲 Leaderboard|Achievements does work as of `v0.1.2`.| 66 | |[Duck Detective: The Secret Salami](https://www.gog.com/game/duck_detective_the_secret_salami)|1.1.0|❌ Not Working|[version `v0.2.0`](https://github.com/imLinguin/comet/releases/tag/v0.2.0)|🔲 No|🟩 Achievements 🔲 Leaderboard|Windows build of the game is required to unlock achievements when on Linux.| 67 | |[Ghostrunner](https://www.gog.com/game/ghostrunner)|42507_446 (6/24/2022)|🔲 Not Available|[version `0.1.0`](https://github.com/imLinguin/comet/releases/tag/v0.1.0)|🔲 No|🟩 Achievements 🟩 Leaderboard|Achievements and Leaderboards work as expected. The game seems to separate saves based on Galaxy user id. Saves may need to be moved manually to be available.| 68 | |[Horizon Zero Dawn Complete Edition](https://gog.com/game/horizon_zero_dawn_complete_edition)|7517962 (1/18/2022)|🔲 Not Available|[commit `c4715bf`](https://github.com/imLinguin/comet/commit/c4715bfa186f9b8955b842d57fd6f17fc5209f26)|🔲 No|🟩 Achievements 🔲 Leaderboard| Achievements do work.| 69 | |[Indivisible](https://www.gog.com/game/indivisible)|42940 (6/22/2020)|🟩 Yes|[version 0.1.0](https://github.com/imLinguin/comet/releases/tag/v0.1.0)|🔲 No|🟩 Achievements 🔲 Leaderboard|Achievements do work.| 70 | |[Kingdom Come: Deliverance](https://www.gog.com/game/kingdom_come_deliverance)|1.9.6-404-504czi3 |🔲 Not available|[version `0.1.2`](https://github.com/imLinguin/comet/releases/tag/v0.1.2)|🟩 Yes|🟩 Achievements 🔲 Leaderboard|| 71 | |[Metal Slug](https://www.gog.com/game/metal_slug)|Version gog-3 - 26/05/2017|🔲 Not Available|[version `v0.1.2`](https://github.com/imLinguin/comet/releases/tag/v0.1.2)|🔲 No|🟩 Achievements 🟩 Leaderboard|Achievements and leaderboard do work as of `v0.1.2`. Multiplayer not tested yet.| 72 | |[Metal Slug 2](https://www.gog.com/game/metal_slug_2)|gog-3 - 26/05/2017|🔲 Not Available|[version `0.1.2`](https://github.com/imLinguin/comet/releases/tag/v0.1.2)|🔲 No|🟩 Achievements 🟩 Leaderboard|Achievements and leaderboard do work as of `v0.1.2`.| 73 | |[Metal Slug 3](https://www.gog.com/game/metal_slug_3)|gog-5 - 26/05/2017|🔲 Not Available|[version `0.1.2`](https://github.com/imLinguin/comet/releases/tag/v0.1.2)|🔲 No|🟩 Achievements 🟩 Leaderboard|Achievements and leaderboard do work as of `v0.1.2`. Multiplayer not tested yet.| 74 | |[Metal Slug X](https://www.gog.com/game/metal_slug_x)|gog-6 - 02/06/2017|🔲 Not Available|[version `0.1.2`](https://github.com/imLinguin/comet/releases/tag/v0.1.2)|🔲 No|🟩 Achievements 🟩 Leaderboard|Achievements and leaderboard do work as of `v0.1.2`. Multiplayer not tested yet.| 75 | |[Quake II](https://www.gog.com/game/quake_ii_quad_damage)|5984 (11/01/2023)|🔲 Not available|[version `0.1.1`](https://github.com/imLinguin/comet/releases/tag/v0.1.1)|🔲 No|🟩 Achievements 🔲 Leaderboard|Achievements work as expected. The game needs OpenID support introduced in comet v0.1.1| 76 | |[STONKS-9800: Stock Market Simulator](https://www.gog.com/game/stonks9800_stock_market_simulator)|0.4.2.5 (04/04/2024)|🔲 Not Available|[commit `55e4025`](https://github.com/imLinguin/comet/commit/55e402538df3bff354bf2e1e9a54fa4e5e091122)|❓ Unknown|❌ Achievements 🔲 Leaderboard|Game specific issue related to the GOG SDK library used. See [#26](https://github.com/imLinguin/comet/issues/26#issuecomment-2053667485) for any information and updates. Game did not connect to GOG via Comet with and without the dummy Service.| 77 | |[Stardew Valley](https://www.gog.com/game/stardew_valley)|1.6.5.24110.6670590629 (4/19/2024)|🟩 Yes|[commit `c4715bf`](https://github.com/imLinguin/comet/commit/c4715bfa186f9b8955b842d57fd6f17fc5209f26)|🔲 No|🔲 Achievements 🔲 Leaderboard|The game uses Galaxy SDK for multiplayer only.| 78 | |[Tomb Raider GOTY](https://www.gog.com/game/tomb_raider_goty)|1.0|🔲 Not available|[version `0.1.2`](https://github.com/imLinguin/comet/releases/tag/v0.1.2)|🔲 No|🟩 Achievements 🔲 Leaderboard|| 79 | |[Wolfenstein: The New Order](https://www.gog.com/game/wolfenstein_the_new_order)|1.0.0.2 - 06/02/2020|🔲 Not Available|[version `0.1.2`](https://github.com/imLinguin/comet/releases/tag/v0.1.2)|🔲 No|🟩 Achievements 🔲 Leaderboard|Switch to older version without hotfix for the achievements, however the game is prone to crash. Refer to [this issue](https://github.com/imLinguin/comet/issues/57).| 80 | |[Xeno Crisis](https://www.gog.com/game/xeno_crisis)|1.0.4 (2/11/2020)|❌ Not Working|[commit `55e4025`](https://github.com/imLinguin/comet/commit/55e402538df3bff354bf2e1e9a54fa4e5e091122)|🔲 No|🟩 Achievements 🔲 Leaderboard|Achievement connection does work. The GOG Galaxy communications are not present in the Linux version, thus the macOS or Windows version needs to be used with Comet to work.| 81 | -------------------------------------------------------------------------------- /docs/wiki/Home.md: -------------------------------------------------------------------------------- 1 | # Comet Wiki 2 | 3 | Welcome to the Wiki of the Comet project! Here you will find some documentation about the project, mainly aimed at the end users. 4 | 5 | ## Important pages 6 | 7 | [Game Compatibility Table](Game-Compatibility) 8 | 9 | [Steam Deck Usage](Usage) 10 | 11 | ## Contribute 12 | 13 | The GitHub Wiki accepts [several markup languages](https://github.com/github/markup#markups) for visualisation. 14 | Your contributions can be sent over as a Pull Request to have it reviewed and merged with the rest of the Wiki. 15 | 16 | To contribute additions or changes to the Wiki, you need to: 17 | 18 | - [Fork the project](https://github.com/imLinguin/comet/fork) 19 | - Add or edit pages located in the `/docs/wiki` directory 20 | - [Start a `Pull Request` ](https://github.com/imLinguin/comet/compare) with your fork, to show the changes to the rest of the community. 21 | 22 | Once your changes are accepted (after possible feedback changes), the owner of the repository will merge your changes and have them appear in the Wiki everyone does see! -------------------------------------------------------------------------------- /docs/wiki/Overlay.md: -------------------------------------------------------------------------------- 1 | # GOG Galaxy Overlay 2 | 3 | This section describes how to use Galaxy overlay with comet. 4 | 5 | > [!IMPORTANT] 6 | > At the moment, overlay injection is implemented only on Linux with galaxy-helper. 7 | > Windows compatibility is a matter of time. While MacOS is not planned in forseable future. 8 | 9 | ## Step by step 10 | 11 | - Get [galaxy-helper](https://github.com/imLinguin/galaxy-helper). Unpack it to your location of choice 12 | - Download / Update overlay 13 | ``` 14 | comet --from-heroic --username overlay --force 15 | ``` 16 | - `HEROIC_APP_NAME` environment variable allows comet to load metadata for given app and give context to overlay itself. (this is required for welcome popup and in-game invites) 17 | - Start comet, at least v0.3.0 18 | - Run the game 19 | 20 | ## Running the game 21 | 22 | Below is the example with UMU. Since pressure vessel shares `/tmp` with host we can ensure the pipes are shared between contaier runtime. 23 | 24 | ``` 25 | GAMEID=0 STEAM_COMPAT_INSTALL_PATH=/game/install/location umu-run galaxy.exe game.exe 26 | ``` 27 | 28 | ### Breakdown 29 | 30 | - STEAM_COMPAT_INSTALL_PATH - is a install location that galaxy-helper uses to determine game details. This path is also used by UMU to ensure it is available in the container runtime. 31 | - GAMEID - standard variable for umu. Used to automatically apply required workarounds for games. 32 | - umu-run - umu entry point 33 | - galaxy.exe - main executable of galaxy-helper. It should be located together with its `libgalaxyunixlib.dll.so`. The exe doesn't need to be in your current directory - just ensure to provide full path to its location. 34 | - game.exe - normal command you'd run to start the game. 35 | 36 | ## Doing this in Heroic (LINUX) 37 | Until overlay support ships in Heroic itself its still possible to add injection code. 38 | You need to create custom wrapper script that will modify the launch command to include path to `galaxy.exe` before game executable itself. Make sure the executable is in location that is accessible from within umu container. 39 | 40 | ```bash 41 | #!/bin/bash 42 | 43 | # Static path to insert 44 | INSERT_PATH="/path/to/galaxy.exe" 45 | 46 | NEW_ARGS=() 47 | PREV_MATCHED=false 48 | 49 | for arg in "$@"; do 50 | if [[ "$PREV_MATCHED" == true ]]; then 51 | NEW_ARGS+=("$INSERT_PATH") 52 | PREV_MATCHED=false 53 | fi 54 | NEW_ARGS+=("$arg") 55 | if [[ "$arg" == *"umu-run" || "$arg" == *"umu-run.py" ]]; then 56 | PREV_MATCHED=true 57 | fi 58 | done 59 | 60 | # If the last argument was umu-run or umu-run.py, add the path at the end 61 | if [[ "$PREV_MATCHED" == true ]]; then 62 | NEW_ARGS+=("$INSERT_PATH") 63 | fi 64 | 65 | # Print the new command for debugging 66 | echo "Modified command: " "${NEW_ARGS[@]}" 67 | 68 | # Execute the modified command 69 | exec "${NEW_ARGS[@]}" 70 | 71 | 72 | ``` 73 | 74 | ## Current limitations 75 | 76 | - Game invitations may not work 77 | - Success of overlay working is bound if comet was able to read up-to date token information. 78 | - Overlay itself is limited to work only when online. It relies on its online services for most features. 79 | 80 | ## Technical details 81 | 82 | How does the galaxy-helper work? Why is it required? 83 | 84 | First we need to understand how communication with overlay occurs. Upon starting the overlay, it gets game pid it needs to attach to via an argument. This information is also used for IPC, overlay connects to two pipes. 85 | 86 | - `\\.\pipe\Galaxy-{pid}-CommunicationService-Overlay` 87 | - `\\.\pipe\Galaxy-{pid}-Overlay-Game` 88 | 89 | The first one is crucial for communication with what acts as a Galaxy client, in this case comet. Since those pipes are only visible in Wine and comet runs natively on Linux, we need a way to communicate these two. That's where galaxy-helper comes in. 90 | 91 | When you start the galaxy.exe it 92 | 93 | - scans `STEAM_COMPAT_INSTALL_PATH` for game details 94 | - looks for game executable to be available 95 | - when executable is found it injects the overlay and contacts comet to let it know about the pid 96 | - after that galaxy-helper communicates `\\.\pipe\Galaxy-{pid}-CommunicationService-Overlay` and `/tmp/Galaxy-{pid}-CommunicationService-Overlay` together. 97 | 98 | ### Why the weird /tmp location 99 | 100 | It mostly comes down on what the client libraries shipped with games are ready for. Since their IPC code is generic for all unix OS, it follows the same pattern as it would on Mac. If we ever get a native overlay for Linux, it would communicate through `/tmp/Galaxy-{pid}-Overlay-Game` and the game itself would expect it there. 101 | -------------------------------------------------------------------------------- /docs/wiki/steamdeck/Usage.md: -------------------------------------------------------------------------------- 1 | # Using with the Steam Deck 2 | 3 | Using Comet with Valve's Steam Deck (running SteamOS) is possible in both Desktop and Game Mode. Comet will only function with games that do support [GOG Galaxy's achievement system](https://www.gog.com/games?features=achievements) or any other online related functionality like Leaderboards. **If your game does not work out of the box, check Known Issues below.** 4 | 5 | Using `comet_shortcut.sh` will simplify the process of launching GOG games from Heroic or Lutris with Comet in the background. That script works in both Modes. 6 | 7 | This instruction also applies for desktop users. If you don't use any launcher see the project [README](https://github.com/imLinguin/comet#via-gogdl-cli) for manual credentials management. 8 | 9 | 10 | ## Installation steps 11 | 12 | 1. Make sure you are logged into GOG on your launcher of choice. 13 | 2. Download the latest release of Comet from [the latest release](https://github.com/imLinguin/comet/releases/latest) labelled `steam-deck.zip`. 14 | 3. Extract the downloaded archive to a desired place. 15 | > It is recommended to have the `comet` binary put into the `~/Documents` directory. Otherwise: choose any directory where your app of choice has access to. 16 | > Ensure that the file is Executable in Properties > Permissions 17 | 4. Open the `comet_shortcut.sh` file with Kate (right click on the file > Open with Kate), and edit the following values: 18 | - `gog_username` 19 | > Change the `username` value after `=` to your GOG username. If your name includes any special characters make sure to quote the username accordingly 20 | - `path_to_comet` 21 | > Change the `path_to_comet` value after `=` (while keeping the `'` characters in tact) to the full file path of the `comet` binary. 22 | 5. Start any game that has the shortcut script included (see instructions for [Heroic](#use-with-heroic) or [Lutris](#use-with-lutris)) to play the game with achievement support! 23 | 24 | > [!NOTE] 25 | > On the startup comet downloads `GalaxyPeer` libraries (~100 MiB) into `$XDG_DATA_HOME/comet/redist/peer`. 26 | > The download is triggered if there is an update available or if the files aren't downloaded already. 27 | > On Mac, both native and windows libraries are being downloaded 28 | 29 | 30 | ## Use with Heroic 31 | 32 | > [!NOTE] 33 | > Starting with Heroic 2.15.0, comet will be setup automatically for every GOG game by Heroic itself. 34 | 35 | 1. Install Comet and its shortcut script. (See the installation steps above.) 36 | 2. In Heroic Games Launcher, set the `comet_shortcut.sh` as a script that is going to be ran before the game (Game Settings > Advanced > Scripts) 37 | 4. Launch the game through either Desktop or Game Mode! 38 | 5. **Directly exiting the game through Steam will not sync your GOG playtime via Heroic!** Make sure to always exit the game via in-game menu. 39 | 40 | > [!TIP] 41 | > Use Heroic's Add to Steam feature for the best experience 42 | > in accessing your games from the Gaming Mode 43 | 44 | ## Use with Lutris 45 | 46 | If you want to use the same script with Lutris, modify it to include `--from-lutris` instead of `--from-heroic`. You need to be logged in the GOG runner in Lutris. 47 | 48 | Steps to add a script to Lutris game 49 | 50 | 1. Install Comet and its shortcut script. (See the installation steps above.) 51 | 2. Right click on the game and click Configure 52 | 3. Head over to `System options` and enable `Advanced` mode (next to save button) 53 | 4. Scroll down into `Game execution` section and set the path to `comet_shortcut.sh` as Command prefix or Pre-launch script. 54 | 5. Launch the game normally! 55 | 56 | > [!TIP] 57 | > Setting the script as Command prefix will allow Lutris to wait for both comet and game process. 58 | > Thus it's a recommended way 59 | 60 | ## Known Issues 61 | 62 | - **Not all GOG games are supported out of the box** - some games (e.g. [Cuphead](https://www.gog.com/game/cuphead)) do not support the way Comet works on its own, due to some checks performed by SDK used for GOG Galaxy features. 63 | 64 | **To solve it**: you will need to install the `GalaxyCommunications` dummy application. (The necessary files (the `.bat` script and the dummy `.exe`) have been included in the archive together with comet binary.) 65 | 66 | 1. Keep the `comet` Linux artifact items in a directory Heroic has access to, such as `~/Documents` or `~/Desktop`. 67 | 2. Go to Heroic Games Launcher, to the malfunctioning game's settings screen.. 68 | 3. Scroll down the WINE tab of the game's settings screen until you see `RUN EXE ON PREFIX`. 69 | 4. Drag and drop the `install-dummy-service.bat` onto `RUN EXE ON PREFIX` to install the dummy service for the game to detect. 70 | 5. Play the game as you would expect. It should now function with Comet's features! 71 | 72 | - Currently there is no visible feedback on when the achievement has been unlocked, there is a [decky-loader plugin planned](https://github.com/imLinguin/comet/issues/18) 73 | 74 | ## Offline support 75 | 76 | Comet should be able to register achievements and statistics while offline just fine and report them to the server next time you play the game online. 77 | 78 | **Please make sure to report issues if you encounter any.** 79 | 80 | -------------------------------------------------------------------------------- /dummy-service/README.md: -------------------------------------------------------------------------------- 1 | # GalaxyCommunication.exe 2 | 3 | A dummy Windows service for Galaxy64.dll 4 | 5 | This is the service that gets woken up by game process when Galaxy is not running already. 6 | 7 | ## Usage 8 | > [!TIP] 9 | > For easy installation, have `GalaxyCommunication.exe` and `install-dummy-service.bat` both in a folder Heroic has access to and run the script. 10 | > 11 | > For Heroic Games Launcher users on Linux and Mac: run the `.bat` file by dragging it into the `RUN EXE ON PREFIX` box of the game's WINE settings (in Heroic). 12 | 13 | This is only to be used within wine environment or on Windows (????), where GOG Galaxy isn't installed. 14 | 15 | In order to leverage this 16 | 1. Download or build GalaxyCommunication.exe 17 | 2. Register the service using the following command or `install-dummy-service.bat` script 18 | 19 | > [!NOTE] 20 | > On Windows you may need admin privileges 21 | 22 | ```shell 23 | sc create GalaxyCommunication binpath= 24 | ``` 25 | `` is an absolute path of downloaded GalaxyCommunication.exe like `C:\\ProgramData\\GOG.com\\Galaxy\\redists\\GalaxyCommunication.exe` 26 | 27 | In case of Wine/Proton make sure to run the command above in the context of your prefix. 28 | 29 | ## Building 30 | 31 | Use 32 | ```shell 33 | meson setup builddir 34 | ``` 35 | 36 | For cross compilation on Linux/Mac make sure `mingw` is installed and add a `--cross-file meson/x86_64-w64-mingw32.ini` to the setup command 37 | 38 | ```shell 39 | meson compile -C builddir 40 | `` 41 | -------------------------------------------------------------------------------- /dummy-service/communication.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | SERVICE_STATUS g_ServiceStatus = {0}; 5 | SERVICE_STATUS_HANDLE g_StatusHandle = NULL; 6 | HANDLE g_ServiceStopEvent = INVALID_HANDLE_VALUE; 7 | 8 | #define SERVICE_NAME "GalaxyCommunication" 9 | 10 | VOID WINAPI ServiceMain (DWORD argc, LPTSTR *argv); 11 | VOID WINAPI ServiceCtrlHandler (DWORD); 12 | DWORD WINAPI ServiceWorkerThread (LPVOID lpParam); 13 | 14 | int main() { 15 | SERVICE_TABLE_ENTRY ServiceTable[] = { 16 | {SERVICE_NAME, (LPSERVICE_MAIN_FUNCTION)ServiceMain}, 17 | {NULL, NULL} 18 | }; 19 | 20 | if (StartServiceCtrlDispatcher(ServiceTable) == FALSE) { 21 | return GetLastError(); 22 | } 23 | 24 | return 0; 25 | } 26 | 27 | VOID WINAPI ServiceMain (DWORD argc, LPTSTR *argv) { 28 | DWORD Status = E_FAIL; 29 | 30 | g_StatusHandle = RegisterServiceCtrlHandler(SERVICE_NAME, ServiceCtrlHandler); 31 | 32 | if (g_StatusHandle == NULL) { 33 | return; 34 | } 35 | 36 | g_ServiceStopEvent = CreateEvent(NULL, TRUE, FALSE, NULL); 37 | if (g_ServiceStopEvent == NULL) { 38 | g_ServiceStatus.dwCurrentState = SERVICE_STOPPED; 39 | g_ServiceStatus.dwWin32ExitCode = GetLastError(); 40 | SetServiceStatus(g_StatusHandle, &g_ServiceStatus); 41 | return; 42 | } 43 | 44 | g_ServiceStatus.dwServiceType = SERVICE_WIN32_OWN_PROCESS; 45 | g_ServiceStatus.dwCurrentState = SERVICE_START_PENDING; 46 | g_ServiceStatus.dwControlsAccepted = SERVICE_ACCEPT_STOP; 47 | g_ServiceStatus.dwWin32ExitCode = 0; 48 | g_ServiceStatus.dwServiceSpecificExitCode = 0; 49 | g_ServiceStatus.dwCheckPoint = 0; 50 | 51 | SetServiceStatus(g_StatusHandle, &g_ServiceStatus); 52 | 53 | Status = ServiceWorkerThread(NULL); 54 | 55 | g_ServiceStatus.dwCurrentState = SERVICE_STOPPED; 56 | g_ServiceStatus.dwWin32ExitCode = Status; 57 | SetServiceStatus(g_StatusHandle, &g_ServiceStatus); 58 | 59 | return; 60 | } 61 | 62 | VOID WINAPI ServiceCtrlHandler (DWORD CtrlCode) { 63 | switch (CtrlCode) { 64 | case SERVICE_CONTROL_STOP: 65 | if (g_ServiceStatus.dwCurrentState != SERVICE_RUNNING) 66 | break; 67 | g_ServiceStatus.dwCurrentState = SERVICE_STOP_PENDING; 68 | SetServiceStatus(g_StatusHandle, &g_ServiceStatus); 69 | SetEvent(g_ServiceStopEvent); 70 | return; 71 | 72 | default: 73 | break; 74 | } 75 | } 76 | 77 | DWORD WINAPI ServiceWorkerThread (LPVOID lpParam) { 78 | while (WaitForSingleObject(g_ServiceStopEvent, 10000) != WAIT_OBJECT_0) {} 79 | return ERROR_SUCCESS; 80 | } 81 | -------------------------------------------------------------------------------- /dummy-service/install-dummy-service.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | SET currdir=%~dp0 4 | SET targetdir=C:\ProgramData\GOG.com\Galaxy\redists 5 | 6 | sc create GalaxyCommunication binpath=%targetdir%\GalaxyCommunication.exe 7 | if not exist "%targetdir%" mkdir %targetdir% 8 | xcopy /y /q %currdir%GalaxyCommunication.exe %targetdir% 9 | %currdir%update-permissions.exe 10 | -------------------------------------------------------------------------------- /dummy-service/meson.build: -------------------------------------------------------------------------------- 1 | project('dummy-service', 'c', 2 | version : '0.1', 3 | default_options : ['warning_level=3']) 4 | 5 | executable('GalaxyCommunication', 6 | 'communication.c') 7 | 8 | executable('update-permissions', 'permissions.c') 9 | -------------------------------------------------------------------------------- /dummy-service/meson/x86_64-w64-mingw32.ini: -------------------------------------------------------------------------------- 1 | [properties] 2 | needs_exe_wrapper = true 3 | 4 | [binaries] 5 | c = 'x86_64-w64-mingw32-gcc' 6 | cpp = 'x86_64-w64-mingw32-g++' 7 | ar = 'x86_64-w64-mingw32-ar' 8 | strip = 'x86_64-w64-mingw32-strip' 9 | pkg-config = 'x86_64-w64-mingw32-pkg-config' 10 | windres = 'x86_64-w64-mingw32-windres' 11 | 12 | exe_wrapper = 'wine64' 13 | 14 | [host_machine] 15 | system = 'windows' 16 | cpu_family = 'x86_64' 17 | cpu = 'x86_64' 18 | endian = 'little' 19 | -------------------------------------------------------------------------------- /dummy-service/permissions.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | // based on https://learn.microsoft.com/en-en/windows/win32/services/svccontrol-cpp 6 | 7 | SC_HANDLE schSCManager; 8 | SC_HANDLE schService; 9 | 10 | EXPLICIT_ACCESS ea; 11 | SECURITY_DESCRIPTOR sd; 12 | PSECURITY_DESCRIPTOR psd = NULL; 13 | PACL pacl = NULL; 14 | PACL pNewAcl = NULL; 15 | BOOL bDaclPresent = FALSE; 16 | BOOL bDaclDefaulted = FALSE; 17 | DWORD dwError = 0; 18 | DWORD dwSize = 0; 19 | DWORD dwBytesNeeded = 0; 20 | 21 | int main(int argc, char** argv) { 22 | 23 | schSCManager = OpenSCManager( 24 | NULL, // local computer 25 | NULL, // ServicesActive database 26 | SC_MANAGER_ALL_ACCESS); // full access rights 27 | 28 | if (NULL == schSCManager) 29 | { 30 | printf("OpenSCManager failed (%ld)\n", GetLastError()); 31 | return 1; 32 | } 33 | 34 | // Get a handle to the service 35 | 36 | schService = OpenService( 37 | schSCManager, // SCManager database 38 | "GalaxyCommunication", // name of service 39 | READ_CONTROL | WRITE_DAC); // access 40 | 41 | if (schService == NULL) 42 | { 43 | printf("OpenService failed (%ld)\n", GetLastError()); 44 | CloseServiceHandle(schSCManager); 45 | return 1; 46 | } 47 | 48 | // Get the current security descriptor. 49 | 50 | if (!QueryServiceObjectSecurity(schService, 51 | DACL_SECURITY_INFORMATION, 52 | &psd, // using NULL does not work on all versions 53 | 0, 54 | &dwBytesNeeded)) 55 | { 56 | if (GetLastError() == ERROR_INSUFFICIENT_BUFFER) 57 | { 58 | dwSize = dwBytesNeeded; 59 | psd = (PSECURITY_DESCRIPTOR)HeapAlloc(GetProcessHeap(), 60 | HEAP_ZERO_MEMORY, dwSize); 61 | if (psd == NULL) 62 | { 63 | // Note: HeapAlloc does not support GetLastError. 64 | printf("HeapAlloc failed\n"); 65 | goto dacl_cleanup; 66 | } 67 | 68 | if (!QueryServiceObjectSecurity(schService, 69 | DACL_SECURITY_INFORMATION, psd, dwSize, &dwBytesNeeded)) 70 | { 71 | printf("QueryServiceObjectSecurity failed (%ld)\n", GetLastError()); 72 | goto dacl_cleanup; 73 | } 74 | } 75 | else 76 | { 77 | printf("QueryServiceObjectSecurity failed (%ld)\n", GetLastError()); 78 | goto dacl_cleanup; 79 | } 80 | } 81 | 82 | // Get the DACL. 83 | 84 | if (!GetSecurityDescriptorDacl(psd, &bDaclPresent, &pacl, 85 | &bDaclDefaulted)) 86 | { 87 | printf("GetSecurityDescriptorDacl failed(%ld)\n", GetLastError()); 88 | goto dacl_cleanup; 89 | } 90 | 91 | // Build the ACE. 92 | 93 | BuildExplicitAccessWithName(&ea, TEXT("EVERYONE"), 94 | SERVICE_START | SERVICE_STOP | READ_CONTROL, 95 | SET_ACCESS, NO_INHERITANCE); 96 | 97 | dwError = SetEntriesInAcl(1, &ea, pacl, &pNewAcl); 98 | if (dwError != ERROR_SUCCESS) 99 | { 100 | printf("SetEntriesInAcl failed(%ld)\n", dwError); 101 | goto dacl_cleanup; 102 | } 103 | 104 | // Initialize a new security descriptor. 105 | 106 | if (!InitializeSecurityDescriptor(&sd, 107 | SECURITY_DESCRIPTOR_REVISION)) 108 | { 109 | printf("InitializeSecurityDescriptor failed(%ld)\n", GetLastError()); 110 | goto dacl_cleanup; 111 | } 112 | 113 | // Set the new DACL in the security descriptor. 114 | 115 | if (!SetSecurityDescriptorDacl(&sd, TRUE, pNewAcl, FALSE)) 116 | { 117 | printf("SetSecurityDescriptorDacl failed(%ld)\n", GetLastError()); 118 | goto dacl_cleanup; 119 | } 120 | 121 | // Set the new DACL for the service object. 122 | 123 | if (!SetServiceObjectSecurity(schService, 124 | DACL_SECURITY_INFORMATION, &sd)) 125 | { 126 | printf("SetServiceObjectSecurity failed(%ld)\n", GetLastError()); 127 | goto dacl_cleanup; 128 | } 129 | else printf("Service DACL updated successfully\n"); 130 | 131 | dacl_cleanup: 132 | CloseServiceHandle(schSCManager); 133 | CloseServiceHandle(schService); 134 | 135 | if(NULL != pNewAcl) 136 | LocalFree((HLOCAL)pNewAcl); 137 | if(NULL != psd) 138 | HeapFree(GetProcessHeap(), 0, (LPVOID)psd); 139 | 140 | 141 | return 0; 142 | } 143 | -------------------------------------------------------------------------------- /external/comet logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imLinguin/comet/db4635045814359dfa0e107e978a064f2e152ad3/external/comet logo.png -------------------------------------------------------------------------------- /src/api.rs: -------------------------------------------------------------------------------- 1 | pub mod gog; 2 | pub mod handlers; 3 | pub mod notification_pusher; 4 | 5 | pub mod structs { 6 | use chrono::{DateTime, Utc}; 7 | use serde::Deserialize; 8 | 9 | #[derive(Debug, Clone, Deserialize)] 10 | pub struct Token { 11 | pub access_token: String, 12 | pub refresh_token: String, 13 | #[serde(skip, default = "Utc::now")] 14 | pub obtain_time: DateTime, 15 | #[serde(default)] 16 | pub scope: Option, 17 | } 18 | impl Token { 19 | pub fn new(access_token: String, refresh_token: String) -> Self { 20 | Self { 21 | access_token, 22 | refresh_token, 23 | obtain_time: Utc::now(), 24 | scope: None, 25 | } 26 | } 27 | } 28 | 29 | #[derive(Debug, Clone, Deserialize)] 30 | pub struct UserInfo { 31 | pub username: String, 32 | #[serde(rename = "galaxyUserId")] 33 | pub galaxy_user_id: String, 34 | } 35 | 36 | #[derive(PartialEq)] 37 | pub enum DataSource { 38 | Online, 39 | Local, 40 | } 41 | 42 | pub enum IDType { 43 | Unassigned(u64), 44 | Lobby(u64), 45 | User(u64), 46 | } 47 | 48 | impl IDType { 49 | /// Parse entity id to this enum 50 | pub fn parse(id: u64) -> Self { 51 | let flag = id >> 56; 52 | let new_value = id << 8 >> 8; 53 | match flag { 54 | 1 => Self::Lobby(new_value), 55 | 2 => Self::User(new_value), 56 | _ => Self::Unassigned(new_value), 57 | } 58 | } 59 | /// Return entity id with magic flag 60 | pub fn value(&self) -> u64 { 61 | match self { 62 | Self::Unassigned(id) => *id, 63 | Self::Lobby(id) => 1 << 56 | id, 64 | Self::User(id) => 2 << 56 | id, 65 | } 66 | } 67 | /// Return underlying entity id 68 | pub fn inner(&self) -> u64 { 69 | match self { 70 | Self::Unassigned(id) | Self::Lobby(id) | Self::User(id) => *id, 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/api/gog.rs: -------------------------------------------------------------------------------- 1 | pub mod achievements; 2 | pub mod components; 3 | pub mod leaderboards; 4 | pub mod overlay; 5 | pub mod stats; 6 | pub mod users; 7 | -------------------------------------------------------------------------------- /src/api/gog/achievements.rs: -------------------------------------------------------------------------------- 1 | use crate::api::handlers::context::HandlerContext; 2 | use crate::api::handlers::error::{MessageHandlingError, MessageHandlingErrorKind}; 3 | use crate::constants::TokenStorage; 4 | use derive_getters::Getters; 5 | use reqwest::{Client, Error}; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | #[derive(Deserialize, Debug, Clone, Getters)] 9 | pub struct Achievement { 10 | pub achievement_id: String, 11 | pub achievement_key: String, 12 | pub name: String, 13 | pub description: String, 14 | pub image_url_locked: String, 15 | pub image_url_unlocked: String, 16 | pub visible: bool, 17 | pub date_unlocked: Option, 18 | pub rarity: f32, 19 | pub rarity_level_description: String, 20 | pub rarity_level_slug: String, 21 | } 22 | 23 | impl Achievement { 24 | #[allow(clippy::too_many_arguments)] 25 | pub fn new( 26 | achievement_id: String, 27 | achievement_key: String, 28 | name: String, 29 | description: String, 30 | image_url_locked: String, 31 | image_url_unlocked: String, 32 | visible: bool, 33 | date_unlocked: Option, 34 | rarity: f32, 35 | rarity_level_description: String, 36 | rarity_level_slug: String, 37 | ) -> Self { 38 | Self { 39 | achievement_id, 40 | achievement_key, 41 | name, 42 | description, 43 | image_url_unlocked, 44 | image_url_locked, 45 | visible, 46 | date_unlocked, 47 | rarity, 48 | rarity_level_slug, 49 | rarity_level_description, 50 | } 51 | } 52 | } 53 | 54 | #[derive(Deserialize, Debug, Getters)] 55 | pub struct AchievementsResponse { 56 | total_count: u32, 57 | limit: u32, 58 | page_token: String, 59 | items: Vec, 60 | achievements_mode: String, 61 | } 62 | 63 | pub async fn fetch_achievements( 64 | token_store: &TokenStorage, 65 | client_id: &str, 66 | user_id: &str, 67 | reqwest_client: &Client, 68 | ) -> Result<(Vec, String), MessageHandlingError> { 69 | let lock = token_store.lock().await; 70 | let token = lock 71 | .get(client_id) 72 | .ok_or(MessageHandlingError::new( 73 | MessageHandlingErrorKind::Unauthorized, 74 | ))? 75 | .clone(); 76 | drop(lock); 77 | 78 | let url = format!( 79 | "https://gameplay.gog.com/clients/{}/users/{}/achievements", 80 | client_id, user_id 81 | ); 82 | let response = reqwest_client 83 | .get(url) 84 | .bearer_auth(token.access_token) 85 | .header("X-Gog-Lc", crate::LOCALE.as_str()) 86 | .send() 87 | .await 88 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Network(err)))?; 89 | 90 | let achievements_data = response 91 | .json::() 92 | .await 93 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Network(err)))?; 94 | 95 | Ok((achievements_data.items, achievements_data.achievements_mode)) 96 | } 97 | 98 | #[derive(Serialize)] 99 | struct SetAchievementRequest { 100 | date_unlocked: Option, 101 | } 102 | 103 | impl SetAchievementRequest { 104 | pub fn new(date_unlocked: Option) -> Self { 105 | Self { date_unlocked } 106 | } 107 | } 108 | 109 | pub async fn set_achievement( 110 | context: &HandlerContext, 111 | reqwest_client: &Client, 112 | user_id: &str, 113 | achievement_id: &str, 114 | date_unlocked: Option, 115 | ) -> Result<(), Error> { 116 | let lock = context.token_store().lock().await; 117 | let client_id = context.client_id().await.unwrap(); 118 | let token = lock.get(&client_id).unwrap().clone(); 119 | drop(lock); 120 | let url = format!( 121 | "https://gameplay.gog.com/clients/{}/users/{}/achievements/{}", 122 | &client_id, user_id, achievement_id 123 | ); 124 | let body = SetAchievementRequest::new(date_unlocked); 125 | 126 | let response = reqwest_client 127 | .post(url) 128 | .json(&body) 129 | .bearer_auth(token.access_token) 130 | .send() 131 | .await?; 132 | response.error_for_status()?; 133 | Ok(()) 134 | } 135 | 136 | pub async fn delete_achievements( 137 | context: &HandlerContext, 138 | reqwest_client: &Client, 139 | user_id: &str, 140 | ) -> Result<(), Error> { 141 | let lock = context.token_store().lock().await; 142 | let client_id = context.client_id().await.unwrap(); 143 | let token = lock.get(&client_id).unwrap().clone(); 144 | drop(lock); 145 | let url = format!( 146 | "https://gameplay.gog.com/clients/{}/users/{}/achievements", 147 | &client_id, user_id 148 | ); 149 | 150 | let response = reqwest_client 151 | .delete(url) 152 | .bearer_auth(token.access_token) 153 | .send() 154 | .await?; 155 | response.error_for_status()?; 156 | 157 | Ok(()) 158 | } 159 | -------------------------------------------------------------------------------- /src/api/gog/components.rs: -------------------------------------------------------------------------------- 1 | use async_zip::base::read::mem::ZipFileReader; 2 | use derive_getters::Getters; 3 | use futures::StreamExt; 4 | use futures_util::io; 5 | use serde::{Deserialize, Serialize}; 6 | use std::time::Duration; 7 | use std::{fmt::Display, path::PathBuf, time::Instant}; 8 | use tokio::fs; 9 | use tokio_util::compat::TokioAsyncWriteCompatExt; 10 | 11 | use reqwest::Client; 12 | 13 | #[allow(dead_code)] 14 | pub enum Platform { 15 | Windows, 16 | Mac, 17 | } 18 | 19 | impl Display for Platform { 20 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 21 | match self { 22 | Self::Windows => f.write_str("windows"), 23 | Self::Mac => f.write_str("osx"), 24 | } 25 | } 26 | } 27 | 28 | #[derive(Serialize, Deserialize, Getters, Debug)] 29 | #[serde(rename_all = "camelCase")] 30 | struct ComponentManifest { 31 | application_type: String, 32 | #[serde(rename = "baseURI")] 33 | base_uri: String, 34 | files: Vec, 35 | force_update: bool, 36 | project_name: String, 37 | symlinks: Vec, 38 | timestamp: String, 39 | version: String, 40 | } 41 | 42 | #[derive(Serialize, Deserialize, Getters, Debug)] 43 | struct ComponentFile { 44 | hash: String, 45 | path: String, 46 | resource: String, 47 | sha256: String, 48 | size: u32, 49 | } 50 | 51 | #[derive(Serialize, Deserialize, Getters, Debug)] 52 | struct ComponentSymlink { 53 | path: String, 54 | target: String, 55 | } 56 | 57 | #[derive(Debug)] 58 | pub enum Component { 59 | Peer, 60 | Overlay, 61 | Web, 62 | } 63 | 64 | #[derive(Serialize, Deserialize, Debug, Default)] 65 | struct ComponentManifestLocal { 66 | pub time: i64, 67 | pub version: String, 68 | } 69 | 70 | impl Display for Component { 71 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 72 | match self { 73 | Self::Peer => f.write_str("desktop-galaxy-peer"), 74 | Self::Overlay => f.write_str("desktop-galaxy-overlay"), 75 | Self::Web => f.write_str("desktop-galaxy-client"), 76 | } 77 | } 78 | } 79 | 80 | pub async fn get_component( 81 | reqwest_client: &Client, 82 | dest_path: PathBuf, 83 | platform: Platform, 84 | component: Component, 85 | ) -> Result<(), Box> { 86 | let manifest_path = dest_path.join(format!(".{}-{}.toml", component, platform)); 87 | let local_manifest: ComponentManifestLocal = match fs::read_to_string(&manifest_path).await { 88 | Ok(manifest_str) => toml::from_str(&manifest_str).unwrap_or_default(), 89 | Err(err) => { 90 | log::debug!("Failed to read component manifest {err:?}"); 91 | ComponentManifestLocal::default() 92 | } 93 | }; 94 | if local_manifest.time + (24 * 3600) > chrono::Utc::now().timestamp() { 95 | return Ok(()); 96 | } 97 | log::debug!("Checking for peer updates"); 98 | let url = format!( 99 | "https://cfg.gog.com/{}/7/master/files-{}.json", 100 | component, platform 101 | ); 102 | 103 | let manifest_res = reqwest_client.get(url).send().await?; 104 | let manifest: ComponentManifest = manifest_res.json().await?; 105 | 106 | if dest_path.exists() { 107 | if local_manifest.version == manifest.version && !manifest.force_update { 108 | return Ok(()); 109 | } 110 | } else { 111 | fs::create_dir_all(&dest_path).await?; 112 | } 113 | 114 | let files_to_dl: Vec<&ComponentFile> = manifest 115 | .files() 116 | .iter() 117 | .filter(|file| !matches!(component, Component::Web) || file.path().starts_with("web")) 118 | .collect(); 119 | 120 | let total_size: u32 = files_to_dl.iter().map(|file| file.size).sum(); 121 | let mut tasks = Vec::with_capacity(files_to_dl.len()); 122 | log::info!( 123 | "Downloading component {} - total download size {:.2} MiB", 124 | component, 125 | (total_size as f32) / 1024.0 / 1024.0 126 | ); 127 | // Download 128 | for file in files_to_dl.into_iter() { 129 | let url = format!("{}/{}", manifest.base_uri(), file.resource()); 130 | let reqwest_client = reqwest_client.clone(); 131 | let file_path = dest_path.join(file.path()); 132 | let parent = file_path.parent(); 133 | if let Some(parent) = parent { 134 | fs::create_dir_all(parent).await?; 135 | } 136 | tasks.push(async move { 137 | let response = reqwest_client.get(url).send().await?; 138 | let data = response.bytes().await?; 139 | 140 | let zip = ZipFileReader::new(data.to_vec()).await?; 141 | 142 | let mut reader = zip.reader_with_entry(0).await?; 143 | let file_handle = fs::File::create(&file_path).await?; 144 | io::copy(&mut reader, &mut file_handle.compat_write()).await?; 145 | #[cfg(unix)] 146 | if let Some(permissions) = reader.entry().unix_permissions() { 147 | use std::{fs::Permissions, os::unix::fs::PermissionsExt}; 148 | let permissions = Permissions::from_mode(permissions as u32); 149 | fs::set_permissions(file_path, permissions).await?; 150 | } 151 | Ok::<_, Box>(file.size) 152 | }); 153 | } 154 | 155 | let mut pending_tasks = futures::stream::iter(tasks).buffer_unordered(4); 156 | 157 | let mut total_dl = 0; 158 | let mut instant = Instant::now(); 159 | while let Some(res) = pending_tasks.next().await { 160 | total_dl += res?; 161 | if instant.elapsed() > Duration::from_secs(1) { 162 | log::info!( 163 | "[{:?}] {:.2} / {:.2}", 164 | component, 165 | total_dl as f32 / 1024.0 / 1024.0, 166 | total_size as f32 / 1024.0 / 1024.0 167 | ); 168 | instant = Instant::now(); 169 | } 170 | } 171 | log::info!( 172 | "[{:?}] {:.2} / {:.2}", 173 | component, 174 | total_dl as f32 / 1024.0 / 1024.0, 175 | total_size as f32 / 1024.0 / 1024.0 176 | ); 177 | 178 | #[cfg(unix)] 179 | for symlink in manifest.symlinks() { 180 | fs::symlink(symlink.target(), dest_path.join(symlink.path())).await?; 181 | } 182 | 183 | let new_manifest = ComponentManifestLocal { 184 | version: manifest.version().clone(), 185 | time: chrono::Utc::now().timestamp(), 186 | }; 187 | let data = toml::to_string(&new_manifest).expect("Failed to serialize local manifest"); 188 | fs::write(manifest_path, data).await?; 189 | 190 | Ok(()) 191 | } 192 | -------------------------------------------------------------------------------- /src/api/gog/leaderboards.rs: -------------------------------------------------------------------------------- 1 | use crate::api::handlers::context::HandlerContext; 2 | use derive_getters::Getters; 3 | use log::debug; 4 | use reqwest::{Client, Url}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Deserialize, Getters, Debug)] 8 | pub struct LeaderboardDefinition { 9 | id: String, 10 | key: String, 11 | name: String, 12 | sort_method: String, 13 | display_type: String, 14 | //locale: String, 15 | //is_localized: bool, 16 | } 17 | 18 | impl LeaderboardDefinition { 19 | pub fn new( 20 | id: String, 21 | key: String, 22 | name: String, 23 | sort_method: String, 24 | display_type: String, 25 | ) -> Self { 26 | Self { 27 | id, 28 | key, 29 | name, 30 | sort_method, 31 | display_type, 32 | } 33 | } 34 | } 35 | 36 | #[derive(Deserialize)] 37 | pub struct LeaderboardsResponse { 38 | pub items: Vec, 39 | } 40 | 41 | pub async fn get_leaderboards( 42 | context: &HandlerContext, 43 | reqwest_client: &Client, 44 | params: I, 45 | ) -> Result, reqwest::Error> 46 | where 47 | I: IntoIterator, 48 | K: AsRef, 49 | V: AsRef, 50 | { 51 | let lock = context.token_store().lock().await; 52 | let client_id = context.client_id().await.unwrap(); 53 | let token = lock.get(&client_id).unwrap().clone(); 54 | drop(lock); 55 | let url = format!( 56 | "https://gameplay.gog.com/clients/{}/leaderboards", 57 | &client_id 58 | ); 59 | 60 | let new_url = Url::parse_with_params(&url, params).unwrap(); 61 | 62 | let response = reqwest_client 63 | .get(new_url) 64 | .bearer_auth(token.access_token) 65 | .header("X-Gog-Lc", crate::LOCALE.as_str()) 66 | .send() 67 | .await?; 68 | 69 | let response_data: LeaderboardsResponse = response.json().await?; 70 | 71 | debug!("Got {} leaderboards", response_data.items.len()); 72 | 73 | Ok(response_data.items) 74 | } 75 | 76 | #[derive(Deserialize)] 77 | pub struct LeaderboardEntry { 78 | pub user_id: String, 79 | pub rank: u32, 80 | pub score: u32, 81 | pub details: Option, 82 | } 83 | 84 | #[derive(Deserialize)] 85 | pub struct LeaderboardEntriesResponse { 86 | pub items: Vec, 87 | pub leaderboard_entry_total_count: u32, 88 | } 89 | 90 | pub async fn get_leaderboards_entries( 91 | context: &HandlerContext, 92 | reqwest_client: &Client, 93 | leaderboard_id: u64, 94 | params: I, 95 | ) -> Result 96 | where 97 | I: IntoIterator, 98 | K: AsRef, 99 | V: AsRef, 100 | { 101 | let lock = context.token_store().lock().await; 102 | let client_id = context.client_id().await.unwrap(); 103 | let token = lock.get(&client_id).unwrap().clone(); 104 | drop(lock); 105 | 106 | let url = format!( 107 | "https://gameplay.gog.com/clients/{}/leaderboards/{}/entries", 108 | &client_id, leaderboard_id 109 | ); 110 | 111 | let new_url = Url::parse_with_params(&url, params).unwrap(); 112 | 113 | let response = reqwest_client 114 | .get(new_url) 115 | .bearer_auth(token.access_token) 116 | .header("X-Gog-Lc", crate::LOCALE.as_str()) 117 | .send() 118 | .await?; 119 | 120 | let response = response.error_for_status()?; 121 | 122 | response.json().await 123 | } 124 | 125 | #[derive(Serialize)] 126 | struct LeaderboardScoreUpdate { 127 | pub score: i32, 128 | pub force: bool, 129 | #[serde(skip_serializing_if = "Option::is_none")] 130 | pub details: Option, 131 | } 132 | 133 | #[derive(Deserialize)] 134 | pub struct LeaderboardScoreUpdateResponse { 135 | pub old_rank: u32, 136 | pub new_rank: u32, 137 | pub leaderboard_entry_total_count: u32, 138 | } 139 | 140 | pub async fn post_leaderboard_score( 141 | context: &HandlerContext, 142 | reqwest_client: &Client, 143 | user_id: &str, 144 | leaderboard_id: i64, 145 | score: i32, 146 | force_update: bool, 147 | details: Option, 148 | ) -> Result { 149 | let lock = context.token_store().lock().await; 150 | let client_id = context.client_id().await.unwrap(); 151 | let token = lock.get(&client_id).unwrap().clone(); 152 | drop(lock); 153 | 154 | let url = format!( 155 | "https://gameplay.gog.com/clients/{}/users/{}/leaderboards/{}", 156 | &client_id, user_id, leaderboard_id 157 | ); 158 | 159 | let payload = LeaderboardScoreUpdate { 160 | score, 161 | force: force_update, 162 | details, 163 | }; 164 | 165 | let response = reqwest_client 166 | .post(url) 167 | .json(&payload) 168 | .bearer_auth(token.access_token) 169 | .send() 170 | .await?; 171 | 172 | let response = response.error_for_status()?; 173 | let data = response.json().await?; 174 | Ok(data) 175 | } 176 | 177 | #[derive(Serialize)] 178 | struct CreateLeaderboardPayload { 179 | pub key: String, 180 | pub name: String, 181 | pub sort_method: String, 182 | pub display_type: String, 183 | } 184 | 185 | pub async fn create_leaderboard( 186 | context: &HandlerContext, 187 | reqwest_client: &Client, 188 | key: String, 189 | name: String, 190 | sort_method: String, 191 | display_type: String, 192 | ) -> Result { 193 | let lock = context.token_store().lock().await; 194 | let client_id = context.client_id().await.unwrap(); 195 | let token = lock.get(&client_id).unwrap().clone(); 196 | drop(lock); 197 | 198 | let payload = CreateLeaderboardPayload { 199 | key, 200 | name, 201 | sort_method, 202 | display_type, 203 | }; 204 | 205 | let url = format!( 206 | "https://gameplay.gog.com/clients/{}/leaderboards", 207 | client_id 208 | ); 209 | 210 | let response = reqwest_client 211 | .post(url) 212 | .json(&payload) 213 | .bearer_auth(token.access_token) 214 | .send() 215 | .await?; 216 | let response = response.error_for_status()?; 217 | 218 | let definition: LeaderboardDefinition = response.json().await?; 219 | 220 | Ok(definition.id) 221 | } 222 | -------------------------------------------------------------------------------- /src/api/gog/overlay.rs: -------------------------------------------------------------------------------- 1 | use super::achievements::Achievement; 2 | 3 | #[derive(Clone, Debug)] 4 | pub enum OverlayPeerMessage { 5 | Achievement(Achievement), 6 | DisablePopups(Vec), 7 | OpenWebPage(String), 8 | InvitationDialog(String), 9 | VisibilityChange(bool), 10 | GameJoin((u64, String, String)), 11 | } 12 | -------------------------------------------------------------------------------- /src/api/gog/stats.rs: -------------------------------------------------------------------------------- 1 | use crate::api::handlers::context::HandlerContext; 2 | use crate::api::handlers::error::{MessageHandlingError, MessageHandlingErrorKind}; 3 | use crate::constants::TokenStorage; 4 | use derive_getters::Getters; 5 | use reqwest::{Client, Error}; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | #[derive(Deserialize, Debug)] 9 | #[serde(tag = "type")] 10 | #[serde(rename_all = "lowercase")] 11 | pub enum FieldValue { 12 | Int { 13 | value: i32, 14 | min_value: Option, 15 | max_value: Option, 16 | max_change: Option, 17 | default_value: Option, 18 | }, 19 | Float { 20 | value: f32, 21 | min_value: Option, 22 | max_value: Option, 23 | max_change: Option, 24 | default_value: Option, 25 | }, 26 | Avgrate { 27 | value: f32, 28 | min_value: Option, 29 | max_value: Option, 30 | max_change: Option, 31 | default_value: Option, 32 | }, 33 | } 34 | 35 | #[derive(Deserialize, Debug, Getters)] 36 | pub struct Stat { 37 | stat_id: String, 38 | stat_key: String, 39 | window: Option, 40 | increment_only: bool, 41 | #[serde(flatten)] 42 | values: FieldValue, 43 | } 44 | 45 | impl Stat { 46 | pub fn new( 47 | stat_id: String, 48 | stat_key: String, 49 | window: Option, 50 | increment_only: bool, 51 | values: FieldValue, 52 | ) -> Self { 53 | Self { 54 | stat_id, 55 | stat_key, 56 | window, 57 | increment_only, 58 | values, 59 | } 60 | } 61 | } 62 | 63 | #[derive(Deserialize, Debug)] 64 | struct StatsResponse { 65 | total_count: u32, 66 | items: Vec, 67 | } 68 | 69 | pub async fn fetch_stats( 70 | token_store: &TokenStorage, 71 | client_id: &str, 72 | user_id: &str, 73 | reqwest_client: &Client, 74 | ) -> Result, MessageHandlingError> { 75 | let lock = token_store.lock().await; 76 | let token = lock 77 | .get(client_id) 78 | .ok_or(MessageHandlingError::new( 79 | MessageHandlingErrorKind::Unauthorized, 80 | ))? 81 | .clone(); 82 | drop(lock); 83 | 84 | let url = format!( 85 | "https://gameplay.gog.com/clients/{}/users/{}/stats", 86 | client_id, user_id 87 | ); 88 | let response = reqwest_client 89 | .get(url) 90 | .bearer_auth(token.access_token) 91 | .send() 92 | .await 93 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Network(err)))?; 94 | 95 | let stats_data = response 96 | .json::() 97 | .await 98 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Network(err)))?; 99 | 100 | Ok(stats_data.items) 101 | } 102 | 103 | #[derive(Serialize)] 104 | #[serde(untagged)] 105 | enum UpdateStatRequestValueType { 106 | Float(f32), 107 | Int(i32), 108 | } 109 | 110 | #[derive(Serialize)] 111 | struct UpdateStatRequest { 112 | value: UpdateStatRequestValueType, 113 | } 114 | 115 | impl UpdateStatRequest { 116 | pub fn new(value: UpdateStatRequestValueType) -> Self { 117 | Self { value } 118 | } 119 | } 120 | 121 | pub async fn update_stat( 122 | context: &HandlerContext, 123 | reqwest_client: &Client, 124 | user_id: &str, 125 | stat: &Stat, 126 | ) -> Result<(), Error> { 127 | let lock = context.token_store().lock().await; 128 | let client_id = context.client_id().await.unwrap(); 129 | let token = lock.get(&client_id).unwrap().clone(); 130 | drop(lock); 131 | 132 | let url = format!( 133 | "https://gameplay.gog.com/clients/{}/users/{}/stats/{}", 134 | &client_id, 135 | user_id, 136 | stat.stat_id() 137 | ); 138 | let value_type = match stat.values { 139 | FieldValue::Float { value, .. } | FieldValue::Avgrate { value, .. } => { 140 | UpdateStatRequestValueType::Float(value) 141 | } 142 | FieldValue::Int { value, .. } => UpdateStatRequestValueType::Int(value), 143 | }; 144 | let payload = UpdateStatRequest::new(value_type); 145 | let response = reqwest_client 146 | .post(url) 147 | .json(&payload) 148 | .bearer_auth(token.access_token) 149 | .send() 150 | .await?; 151 | 152 | response.error_for_status()?; 153 | Ok(()) 154 | } 155 | 156 | pub async fn delete_stats( 157 | context: &HandlerContext, 158 | reqwest_client: &Client, 159 | user_id: &str, 160 | ) -> Result<(), Error> { 161 | let lock = context.token_store().lock().await; 162 | let client_id = context.client_id().await.unwrap(); 163 | let token = lock.get(&client_id).unwrap().clone(); 164 | drop(lock); 165 | 166 | let url = format!( 167 | "https://gameplay.gog.com/clients/{}/users/{}/stats", 168 | &client_id, user_id, 169 | ); 170 | 171 | let response = reqwest_client 172 | .delete(url) 173 | .bearer_auth(token.access_token) 174 | .send() 175 | .await?; 176 | 177 | response.error_for_status()?; 178 | Ok(()) 179 | } 180 | -------------------------------------------------------------------------------- /src/api/gog/users.rs: -------------------------------------------------------------------------------- 1 | use crate::api::structs::Token; 2 | use reqwest::{Client, Error}; 3 | use tokio::time; 4 | 5 | pub async fn get_token_for( 6 | client_id: &str, 7 | client_secret: &str, 8 | refresh_token: &str, 9 | session: &Client, 10 | openid: bool, 11 | ) -> Result { 12 | let mut url = reqwest::Url::parse( 13 | "https://auth.gog.com/token?grant_type=refresh_token&without_new_session=1", 14 | ) 15 | .unwrap(); 16 | url.query_pairs_mut() 17 | .append_pair("client_id", client_id) 18 | .append_pair("client_secret", client_secret) 19 | .append_pair("refresh_token", refresh_token); 20 | 21 | if openid { 22 | url.query_pairs_mut().append_pair("scope", "openid"); 23 | } 24 | 25 | let result = session 26 | .get(url) 27 | .timeout(time::Duration::from_secs(10)) 28 | .send() 29 | .await?; 30 | 31 | let result = result.error_for_status()?; 32 | let token: Token = result.json().await?; 33 | Ok(token) 34 | } 35 | -------------------------------------------------------------------------------- /src/api/handlers/context.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use crate::api::gog::overlay::OverlayPeerMessage; 4 | use crate::constants::TokenStorage; 5 | use crate::db; 6 | use derive_getters::Getters; 7 | use sqlx::SqlitePool; 8 | use tokio::io::AsyncReadExt; 9 | use tokio::net::TcpStream; 10 | use tokio::sync::{broadcast, Mutex, MutexGuard}; 11 | 12 | pub struct State { 13 | is_online: bool, 14 | client_identified: bool, 15 | client_id: Option, 16 | client_secret: Option, 17 | subscribed_topics: HashSet, 18 | updated_achievements: bool, 19 | updated_stats: bool, 20 | updated_leaderboards: bool, 21 | pid: u32, 22 | } 23 | 24 | #[derive(Getters)] 25 | pub struct HandlerContext { 26 | socket: Mutex, 27 | token_store: TokenStorage, 28 | overlay_sender: broadcast::Sender<(u32, OverlayPeerMessage)>, 29 | overlay_listener: Mutex, 30 | #[getter(skip)] 31 | db_connection: Mutex>, 32 | #[getter(skip)] 33 | state: Mutex, 34 | } 35 | 36 | impl HandlerContext { 37 | pub fn new( 38 | socket: TcpStream, 39 | token_store: TokenStorage, 40 | achievement_sender: broadcast::Sender<(u32, OverlayPeerMessage)>, 41 | ) -> Self { 42 | let state = Mutex::new(State { 43 | is_online: false, 44 | client_identified: false, 45 | client_id: None, 46 | client_secret: None, 47 | subscribed_topics: HashSet::new(), 48 | updated_achievements: false, 49 | updated_stats: false, 50 | updated_leaderboards: true, 51 | pid: 0, 52 | }); 53 | Self { 54 | socket: Mutex::new(socket), 55 | token_store, 56 | overlay_sender: achievement_sender, 57 | db_connection: Mutex::new(None), 58 | overlay_listener: Mutex::new(String::default()), 59 | state, 60 | } 61 | } 62 | 63 | pub async fn socket_mut(&self) -> MutexGuard<'_, TcpStream> { 64 | self.socket.lock().await 65 | } 66 | 67 | pub async fn socket_read_u16(&self) -> Result { 68 | self.socket.lock().await.read_u16().await 69 | } 70 | 71 | pub async fn identify_client(&self, client_id: &str, client_secret: &str, pid: u32) { 72 | let mut state = self.state.lock().await; 73 | state.client_identified = true; 74 | state.client_id = Some(client_id.to_string()); 75 | state.client_secret = Some(client_secret.to_string()); 76 | state.pid = pid; 77 | } 78 | 79 | pub async fn set_online(&self) { 80 | self.state.lock().await.is_online = true 81 | } 82 | 83 | pub async fn subscribe_topic(&self, topic: String) { 84 | self.state.lock().await.subscribed_topics.insert(topic); 85 | } 86 | 87 | pub async fn is_subscribed(&self, topic: &String) -> bool { 88 | self.state.lock().await.subscribed_topics.contains(topic) 89 | } 90 | 91 | pub async fn set_offline(&self) { 92 | self.state.lock().await.is_online = false 93 | } 94 | 95 | pub async fn set_updated_achievements(&self, value: bool) { 96 | self.state.lock().await.updated_achievements = value 97 | } 98 | pub async fn set_updated_stats(&self, value: bool) { 99 | self.state.lock().await.updated_stats = value 100 | } 101 | pub async fn set_updated_leaderboards(&self, value: bool) { 102 | self.state.lock().await.updated_leaderboards = value 103 | } 104 | 105 | pub async fn setup_database(&self, client_id: &str, user_id: &str) -> Result<(), sqlx::Error> { 106 | let mut db_con = self.db_connection.lock().await; 107 | if db_con.is_some() { 108 | return Ok(()); 109 | } 110 | let connection = db::gameplay::setup_connection(client_id, user_id).await?; 111 | *db_con = Some(connection); 112 | 113 | let pool = db_con.clone().unwrap(); 114 | let mut connection = pool.acquire().await?; 115 | sqlx::query(db::gameplay::SETUP_QUERY) 116 | .execute(&mut *connection) 117 | .await?; 118 | 119 | // This may already exist, we don't care 120 | let _ = sqlx::query("INSERT INTO database_info VALUES ('language', $1) ON CONFLICT(key) DO UPDATE SET value = excluded.value") 121 | .bind(crate::LOCALE.as_str()) 122 | .execute(&mut *connection) 123 | .await; 124 | 125 | Ok(()) 126 | } 127 | 128 | pub async fn db_connection(&self) -> SqlitePool { 129 | let connection = self.db_connection.lock().await.clone(); 130 | connection.unwrap() 131 | } 132 | 133 | pub async fn client_id(&self) -> Option { 134 | self.state.lock().await.client_id.clone() 135 | } 136 | 137 | pub async fn client_secret(&self) -> Option { 138 | self.state.lock().await.client_secret.clone() 139 | } 140 | 141 | pub async fn is_online(&self) -> bool { 142 | self.state.lock().await.is_online 143 | } 144 | 145 | pub async fn client_identified(&self) -> bool { 146 | self.state.lock().await.client_identified 147 | } 148 | 149 | pub async fn updated_achievements(&self) -> bool { 150 | self.state.lock().await.updated_achievements 151 | } 152 | pub async fn updated_stats(&self) -> bool { 153 | self.state.lock().await.updated_stats 154 | } 155 | pub async fn updated_leaderboards(&self) -> bool { 156 | self.state.lock().await.updated_leaderboards 157 | } 158 | 159 | pub async fn get_pid(&self) -> u32 { 160 | self.state.lock().await.pid 161 | } 162 | 163 | pub async fn register_overlay_listener(&self, pid: u32, listener: String) { 164 | let mut ov_listener = self.overlay_listener.lock().await; 165 | self.state.lock().await.pid = pid; 166 | *ov_listener = listener; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/api/handlers/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub enum MessageHandlingErrorKind { 3 | NotImplemented, 4 | Ignored, 5 | Unauthorized, 6 | IO(tokio::io::Error), 7 | DB(sqlx::Error), 8 | Network(reqwest::Error), 9 | Proto(protobuf::Error), 10 | Json(serde_json::Error), 11 | } 12 | 13 | #[derive(Debug)] 14 | pub struct MessageHandlingError { 15 | pub kind: MessageHandlingErrorKind, 16 | } 17 | 18 | impl std::error::Error for MessageHandlingError {} 19 | 20 | impl MessageHandlingError { 21 | pub fn new(kind: MessageHandlingErrorKind) -> MessageHandlingError { 22 | MessageHandlingError { kind } 23 | } 24 | } 25 | 26 | impl std::fmt::Display for MessageHandlingError { 27 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 28 | write!(f, "") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/api/handlers/overlay_client.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::api::structs::UserInfo; 4 | use crate::proto::galaxy_protocols_overlay_for_client::*; 5 | use crate::proto::{common_utils::ProtoPayload, gog_protocols_pb}; 6 | use crate::CONFIG; 7 | use log::warn; 8 | use protobuf::{Enum, Message}; 9 | use serde_json::json; 10 | 11 | use super::{context::HandlerContext, MessageHandlingError, MessageHandlingErrorKind}; 12 | 13 | // THIS CODE ARE MOSTLY STUBS FOR OVERLAY 14 | // The Galaxy Overlay has ties with GOG Galaxy Client, and expects support for the same methods 15 | // that are normally handled via CEF's IPC. 16 | 17 | pub async fn entry_point( 18 | payload: &ProtoPayload, 19 | context: &HandlerContext, 20 | user_info: Arc, 21 | reqwest_client: &reqwest::Client, 22 | ) -> Result { 23 | let header = &payload.header; 24 | 25 | let message_type: i32 = header.type_().try_into().unwrap(); 26 | 27 | if message_type == MessageType::OVERLAY_FRONTEND_INIT_DATA_REQUEST.value() { 28 | overlay_data_request(payload, context, user_info, reqwest_client).await 29 | } else if message_type == MessageType::OVERLAY_TO_CLIENT_REQUEST.value() { 30 | client_request(payload, context, reqwest_client).await 31 | } else { 32 | warn!( 33 | "Received unsupported ov_client message type {}", 34 | message_type 35 | ); 36 | Err(MessageHandlingError::new( 37 | MessageHandlingErrorKind::NotImplemented, 38 | )) 39 | } 40 | } 41 | 42 | async fn overlay_data_request( 43 | _payload: &ProtoPayload, 44 | _context: &HandlerContext, 45 | user_info: Arc, 46 | reqwest_client: &reqwest::Client, 47 | ) -> Result { 48 | let game_id: String = std::env::var("HEROIC_APP_NAME").unwrap_or_default(); 49 | let default_data = json! ({ 50 | "id": "", 51 | "title": "Comet", 52 | "images": { 53 | "icon": "https://raw.githubusercontent.com/Heroic-Games-Launcher/HeroicGamesLauncher/main/public/icon.png", 54 | "logo": "https://raw.githubusercontent.com/Heroic-Games-Launcher/HeroicGamesLauncher/main/public/icon.png", 55 | "logo2x": "https://raw.githubusercontent.com/Heroic-Games-Launcher/HeroicGamesLauncher/main/public/icon.png", 56 | } 57 | }); 58 | let game_details = if !game_id.is_empty() { 59 | if let Ok(res) = reqwest_client 60 | .get(format!("https://api.gog.com/products/{}", game_id)) 61 | .send() 62 | .await 63 | { 64 | if let Ok(mut res) = res.json::().await { 65 | if let Some(serde_json::Value::Object(ref mut images)) = res.get_mut("images") { 66 | for (_key, url_value) in images.iter_mut() { 67 | if let serde_json::Value::String(url) = url_value { 68 | if url.starts_with("//") { 69 | *url_value = serde_json::Value::String(format!("https:{}", url)); 70 | } 71 | } 72 | } 73 | } 74 | res 75 | } else { 76 | default_data 77 | } 78 | } else { 79 | default_data 80 | } 81 | } else { 82 | default_data 83 | }; 84 | 85 | #[cfg(not(debug_assertions))] 86 | let log_level = 5; 87 | #[cfg(debug_assertions)] 88 | let log_level = 8; 89 | 90 | let init_data = json!( 91 | { 92 | "Languages": [ 93 | { "Code": "en", "EnglishName": "English", "NativeName": "English" }, 94 | { "Code": "de", "EnglishName": "German", "NativeName": "Deutsch" }, 95 | { "Code": "fr", "EnglishName": "French", "NativeName": "Français" }, 96 | { "Code": "ru", "EnglishName": "Russian", "NativeName": "Русский" }, 97 | { "Code": "pl", "EnglishName": "Polish", "NativeName": "Polski" }, 98 | { "Code": "es", "EnglishName": "Spanish", "NativeName": "Español" }, 99 | { "Code": "it", "EnglishName": "Italian", "NativeName": "Italiano" }, 100 | { "Code": "jp", "EnglishName": "Japanese", "NativeName": "日本語" }, 101 | { "Code": "ko", "EnglishName": "Korean", "NativeName": "한국어" }, 102 | { "Code": "pt", "EnglishName": "Portuguese", "NativeName": "Português" }, 103 | { "Code": "tr", "EnglishName": "Turkish", "NativeName": "Türkçe" }, 104 | { "Code": "cz", "EnglishName": "Czech", "NativeName": "Čeština" }, 105 | { "Code": "cn", "EnglishName": "Chinese", "NativeName": "中文" }, 106 | { "Code": "hu", "EnglishName": "Hungarian", "NativeName": "Magyar" }, 107 | { "Code": "nl", "EnglishName": "Dutch", "NativeName": "Nederlands" }, 108 | { "Code": "ho", "EnglishName": "Hiri Motu", "NativeName": "Hiri Motu" }, 109 | { "Code": "ro", "EnglishName": "Romanian", "NativeName": "Română" } 110 | ], 111 | "SettingsData": { 112 | "languageCode": crate::LOCALE.clone(), 113 | "notifChatMessage": { "overlay": CONFIG.overlay.notifications.chat.enabled }, 114 | "notifDownloadStatus": { "overlay": true }, 115 | "notifFriendInvite": { "overlay": CONFIG.overlay.notifications.friend_invite.enabled }, 116 | "notifFriendOnline": { "overlay": CONFIG.overlay.notifications.friend_online.enabled }, 117 | "notifFriendStartsGame": { "overlay":CONFIG.overlay.notifications.friend_game_start.enabled}, 118 | "notifGameInvite": { "overlay": CONFIG.overlay.notifications.game_invite.enabled}, 119 | "notifSoundChatMessage": { "overlay": CONFIG.overlay.notifications.chat.sound }, 120 | "notifSoundDownloadStatus": false, 121 | "notifSoundFriendInvite": { "overlay": CONFIG.overlay.notifications.friend_invite.sound }, 122 | "notifSoundFriendOnline": { "overlay": CONFIG.overlay.notifications.friend_online.sound }, 123 | "notifSoundFriendStartsGame": { "overlay": CONFIG.overlay.notifications.friend_game_start.sound }, 124 | "notifSoundGameInvite": { "overlay": CONFIG.overlay.notifications.game_invite.sound }, 125 | "notifSoundVolume": CONFIG.overlay.notification_volume.clamp(0, 100), 126 | "showFriendsSidebar": true, 127 | "overlayNotificationsPosition": CONFIG.overlay.position.to_string(), 128 | "store": {} 129 | }, 130 | "Config": { 131 | "Endpoints": { 132 | "api": "https://api.gog.com", 133 | "chat": "https://chat.gog.com", 134 | "externalAccounts": "https://external-accounts.gog.com", 135 | "externalUsers": "https://external-users.gog.com", 136 | "gameplay": "https://gameplay.gog.com", 137 | "gog": "https://embed.gog.com", 138 | "Gog": "https://embed.gog.com", 139 | "gogGalaxyStoreApi": "https://embed.gog.com", 140 | "notifications": "https://notifications.gog.com", 141 | "pusher": "https://notifications-pusher.gog.com", 142 | "library": "https://galaxy-library.gog.com", 143 | "presence": "https://presence.gog.com", 144 | "users": "https://users.gog.com", 145 | "redeem": "https://redeem.gog.com", 146 | "marketingSections": "https://marketing-sections.gog.com", 147 | "galaxyPromos": "https://galaxy-promos.gog.com", 148 | "remoteConfigurationHost": "https://remote-config.gog.com", 149 | "recommendations": "https://recommendations-api.gog.com", 150 | "overlayWeb": "https://overlay.gog.com", 151 | "OverlayWeb": "https://overlay.gog.com", 152 | }, 153 | "GalaxyClientId": "46899977096215655", 154 | "ChangelogBasePath": "", 155 | "LoggingLevel": log_level, 156 | "ClientVersions": { "Major": 2, "Minor": 0, "Build": 75, "Compilation": 1 } 157 | }, 158 | "User": { 159 | "UserId": user_info.galaxy_user_id.clone() 160 | }, 161 | "Game": { 162 | "ProductId": game_id, 163 | "ProductDetails": game_details 164 | } 165 | }); 166 | 167 | let mut res = OverlayFrontendInitDataResponse::new(); 168 | res.set_data(init_data.to_string()); 169 | let payload = res 170 | .write_to_bytes() 171 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Proto(err)))?; 172 | 173 | let mut header = gog_protocols_pb::Header::new(); 174 | header.set_sort(MessageSort::MESSAGE_SORT.value().try_into().unwrap()); 175 | header.set_type( 176 | MessageType::OVERLAY_FRONTEND_INIT_DATA_RESPONSE 177 | .value() 178 | .try_into() 179 | .unwrap(), 180 | ); 181 | header.set_size(payload.len().try_into().unwrap()); 182 | 183 | Ok(ProtoPayload { header, payload }) 184 | } 185 | 186 | // Thanks, I hate it 187 | async fn client_request( 188 | payload: &ProtoPayload, 189 | _context: &HandlerContext, 190 | reqwest_client: &reqwest::Client, 191 | ) -> Result { 192 | let request = OverlayToClientRequest::parse_from_bytes(&payload.payload) 193 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Proto(err)))?; 194 | let parsed_request: serde_json::Value = serde_json::from_str(request.data()) 195 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Json(err)))?; 196 | 197 | let command = parsed_request.get("Command"); 198 | let json_data: serde_json::Value = match command { 199 | Some(serde_json::Value::String(product_details_key)) 200 | if product_details_key == "FetchProductDetails" => 201 | { 202 | load_products(parsed_request, reqwest_client).await 203 | } 204 | _ => json!({}), 205 | }; 206 | 207 | let mut res = OverlayToClientResponse::new(); 208 | res.set_data(json_data.to_string()); 209 | let payload = res 210 | .write_to_bytes() 211 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Proto(err)))?; 212 | 213 | let mut header = gog_protocols_pb::Header::new(); 214 | header.set_sort(MessageSort::MESSAGE_SORT.value().try_into().unwrap()); 215 | header.set_type( 216 | MessageType::OVERLAY_FRONTEND_INIT_DATA_RESPONSE 217 | .value() 218 | .try_into() 219 | .unwrap(), 220 | ); 221 | header.set_size(payload.len().try_into().unwrap()); 222 | 223 | Ok(ProtoPayload { header, payload }) 224 | } 225 | 226 | async fn load_products( 227 | parsed_request: serde_json::Value, 228 | reqwest_client: &reqwest::Client, 229 | ) -> serde_json::Value { 230 | if let Some(arguments) = parsed_request.get("Arguments") { 231 | if let Some(ids) = arguments.get("ProductIds") { 232 | let ids = ids.as_array().unwrap(); 233 | let mut products: Vec = Vec::with_capacity(ids.len()); 234 | for id in ids { 235 | if let Ok(res) = reqwest_client 236 | .get(format!( 237 | "https://api.gog.com/products/{}", 238 | id.as_str().unwrap() 239 | )) 240 | .send() 241 | .await 242 | { 243 | if let Ok(mut data) = res.json::().await { 244 | if let Some(serde_json::Value::Object(ref mut images)) = 245 | data.get_mut("images") 246 | { 247 | for (_key, url_value) in images.iter_mut() { 248 | if let serde_json::Value::String(url) = url_value { 249 | if url.starts_with("//") { 250 | *url_value = 251 | serde_json::Value::String(format!("https:{}", url)); 252 | } 253 | } 254 | } 255 | } 256 | products.push(data); 257 | } 258 | } 259 | } 260 | return json!({ 261 | "Command": "ProductDetailsUpdate", 262 | "Arguments": { 263 | "Values": products 264 | } 265 | }); 266 | } 267 | } 268 | 269 | json!({}) 270 | } 271 | -------------------------------------------------------------------------------- /src/api/handlers/overlay_peer.rs: -------------------------------------------------------------------------------- 1 | use crate::proto::common_utils::ProtoPayload; 2 | use crate::proto::gog_protocols_pb::Header; 3 | use crate::{api::gog::overlay::OverlayPeerMessage, proto::galaxy_protocols_overlay_for_peer::*}; 4 | use log::warn; 5 | use protobuf::{Enum, Message}; 6 | 7 | use super::{context::HandlerContext, MessageHandlingError, MessageHandlingErrorKind}; 8 | 9 | pub async fn entry_point( 10 | payload: &ProtoPayload, 11 | context: &HandlerContext, 12 | ) -> Result { 13 | let header = &payload.header; 14 | 15 | let message_type: i32 = header.type_().try_into().unwrap(); 16 | 17 | if message_type == MessageType::SHOW_WEB_PAGE.value() { 18 | let _ = show_web_page(payload, context).await; 19 | } else if message_type == MessageType::VISIBILITY_CHANGE_NOTIFICATION.value() { 20 | let _ = visibility_change(payload, context).await; 21 | } else if message_type == MessageType::SHOW_INVITATION_DIALOG.value() { 22 | let _ = show_invitation(payload, context).await; 23 | } else if message_type == MessageType::GAME_JOIN_REQUEST_NOTIFICATION.value() { 24 | let _ = game_join(payload, context).await; 25 | } else if message_type == MessageType::OVERLAY_INITIALIZED_NOTIFICATION.value() { 26 | let _ = overlay_initialized(payload, context).await; 27 | } else { 28 | warn!("Received unsupported peer message type {}", message_type); 29 | return Err(MessageHandlingError::new( 30 | MessageHandlingErrorKind::NotImplemented, 31 | )); 32 | } 33 | Err(MessageHandlingError::new(MessageHandlingErrorKind::Ignored)) 34 | } 35 | 36 | async fn show_web_page( 37 | payload: &ProtoPayload, 38 | context: &HandlerContext, 39 | ) -> Result<(), Box> { 40 | let request = ShowWebPage::parse_from_bytes(&payload.payload)?; 41 | let pid = context.get_pid().await; 42 | let msg = OverlayPeerMessage::OpenWebPage(request.url().to_owned()); 43 | let _ = context.overlay_sender().send((pid, msg)); 44 | Ok(()) 45 | } 46 | 47 | async fn visibility_change( 48 | payload: &ProtoPayload, 49 | context: &HandlerContext, 50 | ) -> Result<(), Box> { 51 | let request = VisibilityChangeNotification::parse_from_bytes(&payload.payload)?; 52 | let pid = context.get_pid().await; 53 | let msg = OverlayPeerMessage::VisibilityChange(request.visible()); 54 | let _ = context.overlay_sender().send((pid, msg)); 55 | Ok(()) 56 | } 57 | 58 | async fn show_invitation( 59 | payload: &ProtoPayload, 60 | context: &HandlerContext, 61 | ) -> Result<(), Box> { 62 | let request = ShowInvitationDialog::parse_from_bytes(&payload.payload)?; 63 | let pid = context.get_pid().await; 64 | let msg = OverlayPeerMessage::InvitationDialog(request.connection_string().to_owned()); 65 | let _ = context.overlay_sender().send((pid, msg)); 66 | Ok(()) 67 | } 68 | 69 | async fn game_join( 70 | payload: &ProtoPayload, 71 | context: &HandlerContext, 72 | ) -> Result<(), Box> { 73 | let request = GameJoinRequestNotification::parse_from_bytes(&payload.payload)?; 74 | let pid = context.get_pid().await; 75 | let msg = OverlayPeerMessage::GameJoin(( 76 | request.inviter_id(), 77 | request.client_id().to_owned(), 78 | request.connection_string().to_owned(), 79 | )); 80 | let _ = context.overlay_sender().send((pid, msg)); 81 | Ok(()) 82 | } 83 | 84 | async fn overlay_initialized( 85 | payload: &ProtoPayload, 86 | context: &HandlerContext, 87 | ) -> Result<(), Box> { 88 | let pid = context.get_pid().await; 89 | let msg = OverlayPeerMessage::DisablePopups(payload.payload.clone()); 90 | let _ = context.overlay_sender().send((pid, msg)); 91 | Ok(()) 92 | } 93 | 94 | // ================================================== 95 | // Functions used by overlay thread to send messages 96 | // ================================================== 97 | 98 | async fn encode_message( 99 | msg_type: u32, 100 | msg_data: Vec, 101 | ) -> Result, Box> { 102 | let mut header = Header::new(); 103 | header.set_sort(MessageSort::MESSAGE_SORT.value() as u32); 104 | header.set_type(msg_type); 105 | header.set_size(msg_data.len().try_into()?); 106 | header.set_oseq(rand::random()); 107 | let header_bytes = header.write_to_bytes()?; 108 | let header_len: u16 = header_bytes.len().try_into()?; 109 | let bytes = header_len.to_be_bytes(); 110 | 111 | let mut res = Vec::with_capacity(2 + header_bytes.len() + msg_data.len()); 112 | res.extend(bytes); 113 | res.extend(header_bytes); 114 | res.extend(msg_data); 115 | 116 | Ok(res) 117 | } 118 | 119 | pub async fn encode_open_web_page( 120 | msg: String, 121 | ) -> Result, Box> { 122 | let mut response = ShowWebPage::new(); 123 | response.set_url(msg); 124 | let res_bytes = response.write_to_bytes()?; 125 | encode_message(MessageType::SHOW_WEB_PAGE.value() as u32, res_bytes).await 126 | } 127 | 128 | pub async fn encode_visibility_change( 129 | msg: bool, 130 | ) -> Result, Box> { 131 | let mut response = VisibilityChangeNotification::new(); 132 | response.set_visible(msg); 133 | let res_bytes = response.write_to_bytes()?; 134 | encode_message( 135 | MessageType::VISIBILITY_CHANGE_NOTIFICATION.value() as u32, 136 | res_bytes, 137 | ) 138 | .await 139 | } 140 | 141 | pub async fn encode_game_invite( 142 | msg: String, 143 | ) -> Result, Box> { 144 | let mut response = ShowInvitationDialog::new(); 145 | response.set_connection_string(msg); 146 | let res_bytes = response.write_to_bytes()?; 147 | encode_message( 148 | MessageType::SHOW_INVITATION_DIALOG.value() as u32, 149 | res_bytes, 150 | ) 151 | .await 152 | } 153 | 154 | pub async fn encode_game_join( 155 | (inviter, client_id, connection_string): (u64, String, String), 156 | ) -> Result, Box> { 157 | let mut response = GameJoinRequestNotification::new(); 158 | response.set_inviter_id(inviter); 159 | response.set_client_id(client_id); 160 | response.set_connection_string(connection_string); 161 | let res_bytes = response.write_to_bytes()?; 162 | encode_message( 163 | MessageType::GAME_JOIN_REQUEST_NOTIFICATION.value() as u32, 164 | res_bytes, 165 | ) 166 | .await 167 | } 168 | 169 | pub async fn encode_overlay_initialized( 170 | data: Vec, 171 | ) -> Result, Box> { 172 | encode_message( 173 | MessageType::OVERLAY_INITIALIZED_NOTIFICATION.value() as u32, 174 | data, 175 | ) 176 | .await 177 | } 178 | -------------------------------------------------------------------------------- /src/api/handlers/overlay_service.rs: -------------------------------------------------------------------------------- 1 | use super::{context::HandlerContext, MessageHandlingError, MessageHandlingErrorKind}; 2 | use crate::api::gog::achievements::Achievement; 3 | use crate::constants; 4 | use crate::proto::common_utils::ProtoPayload; 5 | use crate::proto::{galaxy_protocols_overlay_for_service::*, gog_protocols_pb}; 6 | use chrono::Utc; 7 | use log::{debug, info, warn}; 8 | use protobuf::{Enum, Message}; 9 | 10 | pub async fn entry_point( 11 | payload: &ProtoPayload, 12 | context: &HandlerContext, 13 | ) -> Result { 14 | debug!("overlay <-> service entry point called"); 15 | let header = &payload.header; 16 | let message_type: i32 = header.type_().try_into().unwrap(); 17 | 18 | if message_type == MessageType::ACCESS_TOKEN_REQUEST.value() { 19 | access_token(payload, context).await 20 | } else if message_type == MessageType::OVERLAY_INITIALIZATION_NOTIFICATION.value() { 21 | init_notification(payload).await?; 22 | Err(MessageHandlingError::new(MessageHandlingErrorKind::Ignored)) 23 | } else { 24 | warn!( 25 | "Received unsupported ov_service message type {}", 26 | message_type 27 | ); 28 | Err(MessageHandlingError::new( 29 | MessageHandlingErrorKind::NotImplemented, 30 | )) 31 | } 32 | } 33 | 34 | pub async fn achievement_notification( 35 | achievement: Achievement, 36 | ) -> Result, Box> { 37 | let mut res_data = NotifyAchievementUnlocked::new(); 38 | res_data.set_key(achievement.achievement_key); 39 | res_data.set_name(achievement.name); 40 | res_data.set_description(achievement.description); 41 | res_data.set_achievement_id(achievement.achievement_id.parse().unwrap()); 42 | if let Some(date) = achievement.date_unlocked { 43 | let parsed_date: chrono::DateTime = date.parse().unwrap(); 44 | let timestamp = parsed_date.timestamp() as u64; 45 | res_data.set_unlock_time(timestamp); 46 | } 47 | res_data.set_image_url_locked(achievement.image_url_locked); 48 | res_data.set_image_url_unlocked(achievement.image_url_unlocked); 49 | res_data.set_visible_while_locked(achievement.visible); 50 | let res_buf = res_data.write_to_bytes()?; 51 | 52 | let mut header = gog_protocols_pb::Header::new(); 53 | header.set_sort(MessageSort::MESSAGE_SORT.value().try_into()?); 54 | header.set_type( 55 | MessageType::NOTIFY_ACHIEVEMENT_UNLOCKED 56 | .value() 57 | .try_into() 58 | .unwrap(), 59 | ); 60 | header.set_size(res_buf.len().try_into()?); 61 | let header_buffer = header.write_to_bytes()?; 62 | let header_size: u16 = header_buffer.len().try_into().unwrap(); 63 | let header_buf = header_size.to_be_bytes(); 64 | 65 | let mut message_buffer = Vec::with_capacity(2 + header_buffer.len() + res_buf.len()); 66 | message_buffer.extend(header_buf); 67 | message_buffer.extend(header_buffer); 68 | message_buffer.extend(res_buf); 69 | 70 | Ok(message_buffer) 71 | } 72 | 73 | async fn access_token( 74 | _payload: &ProtoPayload, 75 | context: &HandlerContext, 76 | ) -> Result { 77 | let tokens = context.token_store(); 78 | 79 | let galaxy_access_token = { 80 | let tokens = tokens.lock().await; 81 | tokens.get(constants::GALAXY_CLIENT_ID).cloned() 82 | }; 83 | 84 | let mut res = AccessTokenResponse::new(); 85 | if let Some(token) = galaxy_access_token { 86 | res.set_access_token(token.access_token.clone()); 87 | } 88 | let payload = res 89 | .write_to_bytes() 90 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Proto(err)))?; 91 | let mut header = gog_protocols_pb::Header::new(); 92 | header.set_sort(MessageSort::MESSAGE_SORT.value().try_into().unwrap()); 93 | header.set_type( 94 | MessageType::ACCESS_TOKEN_RESPONSE 95 | .value() 96 | .try_into() 97 | .unwrap(), 98 | ); 99 | header.set_size(payload.len().try_into().unwrap()); 100 | 101 | Ok(ProtoPayload { header, payload }) 102 | } 103 | 104 | async fn init_notification(payload: &ProtoPayload) -> Result<(), MessageHandlingError> { 105 | let message = OverlayInitializationNotification::parse_from_bytes(&payload.payload) 106 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Proto(err)))?; 107 | 108 | info!( 109 | "Overlay notified if it successfully initialized - {}", 110 | message.initialized_successfully() 111 | ); 112 | 113 | Ok(()) 114 | } 115 | -------------------------------------------------------------------------------- /src/api/handlers/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::api::gog; 2 | use crate::api::gog::leaderboards::get_leaderboards_entries; 3 | use crate::api::handlers::context::HandlerContext; 4 | use crate::api::handlers::error::{MessageHandlingError, MessageHandlingErrorKind}; 5 | use crate::api::structs::IDType; 6 | use crate::db; 7 | use base64::prelude::*; 8 | use log::warn; 9 | use protobuf::{Enum, Message}; 10 | use reqwest::Client; 11 | use tokio::io::AsyncReadExt; 12 | 13 | use crate::proto::galaxy_protocols_communication_service::get_leaderboard_entries_response::LeaderboardEntry; 14 | use crate::proto::galaxy_protocols_communication_service::{ 15 | get_leaderboards_response, DisplayType, GetLeaderboardEntriesResponse, GetLeaderboardsResponse, 16 | MessageType, SortMethod, 17 | }; 18 | use crate::proto::gog_protocols_pb::Header; 19 | use crate::proto::{common_utils::ProtoPayload, gog_protocols_pb}; 20 | 21 | pub async fn parse_payload( 22 | h_size: u16, 23 | socket: &mut R, 24 | ) -> Result { 25 | let h_size: usize = h_size.into(); 26 | let mut buffer: Vec = vec![0; h_size]; 27 | 28 | socket.read_exact(&mut buffer).await?; 29 | 30 | let header = gog_protocols_pb::Header::parse_from_bytes(&buffer)?; 31 | 32 | let size: usize = header.size().try_into().unwrap(); 33 | buffer.resize(size, 0); 34 | socket.read_exact(&mut buffer).await?; 35 | Ok(ProtoPayload { 36 | header, 37 | payload: buffer, 38 | }) 39 | } 40 | 41 | pub async fn handle_leaderboards_query( 42 | context: &HandlerContext, 43 | reqwest_client: &Client, 44 | params: I, 45 | ) -> Result 46 | where 47 | I: IntoIterator + Clone, 48 | K: AsRef, 49 | V: AsRef + std::fmt::Display, 50 | { 51 | let leaderboards_network = 52 | gog::leaderboards::get_leaderboards(context, reqwest_client, params.clone()).await; 53 | 54 | let leaderboards = match leaderboards_network { 55 | Ok(ld) => ld, 56 | Err(_) => db::gameplay::get_leaderboards_defs(context, params) 57 | .await 58 | .unwrap_or_default(), 59 | }; 60 | 61 | if let Err(err) = super::db::gameplay::update_leaderboards(context, &leaderboards).await { 62 | log::error!("Failed to save leaderboards definitions {}", err); 63 | } 64 | 65 | let proto_defs = leaderboards.iter().map(|entry| { 66 | let mut new_def = get_leaderboards_response::LeaderboardDefinition::new(); 67 | let display_type = match entry.display_type().as_str() { 68 | "numeric" => DisplayType::DISPLAY_TYPE_NUMERIC, 69 | "seconds" => DisplayType::DISPLAY_TYPE_TIME_SECONDS, 70 | "milliseconds" => DisplayType::DISPLAY_TYPE_TIME_MILLISECONDS, 71 | _ => DisplayType::DISPLAY_TYPE_UNDEFINED, 72 | }; 73 | let sort_method = match entry.sort_method().as_str() { 74 | "asc" => SortMethod::SORT_METHOD_ASCENDING, 75 | "desc" => SortMethod::SORT_METHOD_DESCENDING, 76 | _ => SortMethod::SORT_METHOD_UNDEFINED, 77 | }; 78 | 79 | new_def.set_key(entry.key().clone()); 80 | new_def.set_name(entry.name().clone()); 81 | new_def.set_leaderboard_id(entry.id().parse().unwrap()); 82 | new_def.set_display_type(display_type); 83 | new_def.set_sort_method(sort_method); 84 | 85 | new_def 86 | }); 87 | 88 | let mut payload_data = GetLeaderboardsResponse::new(); 89 | payload_data.leaderboard_definitions.extend(proto_defs); 90 | 91 | let mut header = Header::new(); 92 | header.set_type( 93 | MessageType::GET_LEADERBOARDS_RESPONSE 94 | .value() 95 | .try_into() 96 | .unwrap(), 97 | ); 98 | 99 | let payload = payload_data 100 | .write_to_bytes() 101 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Proto(err)))?; 102 | 103 | header.set_size(payload.len().try_into().unwrap()); 104 | 105 | Ok(ProtoPayload { header, payload }) 106 | } 107 | 108 | pub async fn handle_leaderboard_entries_request( 109 | context: &HandlerContext, 110 | reqwest_client: &Client, 111 | leaderboard_id: u64, 112 | params: I, 113 | ) -> ProtoPayload 114 | where 115 | I: IntoIterator + std::fmt::Debug, 116 | K: AsRef, 117 | V: AsRef, 118 | { 119 | let mut header = Header::new(); 120 | header.set_type( 121 | MessageType::GET_LEADERBOARD_ENTRIES_RESPONSE 122 | .value() 123 | .try_into() 124 | .unwrap(), 125 | ); 126 | log::debug!("Leaderboards request params: {:?}", params); 127 | 128 | let leaderboard_response = 129 | get_leaderboards_entries(context, reqwest_client, leaderboard_id, params).await; 130 | 131 | let payload = match leaderboard_response { 132 | Ok(results) => { 133 | let mut data = GetLeaderboardEntriesResponse::new(); 134 | data.set_leaderboard_entry_total_count(results.leaderboard_entry_total_count); 135 | data.leaderboard_entries 136 | .extend(results.items.iter().map(|item| { 137 | let mut new_entry = LeaderboardEntry::new(); 138 | let user_id: u64 = item.user_id.parse().unwrap(); 139 | let user_id = IDType::User(user_id); 140 | new_entry.set_user_id(user_id.value()); 141 | new_entry.set_score(item.score); 142 | new_entry.set_rank(item.rank); 143 | if let Some(details) = &item.details { 144 | match BASE64_URL_SAFE_NO_PAD.decode(details) { 145 | Ok(details) => new_entry.set_details(details), 146 | Err(err) => log::error!( 147 | "Failed to decode details {:#?}, source -- {}", 148 | err, 149 | details 150 | ), 151 | } 152 | } 153 | new_entry 154 | })); 155 | log::debug!( 156 | "Leaderboards request entries: {}", 157 | data.leaderboard_entries.len() 158 | ); 159 | data.write_to_bytes().unwrap() 160 | } 161 | Err(err) => { 162 | warn!("Leaderboards request error: {}", err); 163 | if err.is_status() && err.status().unwrap() == reqwest::StatusCode::NOT_FOUND { 164 | header 165 | .mut_special_fields() 166 | .mut_unknown_fields() 167 | .add_varint(101, 404); 168 | } 169 | Vec::new() 170 | } 171 | }; 172 | header.set_size(payload.len().try_into().unwrap()); 173 | 174 | ProtoPayload { header, payload } 175 | } 176 | -------------------------------------------------------------------------------- /src/api/handlers/webbroker.rs: -------------------------------------------------------------------------------- 1 | use log::{debug, warn}; 2 | use protobuf::{Enum, Message}; 3 | 4 | use super::context::HandlerContext; 5 | use super::error::*; 6 | use crate::proto::common_utils::ProtoPayload; 7 | use crate::proto::galaxy_protocols_webbroker_service::{ 8 | MessageSort, MessageType, SubscribeTopicRequest, SubscribeTopicResponse, 9 | }; 10 | use crate::proto::gog_protocols_pb; 11 | 12 | pub async fn entry_point( 13 | payload: &ProtoPayload, 14 | context: &HandlerContext, 15 | ) -> Result { 16 | debug!("webbroker entry point called"); 17 | let header = &payload.header; 18 | 19 | let message_type: i32 = header.type_().try_into().unwrap(); 20 | 21 | if message_type == MessageType::SUBSCRIBE_TOPIC_REQUEST.value() { 22 | subscribe_topic_request(payload, context).await 23 | } else { 24 | warn!( 25 | "Received unsupported webbroker message type {}", 26 | message_type 27 | ); 28 | Err(MessageHandlingError::new( 29 | MessageHandlingErrorKind::NotImplemented, 30 | )) 31 | } 32 | } 33 | 34 | // Actual handlers of the functions 35 | async fn subscribe_topic_request( 36 | payload: &ProtoPayload, 37 | context: &HandlerContext, 38 | ) -> Result { 39 | // This is the stub that just responds with success 40 | let request_data = SubscribeTopicRequest::parse_from_bytes(&payload.payload); 41 | 42 | let proto = request_data 43 | .map_err(|err| MessageHandlingError::new(MessageHandlingErrorKind::Proto(err)))?; 44 | let topic = String::from(proto.topic()); 45 | 46 | context.subscribe_topic(topic.clone()).await; 47 | log::debug!("Webbroker subscribe to {}", topic); 48 | let mut new_data = SubscribeTopicResponse::new(); 49 | let mut header = gog_protocols_pb::Header::new(); 50 | header.set_sort(MessageSort::MESSAGE_SORT.value().try_into().unwrap()); 51 | header.set_type( 52 | MessageType::SUBSCRIBE_TOPIC_RESPONSE 53 | .value() 54 | .try_into() 55 | .unwrap(), 56 | ); 57 | new_data.set_topic(topic); 58 | 59 | let buffer = new_data.write_to_bytes().unwrap(); 60 | header.set_size(buffer.len().try_into().unwrap()); 61 | Ok(ProtoPayload { 62 | header, 63 | payload: buffer, 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /src/api/notification_pusher.rs: -------------------------------------------------------------------------------- 1 | use futures_util::{SinkExt, StreamExt}; 2 | use log::{debug, error, info, warn}; 3 | use protobuf::{Enum, Message, UnknownValueRef}; 4 | use std::sync::Arc; 5 | use tokio::net::TcpStream; 6 | use tokio::sync::broadcast::Sender; 7 | use tokio::time; 8 | use tokio_tungstenite::{ 9 | connect_async_tls_with_config, tungstenite, MaybeTlsStream, WebSocketStream, 10 | }; 11 | use tokio_util::sync::CancellationToken; 12 | 13 | use crate::proto::common_utils::ProtoPayload; 14 | use crate::proto::galaxy_protocols_webbroker_service::MessageFromTopic; 15 | use crate::proto::gog_protocols_pb::response::Status; 16 | use crate::proto::{ 17 | galaxy_common_protocols_connection, 18 | galaxy_protocols_webbroker_service::{ 19 | AuthRequest, MessageSort, MessageType, SubscribeTopicRequest, SubscribeTopicResponse, 20 | }, 21 | gog_protocols_pb::Header, 22 | }; 23 | 24 | lazy_static! { 25 | static ref TLS_CONFIG: Arc = Arc::new({ 26 | let mut root_store = rustls::RootCertStore::empty(); 27 | let mut reader = std::io::BufReader::new(crate::CERT); 28 | let certs = rustls_pemfile::certs(&mut reader).filter_map(|crt| crt.ok()); 29 | root_store.add_parsable_certificates(certs); 30 | rustls::ClientConfig::builder() 31 | .with_root_certificates(root_store) 32 | .with_no_client_auth() 33 | }); 34 | } 35 | 36 | #[derive(Clone)] 37 | pub enum PusherEvent { 38 | Online, 39 | Offline, 40 | Topic(Vec, String), 41 | } 42 | 43 | pub struct NotificationPusherClient { 44 | pusher_connection: WebSocketStream>, 45 | access_token: String, 46 | topic_sender: Sender, 47 | shutdown_token: CancellationToken, 48 | } 49 | 50 | impl NotificationPusherClient { 51 | pub async fn new( 52 | access_token: &String, 53 | topic_sender: Sender, 54 | shutdown_token: CancellationToken, 55 | ) -> NotificationPusherClient { 56 | debug!("Notification pusher init"); 57 | let mut retries = 5; 58 | let ws_stream = loop { 59 | let stream = NotificationPusherClient::init_connection(access_token).await; 60 | match stream { 61 | Ok(stream) => break Some(stream), 62 | Err(tungstenite::Error::Io(_err)) => { 63 | tokio::select! { 64 | _ = time::sleep(time::Duration::from_secs(10)) => {}, 65 | _ = shutdown_token.cancelled() => { break None } 66 | } 67 | } 68 | Err(err) => { 69 | if retries > 0 { 70 | tokio::select! { 71 | _ = time::sleep(time::Duration::from_secs(3)) => {}, 72 | _ = shutdown_token.cancelled() => { break None } 73 | } 74 | retries -= 1; 75 | } else { 76 | panic!("Notification pusher init failed, {:?}", err); 77 | } 78 | } 79 | } 80 | }; 81 | 82 | NotificationPusherClient { 83 | pusher_connection: ws_stream.expect("Unable to get notification pusher connection"), 84 | access_token: access_token.clone(), 85 | topic_sender, 86 | shutdown_token, 87 | } 88 | } 89 | 90 | async fn init_connection( 91 | access_token: &String, 92 | ) -> Result>, tungstenite::Error> { 93 | let tls_connector = tokio_tungstenite::Connector::Rustls(TLS_CONFIG.clone()); 94 | let (mut ws_stream, _) = connect_async_tls_with_config( 95 | crate::constants::NOTIFICATIONS_PUSHER_SOCKET, 96 | None, 97 | false, 98 | Some(tls_connector), 99 | ) 100 | .await?; 101 | info!("Connected to notifications-pusher"); 102 | 103 | let mut header = Header::new(); 104 | header.set_sort(MessageSort::MESSAGE_SORT.value().try_into().unwrap()); 105 | header.set_type(MessageType::AUTH_REQUEST.value().try_into().unwrap()); 106 | 107 | let mut request_body = AuthRequest::new(); 108 | let token_payload = format!("Bearer {}", access_token); 109 | request_body.set_auth_token(token_payload); 110 | 111 | let request_body = request_body.write_to_bytes().unwrap(); 112 | 113 | header.set_size(request_body.len().try_into().unwrap()); 114 | header.set_oseq(10000); 115 | let header_data = header.write_to_bytes().unwrap(); 116 | let size: u16 = header_data.len().try_into().unwrap(); 117 | let size_data = size.to_be_bytes(); 118 | 119 | let mut buffer = Vec::new(); 120 | buffer.extend(size_data); 121 | buffer.extend(header_data); 122 | buffer.extend(request_body); 123 | 124 | let message = tungstenite::Message::Binary(buffer); 125 | 126 | ws_stream.send(message).await?; 127 | 128 | info!("Sent authorization data"); 129 | Ok(ws_stream) 130 | } 131 | 132 | pub async fn handle_loop(&mut self) { 133 | loop { 134 | let mut pending_ping = false; 135 | loop { 136 | let message = tokio::select! { 137 | msg = self.pusher_connection.next() => { 138 | if msg.is_none() { 139 | debug!("Connection reset"); 140 | break; 141 | } 142 | msg.unwrap() 143 | } 144 | _ = time::sleep(time::Duration::from_secs(60)) => { 145 | if pending_ping { 146 | // Send offline status to contexts 147 | if let Err(err) = self.topic_sender.send(PusherEvent::Offline) { 148 | warn!("Failed to send offline event to contexts {}", err.to_string()); 149 | } 150 | break 151 | } 152 | // Ping the service to see if we are still online 153 | let mut header = Header::new(); 154 | header.set_type(galaxy_common_protocols_connection::MessageType::PING.value().try_into().unwrap()); 155 | header.set_sort(MessageSort::MESSAGE_SORT.value().try_into().unwrap()); 156 | let mut content = galaxy_common_protocols_connection::Ping::new(); 157 | content.set_ping_time(chrono::Utc::now().timestamp().try_into().unwrap()); 158 | let content_buffer = content.write_to_bytes().unwrap(); 159 | header.set_size(content_buffer.len().try_into().unwrap()); 160 | let header_buffer = header.write_to_bytes().unwrap(); 161 | let header_size: u16 = header_buffer.len().try_into().unwrap(); 162 | 163 | let mut message: Vec = Vec::new(); 164 | message.extend(header_size.to_be_bytes().to_vec()); 165 | message.extend(header_buffer); 166 | message.extend(content_buffer); 167 | 168 | let ws_message = tungstenite::Message::Ping(message); 169 | if let Err(err) = self.pusher_connection.send(ws_message).await { 170 | warn!("Pusher ping failed {:?}", err); 171 | break; 172 | } 173 | pending_ping = true; 174 | continue 175 | } 176 | _ = self.shutdown_token.cancelled() => { 177 | // We are shutting down, we can ignore any errors 178 | let _ = self.pusher_connection.close(None).await; 179 | break; 180 | } 181 | }; 182 | 183 | let message = match message { 184 | Ok(msg) => msg, 185 | Err(err) => { 186 | error!( 187 | "There was an error reading notifications pusher message: {}", 188 | err 189 | ); 190 | continue; 191 | } 192 | }; 193 | 194 | debug!("Received a message"); 195 | if message.is_binary() { 196 | let msg_data = message.into_data(); 197 | let proto_message = NotificationPusherClient::parse_message(&msg_data); 198 | let parsed_message = match proto_message { 199 | Ok(message) => message, 200 | Err(err) => { 201 | error!("There was an error parsing socket message: {}", err); 202 | continue; 203 | } 204 | }; 205 | let msg_type: i32 = parsed_message.header.type_().try_into().unwrap(); 206 | let sort: i32 = parsed_message.header.sort().try_into().unwrap(); 207 | 208 | if sort != MessageSort::MESSAGE_SORT.value() { 209 | warn!("Notifications pusher sort has unexpected value {}, ignoring... this may introduce unexpected behavior", sort); 210 | } 211 | 212 | if msg_type == MessageType::AUTH_RESPONSE.value() { 213 | // No content 214 | let status_code = parsed_message 215 | .header 216 | .special_fields 217 | .unknown_fields() 218 | .get(101); 219 | if let Some(UnknownValueRef::Varint(code)) = status_code { 220 | let code: i32 = code.try_into().unwrap(); 221 | if let Some(enum_code) = Status::from_i32(code) { 222 | if enum_code == Status::OK { 223 | info!("Subscribing to chat, friends, presence"); 224 | let mut header = Header::new(); 225 | header.set_sort( 226 | MessageSort::MESSAGE_SORT.value().try_into().unwrap(), 227 | ); 228 | header.set_type( 229 | MessageType::SUBSCRIBE_TOPIC_REQUEST 230 | .value() 231 | .try_into() 232 | .unwrap(), 233 | ); 234 | let mut oseq = 1020; 235 | for topic in [ 236 | "friends", 237 | "presence", 238 | "chat", 239 | "chat:removed", 240 | "gameplay", 241 | "galaxy_library", 242 | "user_notification", 243 | "user_notification:consumed", 244 | "user_notification:removed", 245 | "external_users", 246 | "external_accounts", 247 | ] { 248 | let mut message_buffer: Vec = Vec::new(); 249 | let mut request_data = SubscribeTopicRequest::new(); 250 | request_data.set_topic(String::from(topic)); 251 | let payload = request_data.write_to_bytes().unwrap(); 252 | header.set_size(payload.len().try_into().unwrap()); 253 | header.set_oseq(oseq); 254 | oseq += 1; 255 | let header_buf = header.write_to_bytes().unwrap(); 256 | 257 | let header_size: u16 = header_buf.len().try_into().unwrap(); 258 | 259 | message_buffer.extend(header_size.to_be_bytes()); 260 | message_buffer.extend(header_buf); 261 | message_buffer.extend(payload); 262 | 263 | let new_message = 264 | tungstenite::Message::Binary(message_buffer); 265 | if let Err(error) = 266 | self.pusher_connection.feed(new_message).await 267 | { 268 | error!( 269 | "There was an error subscribing to {}, {:?}", 270 | topic, error 271 | ); 272 | } 273 | } 274 | if let Err(error) = self.pusher_connection.flush().await { 275 | error!("There was an error flushing {:?}", error); 276 | } 277 | info!("Completed subscribe requests"); 278 | continue; 279 | } 280 | } 281 | } 282 | } else if msg_type == MessageType::SUBSCRIBE_TOPIC_RESPONSE.value() { 283 | let topic_response = 284 | SubscribeTopicResponse::parse_from_bytes(&parsed_message.payload); 285 | match topic_response { 286 | Ok(response) => { 287 | let topic = response.topic(); 288 | info!("Successfully subscribed to topic {}", topic); 289 | } 290 | Err(err) => { 291 | error!("Failed to parse topic response payload {:?}", err) 292 | } 293 | } 294 | } else if msg_type == MessageType::MESSAGE_FROM_TOPIC.value() { 295 | info!("Recieved message from topic"); 296 | let message_from_topic = 297 | MessageFromTopic::parse_from_bytes(&parsed_message.payload); 298 | if let Ok(msg) = message_from_topic { 299 | log::debug!("Topic message: {:#?}", String::from_utf8_lossy(&msg_data)); 300 | if let Err(error) = self 301 | .topic_sender 302 | .send(PusherEvent::Topic(msg_data, msg.topic().to_owned())) 303 | { 304 | error!( 305 | "There was an error when forwarding topic message: {}", 306 | error 307 | ); 308 | } 309 | } 310 | } else { 311 | warn!("Unhandled message type: {}", msg_type); 312 | } 313 | } else if message.is_pong() { 314 | debug!("Pong received"); 315 | pending_ping = false; 316 | if let Err(err) = self.topic_sender.send(PusherEvent::Online) { 317 | warn!( 318 | "Failed to notify handlers about going online {}", 319 | err.to_string() 320 | ); 321 | } 322 | } 323 | } 324 | if !self.shutdown_token.is_cancelled() { 325 | tokio::time::sleep(time::Duration::from_secs(5)).await; 326 | let mut retries = 5; 327 | let connection = loop { 328 | if self.shutdown_token.is_cancelled() { 329 | break None; 330 | } 331 | let stream = 332 | NotificationPusherClient::init_connection(&self.access_token).await; 333 | if let Ok(stream) = stream { 334 | break Some(stream); 335 | } else if retries > 0 { 336 | tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; 337 | retries -= 1; 338 | } 339 | }; 340 | if let Some(connection) = connection { 341 | self.pusher_connection = connection; 342 | } else { 343 | break; 344 | } 345 | } else { 346 | break; 347 | } 348 | } 349 | } 350 | 351 | pub fn parse_message(msg_data: &Vec) -> Result { 352 | let data = msg_data.as_slice(); 353 | 354 | let mut header_size_buf = [0; 2]; 355 | header_size_buf.copy_from_slice(&data[..2]); 356 | let header_size = u16::from_be_bytes(header_size_buf).into(); 357 | 358 | let mut header_buf: Vec = vec![0; header_size]; 359 | header_buf.copy_from_slice(&data[2..header_size + 2]); 360 | 361 | let header = Header::parse_from_bytes(&header_buf)?; 362 | 363 | let payload_size = header.size().try_into().unwrap(); 364 | let mut payload: Vec = vec![0; payload_size]; 365 | 366 | payload.copy_from_slice(&data[header_size + 2..]); 367 | 368 | Ok(ProtoPayload { header, payload }) 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::paths; 2 | use serde::Deserialize; 3 | use std::fs; 4 | 5 | #[derive(Deserialize, Default, Debug)] 6 | #[serde(default)] 7 | pub struct Configuration { 8 | pub overlay: OverlayConfiguration, 9 | } 10 | 11 | #[derive(Deserialize, Debug)] 12 | #[serde(default)] 13 | pub struct OverlayConfiguration { 14 | #[serde(default = "default_volume")] 15 | pub notification_volume: u32, 16 | pub position: OverlayPosition, 17 | pub notifications: OverlayNotifications, 18 | } 19 | 20 | impl Default for OverlayConfiguration { 21 | fn default() -> Self { 22 | Self { 23 | notification_volume: 50, 24 | position: Default::default(), 25 | notifications: Default::default(), 26 | } 27 | } 28 | } 29 | 30 | #[derive(Deserialize, Default, Debug)] 31 | #[serde(rename_all = "snake_case")] 32 | pub enum OverlayPosition { 33 | #[default] 34 | BottomRight, 35 | BottomLeft, 36 | TopRight, 37 | TopLeft, 38 | } 39 | 40 | impl std::fmt::Display for OverlayPosition { 41 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 42 | match self { 43 | Self::TopLeft => f.write_str("top_left"), 44 | Self::TopRight => f.write_str("top_right"), 45 | Self::BottomLeft => f.write_str("bottom_left"), 46 | Self::BottomRight => f.write_str("bottom_right"), 47 | } 48 | } 49 | } 50 | 51 | #[derive(Deserialize, Default, Debug)] 52 | #[serde(default)] 53 | pub struct OverlayNotifications { 54 | pub chat: OverlayNotificationConfig, 55 | pub friend_online: OverlayNotificationConfig, 56 | pub friend_invite: OverlayNotificationConfig, 57 | pub friend_game_start: OverlayNotificationConfig, 58 | pub game_invite: OverlayNotificationConfig, 59 | } 60 | 61 | #[derive(Deserialize, Debug)] 62 | pub struct OverlayNotificationConfig { 63 | #[serde(default = "default_true")] 64 | pub enabled: bool, 65 | #[serde(default = "default_true")] 66 | pub sound: bool, 67 | } 68 | 69 | impl Default for OverlayNotificationConfig { 70 | fn default() -> Self { 71 | Self { 72 | enabled: true, 73 | sound: true, 74 | } 75 | } 76 | } 77 | 78 | fn default_true() -> bool { 79 | true 80 | } 81 | 82 | fn default_volume() -> u32 { 83 | 50 84 | } 85 | 86 | pub fn load_config() -> Result> { 87 | let data = fs::read_to_string(paths::CONFIG_FILE.as_path())?; 88 | Ok(toml::from_str(&data)?) 89 | } 90 | -------------------------------------------------------------------------------- /src/constants.rs: -------------------------------------------------------------------------------- 1 | pub static GALAXY_CLIENT_ID: &str = "46899977096215655"; 2 | pub static NOTIFICATIONS_PUSHER_SOCKET: &str = "wss://notifications-pusher.gog.com/"; 3 | 4 | use crate::api::structs::Token; 5 | use std::{collections::HashMap, sync::Arc}; 6 | use tokio::sync::Mutex; 7 | 8 | pub type TokenStorage = Arc>>; 9 | -------------------------------------------------------------------------------- /src/db.rs: -------------------------------------------------------------------------------- 1 | pub mod gameplay; 2 | -------------------------------------------------------------------------------- /src/db/gameplay.rs: -------------------------------------------------------------------------------- 1 | use crate::api::gog::achievements::Achievement; 2 | use crate::api::gog::leaderboards::LeaderboardDefinition; 3 | use crate::api::gog::stats::{FieldValue, Stat}; 4 | use crate::api::handlers::context::HandlerContext; 5 | use crate::paths; 6 | use log::info; 7 | use sqlx::sqlite::SqliteRow; 8 | use sqlx::{Acquire, Error, Row, SqlitePool}; 9 | 10 | pub const SETUP_QUERY: &str = r#" 11 | CREATE TABLE IF NOT EXISTS `leaderboard` (`id` INTEGER PRIMARY KEY NOT NULL,`key` TEXT UNIQUE NOT NULL,`name` TEXT NOT NULL,`sort_method` TEXT CHECK ( sort_method IN ( 'SORT_METHOD_ASCENDING', 'SORT_METHOD_DESCENDING' ) ) NOT NULL,`display_type` TEXT CHECK ( display_type IN ( 'DISPLAY_TYPE_NUMERIC', 'DISPLAY_TYPE_TIME_SECONDS', 'DISPLAY_TYPE_TIME_MILLISECONDS' ) ) NOT NULL,`score` INTEGER NOT NULL DEFAULT 0,`rank` INTEGER NOT NULL DEFAULT 0,`force_update` INTEGER CHECK ( force_update IN ( 0, 1 ) ) NOT NULL DEFAULT 0,`changed` INTEGER CHECK ( changed IN ( 0, 1 ) ) NOT NULL, entry_total_count INTEGER NOT NULL DEFAULT 0, details TEXT NOT NULL DEFAULT ""); 12 | CREATE TABLE IF NOT EXISTS `achievement` (`id` INTEGER PRIMARY KEY NOT NULL,`key` TEXT UNIQUE NOT NULL,`name` TEXT NOT NULL,`description` TEXT NOT NULL,`visible_while_locked` INTEGER CHECK ( visible_while_locked IN ( 0, 1 ) ) NOT NULL,`unlock_time` TEXT,`image_url_locked` TEXT NOT NULL,`image_url_unlocked` TEXT NOT NULL,`changed` INTEGER CHECK ( changed IN ( 0, 1 ) ) NOT NULL, rarity REAL NOT NULL DEFAULT 0.0, rarity_level_description TEXT NOT NULL DEFAULT "", rarity_level_slug TEXT NOT NULL DEFAULT ""); 13 | CREATE TABLE IF NOT EXISTS `statistic` (`id` INTEGER PRIMARY KEY NOT NULL,`key` TEXT UNIQUE NOT NULL,`type` TEXT CHECK ( type IN ( 'INT', 'FLOAT', 'AVGRATE' ) ) NOT NULL,`increment_only` INTEGER CHECK ( increment_only IN ( 0, 1 ) ) NOT NULL,`changed` INTEGER CHECK ( changed IN ( 0, 1 ) ) NOT NULL); 14 | CREATE INDEX IF NOT EXISTS `is_leaderboard_score_changed` on leaderboard (changed); 15 | CREATE INDEX IF NOT EXISTS `is_achievement_changed` ON achievement (changed); 16 | CREATE INDEX IF NOT EXISTS `is_statistic_changed` ON statistic (changed); 17 | CREATE TABLE IF NOT EXISTS `game_info` (`time_played` INTEGER NOT NULL); 18 | CREATE TABLE IF NOT EXISTS `int_statistic` (`id` INTEGER REFERENCES statistic ( id ) NOT NULL UNIQUE,`value` INTEGER NOT NULL DEFAULT 0,`default_value` INTEGER NOT NULL DEFAULT 0,`min_value` INTEGER,`max_value` INTEGER,`max_change` INTEGER); 19 | CREATE TABLE IF NOT EXISTS `float_statistic` (`id` INTEGER REFERENCES statistic ( id ) NOT NULL UNIQUE,`value` REAL NOT NULL DEFAULT 0,`default_value` REAL NOT NULL DEFAULT 0,`min_value` REAL,`max_value` REAL,`max_change` REAL,`window` REAL DEFAULT NULL); 20 | CREATE TABLE IF NOT EXISTS `database_info` (`key` TEXT PRIMARY KEY NOT NULL,`value` TEXT NOT NULL); 21 | "#; 22 | 23 | pub async fn setup_connection(client_id: &str, user_id: &str) -> Result { 24 | let databases_path = paths::GAMEPLAY_STORAGE.join(client_id).join(user_id); 25 | let database_file = databases_path.join("gameplay.db"); 26 | if !databases_path.exists() { 27 | let _ = tokio::fs::create_dir_all(&databases_path).await; 28 | } 29 | 30 | if !database_file.exists() { 31 | let _ = tokio::fs::File::create(&database_file).await; 32 | } 33 | 34 | info!("Setting up database at {:?}", database_file); 35 | let url = String::from("sqlite:") + database_file.to_str().unwrap(); 36 | 37 | SqlitePool::connect(&url).await 38 | } 39 | 40 | pub async fn has_statistics(database: &SqlitePool) -> bool { 41 | let connection = database.acquire().await; 42 | if connection.is_err() { 43 | return false; 44 | } 45 | let mut connection = connection.unwrap(); 46 | let res = sqlx::query("SELECT * FROM database_info WHERE key='stats_retrieved'") 47 | .fetch_one(&mut *connection) 48 | .await; 49 | 50 | match res { 51 | Ok(result) => { 52 | let value = result 53 | .try_get("value") 54 | .unwrap_or("0") 55 | .parse::() 56 | .unwrap(); 57 | !result.is_empty() && value != 0 58 | } 59 | Err(_) => false, 60 | } 61 | } 62 | 63 | pub async fn get_statistics( 64 | context: &HandlerContext, 65 | only_changed: bool, 66 | ) -> Result, Error> { 67 | let database = context.db_connection().await; 68 | let mut connection = database.acquire().await?; 69 | let mut stats: Vec = Vec::new(); 70 | let int_stats = sqlx::query( 71 | "SELECT s.id, s.key, s.increment_only, 72 | i.value, i.default_value, i.min_value, i.max_value, i.max_change 73 | FROM int_statistic AS i 74 | JOIN statistic AS s 75 | ON s.id = i.id 76 | WHERE ($1=0 OR s.changed=1)", 77 | ) 78 | .bind(only_changed as u8) 79 | .fetch_all(&mut *connection) 80 | .await?; 81 | let float_stats = sqlx::query( 82 | r#"SELECT s.id, s.key, s.type, s.increment_only, 83 | f.value, f.default_value, f.min_value, f.max_value, f.max_change, f.window 84 | FROM float_statistic AS f 85 | JOIN statistic AS s 86 | ON s.id = f.id 87 | WHERE ($1=0 OR s.changed=1)"#, 88 | ) 89 | .bind(only_changed as u8) 90 | .fetch_all(&mut *connection) 91 | .await?; 92 | 93 | for int_stat in int_stats { 94 | let id: i64 = int_stat.try_get("id").unwrap(); 95 | let key: String = int_stat.try_get("key").unwrap(); 96 | let increment_only: u8 = int_stat.try_get("increment_only").unwrap(); 97 | let values = FieldValue::Int { 98 | value: int_stat.try_get("value").unwrap(), 99 | default_value: int_stat.try_get("default_value").unwrap(), 100 | min_value: int_stat.try_get("min_value").unwrap(), 101 | max_value: int_stat.try_get("max_value").unwrap(), 102 | max_change: int_stat.try_get("max_change").unwrap(), 103 | }; 104 | let new_stat = Stat::new(id.to_string(), key, None, increment_only == 1, values); 105 | stats.push(new_stat) 106 | } 107 | 108 | for float_stat in float_stats { 109 | let id: i64 = float_stat.try_get("id").unwrap(); 110 | let key: String = float_stat.try_get("key").unwrap(); 111 | let increment_only: u8 = float_stat.try_get("increment_only").unwrap(); 112 | let window: Option = float_stat.try_get("window").unwrap(); 113 | let value_type: String = float_stat.try_get("type").unwrap(); 114 | let values: FieldValue = match value_type.as_str() { 115 | "FLOAT" => FieldValue::Float { 116 | value: float_stat.try_get("value").unwrap(), 117 | default_value: float_stat.try_get("default_value").unwrap(), 118 | min_value: float_stat.try_get("min_value").unwrap(), 119 | max_value: float_stat.try_get("max_value").unwrap(), 120 | max_change: float_stat.try_get("max_change").unwrap(), 121 | }, 122 | "AVGRATE" => FieldValue::Avgrate { 123 | value: float_stat.try_get("value").unwrap(), 124 | default_value: float_stat.try_get("default_value").unwrap(), 125 | min_value: float_stat.try_get("min_value").unwrap(), 126 | max_value: float_stat.try_get("max_value").unwrap(), 127 | max_change: float_stat.try_get("max_change").unwrap(), 128 | }, 129 | _ => panic!("Unsupported value type"), 130 | }; 131 | let new_stat = Stat::new(id.to_string(), key, window, increment_only == 1, values); 132 | stats.push(new_stat) 133 | } 134 | 135 | Ok(stats) 136 | } 137 | 138 | pub async fn set_statistics(database: SqlitePool, stats: &Vec) -> Result<(), Error> { 139 | let mut connection = database.acquire().await?; 140 | let mut transaction = connection.begin().await?; 141 | 142 | for stat in stats { 143 | let stat_id = stat.stat_id().parse::().unwrap(); 144 | let stat_type = match stat.values() { 145 | FieldValue::Int { .. } => "INT", 146 | FieldValue::Float { .. } => "FLOAT", 147 | FieldValue::Avgrate { .. } => "AVGRATE", 148 | }; 149 | sqlx::query( 150 | "INSERT INTO statistic VALUES ($1, $2, $3, $4, 0) ON CONFLICT(id) 151 | DO UPDATE SET key=excluded.key, type=excluded.type, increment_only=excluded.increment_only", 152 | ) 153 | .bind(stat_id) 154 | .bind(stat.stat_key()) 155 | .bind(stat_type) 156 | .bind(stat.increment_only().to_owned() as u8) 157 | .execute(&mut *transaction) 158 | .await?; 159 | 160 | match stat.values() { 161 | FieldValue::Int { 162 | value, 163 | default_value, 164 | max_value, 165 | min_value, 166 | max_change, 167 | } => { 168 | log::debug!("Inserting int"); 169 | sqlx::query( 170 | "INSERT INTO int_statistic VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id) 171 | DO UPDATE SET value=excluded.value, default_value=excluded.default_value, 172 | min_value=excluded.min_value, max_change=excluded.max_change 173 | WHERE int_statistic.id NOT IN (SELECT id FROM statistic WHERE changed=1)", 174 | ) 175 | .bind(stat_id) 176 | .bind(value) 177 | .bind(default_value.unwrap_or_else(|| 0)) 178 | .bind(min_value) 179 | .bind(max_value) 180 | .bind(max_change) 181 | .execute(&mut *transaction) 182 | .await?; 183 | } 184 | 185 | FieldValue::Float { 186 | value, 187 | default_value, 188 | min_value, 189 | max_value, 190 | max_change, 191 | } 192 | | FieldValue::Avgrate { 193 | value, 194 | default_value, 195 | min_value, 196 | max_value, 197 | max_change, 198 | } => { 199 | log::debug!("Inserting float"); 200 | sqlx::query( 201 | "INSERT INTO float_statistic VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) 202 | DO UPDATE SET value=excluded.value, default_value=excluded.default_value, 203 | min_value=excluded.min_value, max_change=excluded.max_change, window=excluded.window 204 | WHERE float_statistic.id NOT IN (SELECT id FROM statistic WHERE changed=1)", 205 | ) 206 | .bind(stat_id) 207 | .bind(value) 208 | .bind(default_value.unwrap_or_else(|| 0.0)) 209 | .bind(min_value) 210 | .bind(max_value) 211 | .bind(max_change) 212 | .bind(stat.window()) 213 | .execute(&mut *transaction) 214 | .await?; 215 | } 216 | } 217 | } 218 | 219 | let _ = sqlx::query("INSERT INTO database_info VALUES ('stats_retrieved', '1')") 220 | .execute(&mut *transaction) 221 | .await; 222 | 223 | let _ = sqlx::query("UPDATE database_info SET value='1' WHERE key='stats_retrieved'") 224 | .execute(&mut *transaction) 225 | .await; 226 | 227 | transaction.commit().await?; 228 | Ok(()) 229 | } 230 | 231 | pub async fn set_stat_float( 232 | context: &HandlerContext, 233 | stat_id: i64, 234 | value: f32, 235 | ) -> Result<(), Error> { 236 | let database = context.db_connection().await; 237 | let mut connection = database.acquire().await?; 238 | 239 | sqlx::query("UPDATE float_statistic SET value=$1 WHERE id=$2; UPDATE statistic SET changed=1 WHERE id=$2;") 240 | .bind(value) 241 | .bind(stat_id) 242 | .execute(&mut *connection) 243 | .await?; 244 | 245 | Ok(()) 246 | } 247 | pub async fn set_stat_int(context: &HandlerContext, stat_id: i64, value: i32) -> Result<(), Error> { 248 | let database = context.db_connection().await; 249 | let mut connection = database.acquire().await?; 250 | 251 | sqlx::query("UPDATE int_statistic SET value=$1 WHERE id=$2; UPDATE statistic SET changed=1 WHERE id=$2;") 252 | .bind(value) 253 | .bind(stat_id) 254 | .execute(&mut *connection) 255 | .await?; 256 | 257 | Ok(()) 258 | } 259 | 260 | pub async fn reset_stats(context: &HandlerContext) -> Result<(), Error> { 261 | let database = context.db_connection().await; 262 | let mut connection = database.acquire().await?; 263 | 264 | sqlx::query("UPDATE float_statistic SET value=default_value; UPDATE int_statistic SET value=default_value; UPDATE statistic SET changed=0") 265 | .execute(&mut *connection) 266 | .await?; 267 | 268 | Ok(()) 269 | } 270 | 271 | pub async fn has_achievements(database: &SqlitePool) -> bool { 272 | let connection = database.acquire().await; 273 | if connection.is_err() { 274 | return false; 275 | } 276 | let mut connection = connection.unwrap(); 277 | let res = sqlx::query("SELECT * FROM database_info WHERE key='achievements_retrieved'") 278 | .fetch_one(&mut *connection) 279 | .await; 280 | 281 | match res { 282 | Ok(result) => { 283 | let value = result 284 | .try_get("value") 285 | .unwrap_or("0") 286 | .parse::() 287 | .unwrap(); 288 | !result.is_empty() && value != 0 289 | } 290 | Err(_) => false, 291 | } 292 | } 293 | 294 | fn achievement_from_database_row(row: SqliteRow) -> Achievement { 295 | let visible: u8 = row.try_get("visible_while_locked").unwrap(); 296 | let achievement_id: i64 = row.try_get("id").unwrap(); 297 | Achievement::new( 298 | achievement_id.to_string(), 299 | row.try_get("key").unwrap(), 300 | row.try_get("name").unwrap(), 301 | row.try_get("description").unwrap(), 302 | row.try_get("image_url_locked").unwrap(), 303 | row.try_get("image_url_unlocked").unwrap(), 304 | visible == 1, 305 | row.try_get("unlock_time").unwrap(), 306 | row.try_get("rarity").unwrap(), 307 | row.try_get("rarity_level_description").unwrap(), 308 | row.try_get("rarity_level_slug").unwrap(), 309 | ) 310 | } 311 | 312 | pub async fn get_achievements( 313 | context: &HandlerContext, 314 | only_changed: bool, 315 | ) -> Result<(Vec, String), Error> { 316 | let database = context.db_connection().await; 317 | let mut connection = database.acquire().await?; 318 | let mut achievements: Vec = Vec::new(); 319 | 320 | let mode_res = sqlx::query("SELECT * FROM database_info WHERE key='achievements_mode'") 321 | .fetch_one(&mut *connection) 322 | .await; 323 | 324 | if let Err(sqlx::Error::RowNotFound) = mode_res { 325 | return Ok((achievements, String::default())); 326 | } 327 | let achievements_mode = mode_res.unwrap().try_get("value")?; 328 | 329 | let db_achievements = sqlx::query( 330 | r#"SELECT id, key, name, description, visible_while_locked, 331 | unlock_time, image_url_locked, image_url_unlocked, rarity, 332 | rarity_level_description, rarity_level_slug 333 | FROM achievement WHERE ($1=0 OR changed=1)"#, 334 | ) 335 | .bind(only_changed as u8) 336 | .fetch_all(&mut *connection) 337 | .await?; 338 | 339 | for row in db_achievements { 340 | let new_achievement = achievement_from_database_row(row); 341 | achievements.push(new_achievement); 342 | } 343 | 344 | Ok((achievements, achievements_mode)) 345 | } 346 | 347 | pub async fn set_achievements( 348 | database: SqlitePool, 349 | achievements: &Vec, 350 | mode: &str, 351 | ) -> Result<(), Error> { 352 | let mut connection = database.acquire().await?; 353 | let mut transaction = connection.begin().await?; 354 | 355 | for achievement in achievements { 356 | let achievement_id = achievement.achievement_id().parse::().unwrap(); 357 | 358 | sqlx::query( 359 | "INSERT INTO achievement VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, $9, $10, $11) 360 | ON CONFLICT(id) DO UPDATE SET key=excluded.key, 361 | name=excluded.name, description=excluded.description, 362 | visible_while_locked=excluded.visible_while_locked, 363 | image_url_locked=excluded.image_url_locked, 364 | image_url_unlocked=excluded.image_url_unlocked, 365 | rarity=excluded.rarity, rarity_level_description=excluded.rarity_level_description, 366 | rarity_level_slug=excluded.rarity_level_slug 367 | ", 368 | ) 369 | .bind(achievement_id) 370 | .bind(achievement.achievement_key()) 371 | .bind(achievement.name()) 372 | .bind(achievement.description()) 373 | .bind(*achievement.visible() as u32) 374 | .bind(achievement.date_unlocked()) 375 | .bind(achievement.image_url_locked()) 376 | .bind(achievement.image_url_unlocked()) 377 | .bind(achievement.rarity()) 378 | .bind(achievement.rarity_level_description()) 379 | .bind(achievement.rarity_level_slug()) 380 | .execute(&mut *transaction) 381 | .await?; 382 | 383 | sqlx::query("UPDATE achievement SET unlock_time=$1 WHERE id=$2 AND changed=0") 384 | .bind(achievement.date_unlocked()) 385 | .bind(achievement_id) 386 | .execute(&mut *transaction) 387 | .await?; 388 | } 389 | 390 | sqlx::query( 391 | r"INSERT INTO database_info VALUES 392 | ('achievements_retrieved', '1'), ('achievements_mode', $1) 393 | ON CONFLICT(key) DO UPDATE SET value=excluded.value", 394 | ) 395 | .bind(mode) 396 | .execute(&mut *transaction) 397 | .await?; 398 | 399 | transaction.commit().await?; 400 | Ok(()) 401 | } 402 | 403 | pub async fn get_achievement( 404 | context: &HandlerContext, 405 | achievement_id: i64, 406 | ) -> Result { 407 | let database = context.db_connection().await; 408 | let mut connection = database.acquire().await?; 409 | 410 | let result = sqlx::query( 411 | r#"SELECT id, key, name, description, visible_while_locked, 412 | unlock_time, image_url_locked, image_url_unlocked, rarity, 413 | rarity_level_description, rarity_level_slug 414 | FROM achievement WHERE id=$1"#, 415 | ) 416 | .bind(achievement_id) 417 | .fetch_one(&mut *connection) 418 | .await?; 419 | 420 | Ok(achievement_from_database_row(result)) 421 | } 422 | 423 | pub async fn set_achievement( 424 | context: &HandlerContext, 425 | achievement_id: i64, 426 | date_unlocked: Option, 427 | ) -> Result<(), Error> { 428 | let database = context.db_connection().await; 429 | let mut connection = database.acquire().await?; 430 | 431 | sqlx::query("UPDATE achievement SET changed=1, unlock_time=? WHERE id=?") 432 | .bind(date_unlocked) 433 | .bind(achievement_id) 434 | .execute(&mut *connection) 435 | .await?; 436 | 437 | Ok(()) 438 | } 439 | 440 | pub async fn reset_achievements(context: &HandlerContext) -> Result<(), Error> { 441 | let database = context.db_connection().await; 442 | let mut connection = database.acquire().await?; 443 | 444 | sqlx::query("UPDATE achievement SET changed=0, unlock_time=NULL") 445 | .execute(&mut *connection) 446 | .await?; 447 | Ok(()) 448 | } 449 | 450 | pub async fn update_leaderboards( 451 | context: &HandlerContext, 452 | leaderboard_definitions: &Vec, 453 | ) -> Result<(), Error> { 454 | let mut connection = context.db_connection().await.acquire().await?; 455 | let mut transaction = connection.begin().await?; 456 | 457 | for def in leaderboard_definitions { 458 | let sort_method = match def.sort_method().as_str() { 459 | "desc" => "SORT_METHOD_DESCENDING", 460 | _ => "SORT_METHOD_ASCENDING", 461 | }; 462 | let display_type = match def.display_type().as_str() { 463 | "seconds" => "DISPLAY_TYPE_TIME_SECONDS", 464 | "milliseconds" => "DISPLAY_TYPE_TIME_MILLISECONDS", 465 | _ => "DISPLAY_TYPE_NUMERIC", 466 | }; 467 | log::trace!("Inserting new leaderboard entry {}", def.key()); 468 | sqlx::query( 469 | r"INSERT INTO leaderboard (id, key, name, sort_method, display_type, changed) 470 | VALUES ($1, $2, $3, $4, $5, 0) 471 | ON CONFLICT(id) DO UPDATE 472 | SET key=excluded.key,name=excluded.name, 473 | sort_method=excluded.sort_method,display_type=excluded.display_type", 474 | ) 475 | .bind(def.id()) 476 | .bind(def.key()) 477 | .bind(def.name()) 478 | .bind(sort_method) 479 | .bind(display_type) 480 | .execute(&mut *transaction) 481 | .await?; 482 | } 483 | 484 | transaction.commit().await?; 485 | Ok(()) 486 | } 487 | 488 | fn row_to_leaderboard_def(row: &SqliteRow) -> LeaderboardDefinition { 489 | let id: i64 = row.try_get("id").unwrap(); 490 | let key: String = row.try_get("key").unwrap(); 491 | let name: String = row.try_get("name").unwrap(); 492 | let sort_method: String = row.try_get("sort_method").unwrap(); 493 | let display_type: String = row.try_get("display_type").unwrap(); 494 | 495 | let sort_method = match sort_method.as_str() { 496 | "SORT_METHOD_DESCENDING" => "desc", 497 | _ => "asc", 498 | } 499 | .to_string(); 500 | let display_type = match display_type.as_str() { 501 | "DISPLAY_TYPE_TIME_SECONDS" => "seconds", 502 | "DISPLAY_TYPE_TIME_MILLISECONDS" => "milliseconds", 503 | _ => "numeric", 504 | } 505 | .to_string(); 506 | LeaderboardDefinition::new(id.to_string(), key, name, sort_method, display_type) 507 | } 508 | 509 | pub async fn get_leaderboards_defs( 510 | context: &HandlerContext, 511 | params: I, 512 | ) -> Result, Error> 513 | where 514 | I: IntoIterator, 515 | K: AsRef, 516 | V: AsRef + std::fmt::Display, 517 | { 518 | let mut connection = context.db_connection().await.acquire().await?; 519 | let data = sqlx::query("SELECT * FROM leaderboard") 520 | .fetch_all(&mut *connection) 521 | .await?; 522 | 523 | let leaderboards = data.iter().map(row_to_leaderboard_def); 524 | if let Some((_, value)) = params.into_iter().find(|(k, _v)| k.as_ref() == "keys") { 525 | let value = value.to_string(); 526 | let ids: Vec<&str> = value.split(',').collect(); 527 | return Ok(leaderboards 528 | .filter(|x| ids.contains(&x.id().as_str())) 529 | .collect()); 530 | } 531 | Ok(leaderboards.collect()) 532 | } 533 | 534 | pub async fn get_leaderboards_score_changed( 535 | context: &HandlerContext, 536 | ) -> Result, Error> { 537 | let mut connection = context.db_connection().await.acquire().await?; 538 | let mut leaderboards = Vec::new(); 539 | let rows = sqlx::query("SELECT id, score, rank, force_update, details, entry_total_count FROM leaderboard WHERE changed=1") 540 | .fetch_all(&mut *connection) 541 | .await?; 542 | 543 | for row in rows { 544 | let id: i64 = row.try_get("id").unwrap(); 545 | let score: i32 = row.try_get("score").unwrap(); 546 | let rank: u32 = row.try_get("rank").unwrap(); 547 | let entry_total_count: u32 = row.try_get("entry_total_count").unwrap(); 548 | let force: bool = row.try_get("force_update").unwrap(); 549 | let details: String = row.try_get("details").unwrap(); 550 | leaderboards.push((id, score, rank, entry_total_count, force, details)) 551 | } 552 | 553 | Ok(leaderboards) 554 | } 555 | 556 | pub async fn get_leaderboard_score( 557 | context: &HandlerContext, 558 | leaderboard_id: &str, 559 | ) -> Result<(i32, u32, u32, bool, String), Error> { 560 | let mut connection = context.db_connection().await.acquire().await?; 561 | let row = 562 | sqlx::query("SELECT score, rank, force_update, details, entry_total_count FROM leaderboard WHERE id = $1") 563 | .bind(leaderboard_id) 564 | .fetch_one(&mut *connection) 565 | .await?; 566 | let score: i32 = row.try_get("score").unwrap(); 567 | let rank: u32 = row.try_get("rank").unwrap(); 568 | let entry_total_count: u32 = row.try_get("entry_total_count").unwrap(); 569 | let force: bool = row.try_get("force_update").unwrap(); 570 | let details: String = row.try_get("details").unwrap(); 571 | Ok((score, rank, entry_total_count, force, details)) 572 | } 573 | 574 | pub async fn set_leaderboad_changed( 575 | context: &HandlerContext, 576 | leaderboard_id: &str, 577 | changed: bool, 578 | ) -> Result<(), Error> { 579 | let mut connection = context.db_connection().await.acquire().await?; 580 | sqlx::query("UPDATE leaderboard SET changed=$1 WHERE id = $2") 581 | .bind(changed) 582 | .bind(leaderboard_id) 583 | .execute(&mut *connection) 584 | .await?; 585 | 586 | Ok(()) 587 | } 588 | 589 | pub async fn set_leaderboard_score( 590 | context: &HandlerContext, 591 | leaderboard_id: &str, 592 | score: i32, 593 | force: bool, 594 | details: &str, 595 | ) -> Result<(), Error> { 596 | let mut connection = context.db_connection().await.acquire().await?; 597 | sqlx::query("UPDATE leaderboard SET score=$1, force_update=$2, details=$3 WHERE id = $4") 598 | .bind(score) 599 | .bind(force as u8) 600 | .bind(details) 601 | .bind(leaderboard_id) 602 | .execute(&mut *connection) 603 | .await?; 604 | 605 | Ok(()) 606 | } 607 | 608 | pub async fn set_leaderboard_rank( 609 | context: &HandlerContext, 610 | leaderboard_id: &str, 611 | rank: u32, 612 | total_entries: u32, 613 | ) -> Result<(), Error> { 614 | let mut connection = context.db_connection().await.acquire().await?; 615 | sqlx::query("UPDATE leaderboard SET score=$1, entry_total_count=$2 WHERE id = $3") 616 | .bind(rank) 617 | .bind(total_entries) 618 | .bind(leaderboard_id) 619 | .execute(&mut *connection) 620 | .await?; 621 | 622 | Ok(()) 623 | } 624 | -------------------------------------------------------------------------------- /src/import_parsers.rs: -------------------------------------------------------------------------------- 1 | use crate::constants; 2 | 3 | mod heroic; 4 | #[cfg(target_os = "linux")] 5 | mod lutris; 6 | #[cfg(target_os = "linux")] 7 | mod wyvern; 8 | 9 | pub fn handle_credentials_import(args: &crate::Args) -> (String, String, String) { 10 | if args.heroic { 11 | log::debug!("Loading Heroic credentials"); 12 | let config = heroic::load_tokens(); 13 | let config = config 14 | .fields 15 | .get(constants::GALAXY_CLIENT_ID) 16 | .expect("No Galaxy credentials"); 17 | 18 | let access_token = config 19 | .get("access_token") 20 | .expect("access_token not present in heroic config") 21 | .as_str() 22 | .unwrap() 23 | .to_owned(); 24 | let refresh_token = config 25 | .get("refresh_token") 26 | .expect("refresh_token not present in heroic config") 27 | .as_str() 28 | .unwrap() 29 | .to_owned(); 30 | let galaxy_user_id = config 31 | .get("user_id") 32 | .expect("user_id not present in heroic config") 33 | .as_str() 34 | .unwrap() 35 | .to_owned(); 36 | return (access_token, refresh_token, galaxy_user_id); 37 | } 38 | 39 | #[cfg(target_os = "linux")] 40 | if args.lutris { 41 | let config = lutris::load_tokens(); 42 | let access_token = config 43 | .get("access_token") 44 | .expect("access_token not present in lutris config") 45 | .as_str() 46 | .unwrap() 47 | .to_owned(); 48 | let refresh_token = config 49 | .get("refresh_token") 50 | .expect("refresh_token not present in lutris config") 51 | .as_str() 52 | .unwrap() 53 | .to_owned(); 54 | let galaxy_user_id = config 55 | .get("user_id") 56 | .expect("user_id not present in lutris config") 57 | .as_str() 58 | .unwrap() 59 | .to_owned(); 60 | return (access_token, refresh_token, galaxy_user_id); 61 | } 62 | 63 | #[cfg(target_os = "linux")] 64 | if args.wyvern { 65 | let config = wyvern::load_tokens(); 66 | let token = config.token; 67 | 68 | return token.dissolve(); 69 | } 70 | 71 | let access_token = args.access_token.clone().expect("Access token is required"); 72 | let refresh_token = args 73 | .refresh_token 74 | .clone() 75 | .expect("Refresh token is required"); 76 | let galaxy_user_id = args.user_id.clone().expect("User id is required"); 77 | 78 | (access_token, refresh_token, galaxy_user_id) 79 | } 80 | -------------------------------------------------------------------------------- /src/import_parsers/heroic.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::collections::HashMap; 3 | use std::env; 4 | use std::fs; 5 | use std::path; 6 | 7 | #[derive(Serialize, Deserialize)] 8 | pub struct HeroicAuthConfig { 9 | #[serde(flatten)] 10 | pub fields: HashMap, 11 | } 12 | 13 | #[cfg(target_os = "linux")] 14 | fn get_config_path() -> Option { 15 | let home_dir = env::var("HOME").unwrap(); 16 | let home_dir = path::Path::new(&home_dir); 17 | 18 | let config_path = env::var("XDG_CONFIG_PATH") 19 | .unwrap_or_else(|_e| home_dir.join(".config").to_str().unwrap().to_owned()); 20 | let config_path = path::Path::new(&config_path); 21 | 22 | let host_path = config_path.join("heroic/gog_store/auth.json"); 23 | let flatpak_path = 24 | home_dir.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/gog_store/auth.json"); 25 | 26 | if host_path.exists() { 27 | Some(host_path) 28 | } else if flatpak_path.exists() { 29 | Some(flatpak_path) 30 | } else { 31 | None 32 | } 33 | } 34 | 35 | #[cfg(target_os = "windows")] 36 | fn get_config_path() -> Option { 37 | let appdata = env::var("APPDATA").unwrap(); 38 | let appdata = path::Path::new(&appdata); 39 | 40 | Some(appdata.join("heroic/gog_store/auth.json")) 41 | } 42 | 43 | #[cfg(target_os = "macos")] 44 | fn get_config_path() -> Option { 45 | let app_support = env::var("HOME").unwrap(); 46 | let app_support = path::Path::new(&app_support).join("Library/Application Support"); 47 | Some(app_support.join("heroic/gog_store/auth.json")) 48 | } 49 | 50 | pub fn load_tokens() -> HeroicAuthConfig { 51 | let config_path = get_config_path().expect("No heroic's auth.json found"); 52 | log::debug!("Loading Heroic credentials from {:?}", config_path); 53 | let data = fs::read(config_path).expect("Failed to read heroic auth file"); 54 | let data = data.as_slice(); 55 | 56 | let parsed: HeroicAuthConfig = 57 | serde_json::from_slice(data).expect("Heroic auth file is corrupted"); 58 | parsed 59 | } 60 | -------------------------------------------------------------------------------- /src/import_parsers/lutris.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use std::{env, fs, path}; 3 | 4 | #[derive(Deserialize)] 5 | struct LutrisSection { 6 | cache_dir: Option, 7 | } 8 | 9 | #[derive(Deserialize)] 10 | struct LutrisConf { 11 | lutris: LutrisSection, 12 | } 13 | 14 | fn get_config_path() -> Option { 15 | let home_dir = env::var("HOME").ok()?; 16 | let home_dir = path::Path::new(&home_dir); 17 | 18 | let config_path: path::PathBuf = env::var("XDG_CONFIG_HOME") 19 | .unwrap_or_else(|_e| home_dir.join(".config").to_str().unwrap().to_string()) 20 | .into(); 21 | let data_path: path::PathBuf = env::var("XDG_DATA_HOME") 22 | .unwrap_or_else(|_e| home_dir.join(".local/share").to_str().unwrap().to_string()) 23 | .into(); 24 | let cache_path: path::PathBuf = env::var("XDG_CACHE_HOME") 25 | .unwrap_or_else(|_e| home_dir.join(".cache").to_str().unwrap().to_owned()) 26 | .into(); 27 | 28 | // Look for lutris.conf in order 29 | // 1. $XDG_CONFIG_HOME/lutris/lutris.conf 30 | // 2. $XDG_DATA_HOME/lutris/lutris.conf 31 | // For both host and flatpak paths 32 | 33 | let lutris_conf = config_path.join("lutris/lutris.conf"); 34 | let lutris_dconf = data_path.join("lutris/lutris.conf"); 35 | let lutris_conf_data = fs::read_to_string(lutris_conf) 36 | .or_else(|_| fs::read_to_string(lutris_dconf)) 37 | .or_else(|_| { 38 | fs::read_to_string( 39 | home_dir.join(".var/app/net.lutris.Lutris/config/lutris/lutris.conf"), 40 | ) 41 | }) 42 | .or_else(|_| { 43 | fs::read_to_string(home_dir.join(".var/app/net.lutris.Lutris/data/lutris/lutris.conf")) 44 | }); 45 | match lutris_conf_data { 46 | Ok(data) => { 47 | // Attempt to parse the config in search for custom cache_dir 48 | match serde_ini::from_str::(&data) { 49 | Ok(config_data) => { 50 | if let Some(dir) = config_data.lutris.cache_dir { 51 | let dir: path::PathBuf = dir.into(); 52 | let token_path = dir.join(".gog.token"); 53 | // Check if token config exists, if it doesn't return None 54 | if token_path.exists() { 55 | return Some(token_path); 56 | } else { 57 | return None; 58 | } 59 | } else { 60 | log::debug!("Cache dir not specified in lutris config"); 61 | } 62 | } 63 | Err(err) => { 64 | log::warn!("Failed to parse lutris config: {:?}", err); 65 | } 66 | } 67 | } 68 | Err(err) => log::warn!("Failed to read lutris.conf: {:?}", err), 69 | } 70 | 71 | let host_path = cache_path.join("lutris/.gog.token"); 72 | let flatpak_path = home_dir.join(".var/app/net.lutris.Lutris/cache/lutris/.gog.token"); 73 | 74 | if host_path.exists() { 75 | Some(host_path) 76 | } else if flatpak_path.exists() { 77 | Some(flatpak_path) 78 | } else { 79 | None 80 | } 81 | } 82 | 83 | pub fn load_tokens() -> serde_json::Value { 84 | let config_path = get_config_path().expect("No lutris tokens found"); 85 | log::debug!("Loading Lutris credentials from {:?}", config_path); 86 | let data = fs::read(config_path).expect("Failed to read lutris token file"); 87 | let data = data.as_slice(); 88 | 89 | let parsed: serde_json::Value = 90 | serde_json::from_slice(data).expect("Failed to parse lutris token file"); 91 | parsed 92 | } 93 | -------------------------------------------------------------------------------- /src/import_parsers/wyvern.rs: -------------------------------------------------------------------------------- 1 | use derive_getters::Dissolve; 2 | use serde::Deserialize; 3 | use std::env; 4 | use std::fs::read_to_string; 5 | use std::path; 6 | 7 | #[derive(Deserialize, Dissolve)] 8 | pub struct WyvernTokenData { 9 | pub access_token: String, 10 | pub refresh_token: String, 11 | pub user_id: String, 12 | } 13 | 14 | #[derive(Deserialize)] 15 | pub struct WyvernConfig { 16 | pub token: WyvernTokenData, 17 | } 18 | 19 | fn get_config_path() -> Option { 20 | let home_dir = env::var("HOME").unwrap(); 21 | let home_dir = path::Path::new(&home_dir); 22 | 23 | let config_path: path::PathBuf = env::var("XDG_CONFIG_HOME") 24 | .unwrap_or_else(|_e| home_dir.join(".config").to_str().unwrap().to_string()) 25 | .into(); 26 | 27 | let wyvern_config_path = config_path.join("wyvern/wyvern.toml"); 28 | 29 | if wyvern_config_path.exists() { 30 | Some(wyvern_config_path) 31 | } else { 32 | None 33 | } 34 | } 35 | 36 | pub fn load_tokens() -> WyvernConfig { 37 | let config_path = get_config_path().expect("Wyvern toml doesn't exist"); 38 | let data = read_to_string(config_path).expect("Failed to read wyvern.toml"); 39 | toml::from_str(&data).expect("Failed to parse wyvern config") 40 | } 41 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | use std::{collections::HashMap, sync::Arc}; 3 | 4 | use api::gog::overlay::OverlayPeerMessage; 5 | use clap::{Parser, Subcommand}; 6 | use env_logger::{Builder, Env, Target}; 7 | use futures_util::future::join_all; 8 | use log::{error, info, warn}; 9 | use reqwest::Client; 10 | use tokio::net::TcpListener; 11 | use tokio::sync::Mutex; 12 | #[macro_use] 13 | extern crate lazy_static; 14 | mod api; 15 | mod config; 16 | mod constants; 17 | mod db; 18 | mod import_parsers; 19 | mod paths; 20 | mod proto; 21 | 22 | use crate::api::notification_pusher::PusherEvent; 23 | use crate::api::structs::{Token, UserInfo}; 24 | use api::notification_pusher::NotificationPusherClient; 25 | 26 | static CERT: &[u8] = include_bytes!("../external/rootCA.pem"); 27 | 28 | #[derive(Subcommand, Debug)] 29 | enum SubCommand { 30 | #[command(about = "Preload achievements and statistics for offline usage")] 31 | Preload { 32 | client_id: String, 33 | client_secret: String, 34 | }, 35 | 36 | #[command(about = "Download overlay")] 37 | Overlay { 38 | #[arg(long, help = "Force the download of non-native overlay")] 39 | force: bool, 40 | }, 41 | } 42 | 43 | #[derive(Parser, Debug)] 44 | #[command(author, version, about)] 45 | struct Args { 46 | #[arg(long, help = "Provide access token (for getting user data)")] 47 | access_token: Option, 48 | #[arg(long, help = "Provide refresh token (for creating game sessions)")] 49 | refresh_token: Option, 50 | #[arg(long, help = "Galaxy user id from /userData.json")] 51 | user_id: Option, 52 | #[arg(long, help = "User name")] 53 | username: String, 54 | #[arg( 55 | long = "from-heroic", 56 | help = "Load tokens from heroic", 57 | global = true, 58 | group = "import" 59 | )] 60 | heroic: bool, 61 | #[arg( 62 | long = "from-lutris", 63 | help = "Load tokens from lutris", 64 | global = true, 65 | group = "import" 66 | )] 67 | #[cfg(target_os = "linux")] 68 | lutris: bool, 69 | 70 | #[arg( 71 | long = "from-wyvern", 72 | help = "Load tokens from wyvern", 73 | global = true, 74 | group = "import" 75 | )] 76 | #[cfg(target_os = "linux")] 77 | wyvern: bool, 78 | 79 | #[arg( 80 | short, 81 | long, 82 | global = true, 83 | help = "Make comet quit after every client disconnects. Use COMET_IDLE_WAIT environment variable to control the wait time (seconds)" 84 | )] 85 | quit: bool, 86 | 87 | #[command(subcommand)] 88 | subcommand: Option, 89 | } 90 | 91 | lazy_static! { 92 | static ref CONFIG: config::Configuration = config::load_config().unwrap_or_default(); 93 | static ref LOCALE: String = sys_locale::get_locale() 94 | .and_then(|x| if !x.contains("-") { None } else { Some(x) }) 95 | .unwrap_or_else(|| String::from("en-US")); 96 | } 97 | 98 | #[tokio::main] 99 | async fn main() { 100 | let args = Args::parse(); 101 | let env = Env::new().filter_or("COMET_LOG", "info"); 102 | Builder::from_env(env) 103 | .target(Target::Stderr) 104 | .filter_module("h2::codec", log::LevelFilter::Off) 105 | .init(); 106 | 107 | log::debug!("Configuration file {:?}", *CONFIG); 108 | log::info!("Preferred language: {}", LOCALE.as_str()); 109 | 110 | let (access_token, refresh_token, galaxy_user_id) = 111 | import_parsers::handle_credentials_import(&args); 112 | 113 | let certificate = reqwest::tls::Certificate::from_pem(CERT).unwrap(); 114 | let reqwest_client = Client::builder() 115 | .user_agent(format!("Comet/{}", env!("CARGO_PKG_VERSION"))) 116 | .add_root_certificate(certificate) 117 | .build() 118 | .expect("Failed to build reqwest client"); 119 | 120 | let user_info = Arc::new(UserInfo { 121 | username: args.username, 122 | galaxy_user_id: galaxy_user_id.clone(), 123 | }); 124 | 125 | let token_store: constants::TokenStorage = Arc::new(Mutex::new(HashMap::new())); 126 | let galaxy_token = Token::new(access_token.clone(), refresh_token.clone()); 127 | let mut store_lock = token_store.lock().await; 128 | store_lock.insert(String::from(constants::GALAXY_CLIENT_ID), galaxy_token); 129 | drop(store_lock); 130 | 131 | let client_clone = reqwest_client.clone(); 132 | tokio::spawn(async move { 133 | let mut retries = 0; 134 | loop { 135 | if retries > 10 { 136 | log::warn!("Failed to get peer libraries over 10 times, will not try again"); 137 | return; 138 | } 139 | tokio::time::sleep(Duration::from_secs(retries * 5)).await; 140 | retries += 1; 141 | 142 | let result_win = api::gog::components::get_component( 143 | &client_clone, 144 | paths::REDISTS_STORAGE.clone(), 145 | api::gog::components::Platform::Windows, 146 | api::gog::components::Component::Peer, 147 | ) 148 | .await; 149 | #[cfg(target_os = "macos")] 150 | let result_mac = api::gog::components::get_component( 151 | &client_clone, 152 | paths::REDISTS_STORAGE.clone(), 153 | api::gog::components::Platform::Mac, 154 | api::gog::components::Component::Peer, 155 | ) 156 | .await; 157 | 158 | #[cfg(target_os = "macos")] 159 | if result_win.is_ok() && result_mac.is_ok() { 160 | break; 161 | } else { 162 | continue; 163 | } 164 | 165 | #[cfg(not(target_os = "macos"))] 166 | if result_win.is_ok() { 167 | break; 168 | } 169 | } 170 | }); 171 | 172 | if let Some(subcommand) = args.subcommand { 173 | match subcommand { 174 | SubCommand::Preload { 175 | client_id, 176 | client_secret, 177 | } => { 178 | let database = db::gameplay::setup_connection(&client_id, &galaxy_user_id) 179 | .await 180 | .expect("Failed to setup the database"); 181 | 182 | if !db::gameplay::has_achievements(&database).await 183 | || !db::gameplay::has_statistics(&database).await 184 | { 185 | let mut connection = database.acquire().await.unwrap(); 186 | sqlx::query(db::gameplay::SETUP_QUERY) 187 | .execute(&mut *connection) 188 | .await 189 | .expect("Failed to setup the database"); 190 | drop(connection); 191 | 192 | let new_token = api::gog::users::get_token_for( 193 | &client_id, 194 | &client_secret, 195 | &refresh_token, 196 | &reqwest_client, 197 | false, 198 | ) 199 | .await 200 | .expect("Failed to obtain credentials"); 201 | 202 | let mut tokens = token_store.lock().await; 203 | tokens.insert(client_id.clone(), new_token); 204 | drop(tokens); 205 | 206 | let new_achievements = api::gog::achievements::fetch_achievements( 207 | &token_store, 208 | &client_id, 209 | &galaxy_user_id, 210 | &reqwest_client, 211 | ) 212 | .await; 213 | let new_stats = api::gog::stats::fetch_stats( 214 | &token_store, 215 | &client_id, 216 | &galaxy_user_id, 217 | &reqwest_client, 218 | ) 219 | .await; 220 | 221 | if let Ok((achievements, mode)) = new_achievements { 222 | db::gameplay::set_achievements(database.clone(), &achievements, &mode) 223 | .await 224 | .expect("Failed to write to the database"); 225 | info!("Got achievements"); 226 | } else { 227 | error!("Failed to fetch achievements"); 228 | } 229 | if let Ok(stats) = new_stats { 230 | db::gameplay::set_statistics(database.clone(), &stats) 231 | .await 232 | .expect("Failed to write to the database"); 233 | info!("Got stats"); 234 | } else { 235 | error!("Failed to fetch stats") 236 | } 237 | } else { 238 | info!("Already in database") 239 | } 240 | } 241 | SubCommand::Overlay { force } => { 242 | #[cfg(target_os = "linux")] 243 | if !force { 244 | error!("There is no linux native overlay, to download a windows version use --force"); 245 | return; 246 | } 247 | #[cfg(not(target_os = "linux"))] 248 | if force { 249 | warn!("The force flag has no effect on this platform"); 250 | } 251 | 252 | let web = api::gog::components::get_component( 253 | &reqwest_client, 254 | paths::REDISTS_STORAGE.clone(), 255 | api::gog::components::Platform::Windows, 256 | api::gog::components::Component::Web, 257 | ) 258 | .await; 259 | let overlay = api::gog::components::get_component( 260 | &reqwest_client, 261 | paths::REDISTS_STORAGE.clone(), 262 | #[cfg(not(target_os = "macos"))] 263 | api::gog::components::Platform::Windows, 264 | #[cfg(target_os = "macos")] 265 | api::gog::components::Platform::Mac, 266 | api::gog::components::Component::Overlay, 267 | ) 268 | .await; 269 | 270 | if let Err(err) = web { 271 | error!("Unexpected error occured when downloading web component {err}"); 272 | } 273 | 274 | if let Err(err) = overlay { 275 | error!("Unexpected error occured when downloading overlay component {err}"); 276 | } 277 | 278 | log::info!("Done"); 279 | } 280 | } 281 | 282 | return; 283 | } 284 | 285 | let listener = TcpListener::bind("127.0.0.1:9977") 286 | .await 287 | .expect("Failed to bind to port 9977"); 288 | 289 | let (topic_sender, _) = tokio::sync::broadcast::channel::(20); 290 | let shutdown_token = tokio_util::sync::CancellationToken::new(); 291 | let pusher_shutdown = shutdown_token.clone(); // Handler for notifications-pusher 292 | let cloned_shutdown = shutdown_token.clone(); // Handler to share between main thread and sockets 293 | 294 | let notifications_pusher_topic_sender = topic_sender.clone(); 295 | let pusher_handle = tokio::spawn(async move { 296 | let mut notification_pusher_client = NotificationPusherClient::new( 297 | &access_token, 298 | notifications_pusher_topic_sender, 299 | pusher_shutdown, 300 | ) 301 | .await; 302 | notification_pusher_client.handle_loop().await; 303 | warn!("Notification pusher exiting"); 304 | }); 305 | tokio::spawn(async move { 306 | tokio::signal::ctrl_c() 307 | .await 308 | .expect("Failed to listen to ctrl c signal"); 309 | shutdown_token.cancel(); 310 | }); 311 | 312 | info!("Listening on port 9977"); 313 | let socket_shutdown = cloned_shutdown.clone(); 314 | let cloned_user_info = user_info.clone(); 315 | 316 | let (client_exit, mut con_exit_recv) = tokio::sync::mpsc::unbounded_channel::(); 317 | let (overlay_ipc, _recv) = tokio::sync::broadcast::channel::<(u32, OverlayPeerMessage)>(32); 318 | 319 | let comet_idle_wait: u64 = match std::env::var("COMET_IDLE_WAIT") { 320 | Ok(wait) => wait.parse().unwrap_or(15), 321 | Err(_) => 15, 322 | }; 323 | let mut ever_connected = false; 324 | let mut active_clients = 0; 325 | let mut handlers = Vec::new(); 326 | loop { 327 | let (socket, _addr) = tokio::select! { 328 | accept = listener.accept() => { 329 | match accept { 330 | Ok(accept) => accept, 331 | Err(error) => { 332 | error!("Failed to accept the connection {:?}", error); 333 | continue; 334 | } 335 | } 336 | } 337 | _ = tokio::time::sleep(Duration::from_secs(comet_idle_wait)) => { 338 | if active_clients == 0 && ever_connected { 339 | socket_shutdown.cancel(); 340 | break 341 | } 342 | continue; 343 | }, 344 | _ = con_exit_recv.recv() => { active_clients -= 1; continue; } 345 | _ = socket_shutdown.cancelled() => {break} 346 | }; 347 | 348 | // Spawn handler 349 | let socket_topic_receiver = topic_sender.subscribe(); 350 | 351 | let cloned_client = reqwest_client.clone(); 352 | let cloned_token_store = token_store.clone(); 353 | let shutdown_handler = socket_shutdown.clone(); 354 | let socket_user_info = cloned_user_info.clone(); 355 | let client_exit = client_exit.clone(); 356 | let achievement_unlock_event = overlay_ipc.clone(); 357 | active_clients += 1; 358 | ever_connected = args.quit; 359 | handlers.push(tokio::spawn(async move { 360 | api::handlers::entry_point( 361 | socket, 362 | cloned_client, 363 | cloned_token_store, 364 | socket_user_info, 365 | socket_topic_receiver, 366 | achievement_unlock_event, 367 | shutdown_handler, 368 | ) 369 | .await; 370 | let _ = client_exit.send(true); 371 | })); 372 | } 373 | 374 | // Ignore errors, we are exiting 375 | let _ = pusher_handle.await; 376 | join_all(handlers).await; 377 | } 378 | -------------------------------------------------------------------------------- /src/paths.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::path::PathBuf; 3 | 4 | #[cfg(target_os = "linux")] 5 | lazy_static! { 6 | static ref DATA_PATH: PathBuf = match env::var("XDG_DATA_HOME") { 7 | Ok(path) => { 8 | PathBuf::from(path).join("comet") 9 | } 10 | Err(_) => { 11 | let home = env::var("HOME").unwrap(); 12 | PathBuf::from(home).join(".local/share/comet") 13 | } 14 | }; 15 | static ref CONFIG_PATH: PathBuf = match env::var("XDG_CONFIG_HOME") { 16 | Ok(path) => { 17 | PathBuf::from(path).join("comet") 18 | } 19 | Err(_) => { 20 | let home = env::var("HOME").unwrap(); 21 | PathBuf::from(home).join(".config/comet") 22 | } 23 | }; 24 | } 25 | 26 | #[cfg(target_os = "windows")] 27 | lazy_static! { 28 | static ref DATA_PATH: PathBuf = PathBuf::from(env::var("LOCALAPPDATA").unwrap()).join("comet"); 29 | static ref CONFIG_PATH: PathBuf = PathBuf::from(env::var("APPDATA").unwrap()).join("comet"); 30 | } 31 | 32 | #[cfg(target_os = "macos")] 33 | lazy_static! { 34 | static ref DATA_PATH: PathBuf = 35 | PathBuf::from(env::var("HOME").unwrap()).join("Library/Application Support/comet"); 36 | static ref CONFIG_PATH: PathBuf = DATA_PATH.clone(); 37 | } 38 | 39 | lazy_static! { 40 | pub static ref GAMEPLAY_STORAGE: PathBuf = DATA_PATH.join("gameplay"); 41 | pub static ref REDISTS_STORAGE: PathBuf = DATA_PATH.join("redist"); 42 | pub static ref CONFIG_FILE: PathBuf = CONFIG_PATH.join("config.toml"); 43 | } 44 | -------------------------------------------------------------------------------- /src/proto.rs: -------------------------------------------------------------------------------- 1 | // Load protobuf 2 | include!(concat!(env!("OUT_DIR"), "/proto/mod.rs")); 3 | 4 | pub mod common_utils { 5 | pub struct ProtoPayload { 6 | pub header: super::gog_protocols_pb::Header, 7 | pub payload: Vec, 8 | } 9 | } 10 | --------------------------------------------------------------------------------