├── src ├── __init__.py ├── bin │ ├── __init__.py │ ├── rpc.py │ ├── uninstall.py │ └── setup.py └── utilities │ ├── __init__.py │ ├── rpc │ ├── assets.py │ ├── __init__.py │ ├── logger.py │ ├── database.py │ └── presence.py │ └── cli │ ├── __init__.py │ ├── errors.py │ ├── output.py │ └── input.py ├── index.py ├── .gitignore ├── requirements.txt ├── assets └── logo.ico ├── screenshots ├── dark-db.png ├── dark-no-db.png ├── light-db.png └── light-no-db.png ├── config.py ├── wuthering_waves_rpc.spec ├── wuthering_waves_rpc_uninstall.spec ├── wuthering_waves_rpc_setup.spec ├── LICENSE.md └── README.md /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/bin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.py: -------------------------------------------------------------------------------- 1 | import src.bin.setup 2 | -------------------------------------------------------------------------------- /src/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__ 3 | logs 4 | dist 5 | build 6 | .vscode -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pypresence 2 | psutil 3 | pyinstaller 4 | rich 5 | pywin32 6 | -------------------------------------------------------------------------------- /assets/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xAkre/Wuthering-Waves-RPC/HEAD/assets/logo.ico -------------------------------------------------------------------------------- /screenshots/dark-db.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xAkre/Wuthering-Waves-RPC/HEAD/screenshots/dark-db.png -------------------------------------------------------------------------------- /screenshots/dark-no-db.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xAkre/Wuthering-Waves-RPC/HEAD/screenshots/dark-no-db.png -------------------------------------------------------------------------------- /screenshots/light-db.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xAkre/Wuthering-Waves-RPC/HEAD/screenshots/light-db.png -------------------------------------------------------------------------------- /src/utilities/rpc/assets.py: -------------------------------------------------------------------------------- 1 | class DiscordAssets: 2 | LARGE_IMAGE = "logo" 3 | SMALL_IMAGE = "logo" 4 | -------------------------------------------------------------------------------- /screenshots/light-no-db.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xAkre/Wuthering-Waves-RPC/HEAD/screenshots/light-no-db.png -------------------------------------------------------------------------------- /src/utilities/rpc/__init__.py: -------------------------------------------------------------------------------- 1 | from .assets import DiscordAssets 2 | from .logger import Logger 3 | from .database import ( 4 | get_database, 5 | get_game_version, 6 | get_player_region, 7 | get_player_union_level, 8 | ) 9 | from .presence import Presence 10 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from os.path import join 2 | from os import getenv 3 | 4 | 5 | class Config: 6 | MAIN_EXECUTABLE_NAME = "Wuthering Waves RPC.exe" 7 | UNINSTALL_EXECUTABLE_NAME = "Uninstall Wuthering Waves RPC.exe" 8 | APPLICATION_ID = "1243855663210303488" 9 | WUWA_PROCESS_NAME = "Wuthering Waves.exe" 10 | -------------------------------------------------------------------------------- /src/utilities/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .output import indent, print_divider 2 | from .errors import fatal_error 3 | from .input import ( 4 | get_input, 5 | get_boolean_input, 6 | get_startup_preference, 7 | get_shortcut_preference, 8 | get_database_access_preference, 9 | get_rich_presence_install_location, 10 | get_using_steam_version, 11 | get_wuwa_install_location, 12 | get_promote_preference, 13 | get_keep_running_preference, 14 | get_kuro_games_uid, 15 | ) 16 | -------------------------------------------------------------------------------- /src/utilities/cli/errors.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | from src.utilities.cli import indent, print_divider 3 | 4 | 5 | def fatal_error( 6 | console: Console, message: str, exception: Exception | None = None 7 | ) -> None: 8 | """ 9 | Display a fatal error message and exit the program 10 | 11 | :param console: The console to use for output 12 | :param message: The fatal error message to display 13 | """ 14 | print_divider(console, "[red]An Error Occurred[/red]", "red") 15 | console.print(message, style="red") 16 | 17 | if exception is not None: 18 | console.print_exception(show_locals=True) 19 | 20 | console.show_cursor(False) 21 | # For some reason using console.input() here doesn't work, so I'm using input() instead 22 | input(indent("Press Enter to exit...")) 23 | exit(1) 24 | -------------------------------------------------------------------------------- /src/bin/rpc.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os.path import exists, join, abspath, dirname, normcase, normpath 3 | from json import loads 4 | from src.utilities.rpc import Presence 5 | 6 | config_path = join(abspath(dirname(sys.executable)), "config/config.json") 7 | 8 | if not exists(config_path): 9 | raise Exception(f"Config file does not exist, {config_path}") 10 | 11 | with open(config_path, "r") as f: 12 | config = loads(f.read()) 13 | if normpath(normcase(config["rich_presence_install_location"])) != normpath( 14 | normcase(abspath(dirname(sys.executable))) 15 | ): 16 | raise Exception( 17 | "The rich presence install location in the config file does not match the actual install location. Please update the config file, or setup the RPC again" 18 | ) 19 | 20 | presence = Presence(config) 21 | presence.start() 22 | -------------------------------------------------------------------------------- /wuthering_waves_rpc.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | rpc = Analysis( 5 | ['src/bin/rpc.py'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[], 9 | hiddenimports=[], 10 | hookspath=[], 11 | hooksconfig={}, 12 | runtime_hooks=[], 13 | excludes=[], 14 | noarchive=False, 15 | optimize=0, 16 | ) 17 | 18 | rpc_pyz = PYZ(rpc.pure) 19 | 20 | rpc_exe = EXE( 21 | rpc_pyz, 22 | rpc.scripts, 23 | rpc.binaries, 24 | rpc.datas, 25 | [], 26 | uac_admin=True, 27 | name='Wuthering Waves RPC', 28 | debug=False, 29 | bootloader_ignore_signals=False, 30 | strip=False, 31 | upx=True, 32 | upx_exclude=[], 33 | runtime_tmpdir=None, 34 | console=False, 35 | disable_windowed_traceback=False, 36 | argv_emulation=False, 37 | target_arch=None, 38 | codesign_identity=None, 39 | entitlements_file=None, 40 | icon=['assets\\logo.ico'], 41 | ) 42 | 43 | -------------------------------------------------------------------------------- /wuthering_waves_rpc_uninstall.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | uninstall = Analysis( 5 | ['src/bin/uninstall.py'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[], 9 | hiddenimports=[], 10 | hookspath=[], 11 | hooksconfig={}, 12 | runtime_hooks=[], 13 | excludes=[], 14 | noarchive=False, 15 | optimize=0, 16 | ) 17 | 18 | uninstall_pyz = PYZ(uninstall.pure) 19 | 20 | uninstall_exe = EXE( 21 | uninstall_pyz, 22 | uninstall.scripts, 23 | uninstall.binaries, 24 | uninstall.datas, 25 | [], 26 | uac_admin=True, 27 | name='Uninstall Wuthering Waves RPC', 28 | debug=False, 29 | bootloader_ignore_signals=False, 30 | strip=False, 31 | upx=True, 32 | upx_exclude=[], 33 | runtime_tmpdir=None, 34 | console=True, 35 | disable_windowed_traceback=False, 36 | argv_emulation=False, 37 | target_arch=None, 38 | codesign_identity=None, 39 | entitlements_file=None, 40 | icon=['assets\\logo.ico'], 41 | ) 42 | 43 | -------------------------------------------------------------------------------- /wuthering_waves_rpc_setup.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | setup = Analysis( 5 | ['index.py'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[('dist/Wuthering Waves RPC.exe', '.'), ('dist/Uninstall Wuthering Waves RPC.exe', '.'), ('assets/logo.ico', '.')], 9 | hiddenimports=[], 10 | hookspath=[], 11 | hooksconfig={}, 12 | runtime_hooks=[], 13 | excludes=[], 14 | noarchive=False, 15 | optimize=0, 16 | ) 17 | 18 | setup_pyz = PYZ(setup.pure) 19 | 20 | setup_exe = EXE( 21 | setup_pyz, 22 | setup.scripts, 23 | setup.binaries, 24 | setup.datas, 25 | [], 26 | uac_admin=True, 27 | name='Wuthering Waves RPC Setup', 28 | debug=False, 29 | bootloader_ignore_signals=False, 30 | strip=False, 31 | upx=True, 32 | upx_exclude=[], 33 | runtime_tmpdir=None, 34 | console=True, 35 | disable_windowed_traceback=False, 36 | argv_emulation=False, 37 | target_arch=None, 38 | codesign_identity=None, 39 | entitlements_file=None, 40 | icon=['assets\\logo.ico'], 41 | ) -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dawid Szymaniak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/utilities/cli/output.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | 3 | 4 | # The cli doesn't really look nice when everything is flush against the left side of the terminal, 5 | # so I added this function to indent the text 6 | def indent(*args: str, spaces: int = 4) -> str: 7 | """ 8 | Indent the provided strings by the specified number of spaces 9 | 10 | :param args: The strings to indent 11 | :param spaces: The number of spaces to indent the strings by 12 | :return: One string with all the provided strings indented 13 | """ 14 | output = "" 15 | 16 | for i, arg in enumerate(args): 17 | output += f"{(' ' * spaces)}{arg}" 18 | 19 | if i != len(args) - 1: 20 | output += "\n" 21 | 22 | return output 23 | 24 | 25 | def print_divider(console: Console, text: str, style: str) -> None: 26 | """ 27 | Print a section divider 28 | 29 | :param console: The console to use for output 30 | :param content: The text inside the divider 31 | :param style: The style of the divider 32 | """ 33 | console.print("\n") 34 | console.rule(text, style=style) 35 | console.print("\n") 36 | -------------------------------------------------------------------------------- /src/utilities/rpc/logger.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os import makedirs 3 | from os.path import join, dirname, abspath 4 | from datetime import datetime 5 | 6 | 7 | class Logger: 8 | """ 9 | Handles application logging 10 | """ 11 | 12 | log_file_path: str 13 | 14 | def __init__( 15 | self, 16 | log_folder: str = join(abspath(dirname(sys.executable)), "logs"), 17 | ): 18 | """ 19 | Create a new logger instance 20 | 21 | :param log_folder: The path to the log folder 22 | """ 23 | makedirs(log_folder, exist_ok=True) 24 | self.log_folder = log_folder 25 | self.log_file_path = join(log_folder, "log.txt") 26 | 27 | def error(self, message: str): 28 | """ 29 | Log an error message 30 | """ 31 | self.write("ERROR", message) 32 | print(f"ERROR: {message}") 33 | 34 | def warning(self, message: str): 35 | """ 36 | Log a warning message 37 | """ 38 | self.write("WARNING", message) 39 | print(f"WARNING: {message}") 40 | 41 | def info(self, message: str): 42 | """ 43 | Log an info message 44 | """ 45 | self.write("INFO", message) 46 | print(f"INFO: {message}") 47 | 48 | def write(self, type: str, message: str): 49 | """ 50 | Write a message to the log file 51 | """ 52 | with open(self.log_file_path, "a") as log_file: 53 | log_file.write(f"[{type}] [{datetime.now()}] {message}\n") 54 | 55 | def clear(self): 56 | """ 57 | Clear the log file of all content 58 | """ 59 | open(self.log_file_path, "w").close() 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License](https://img.shields.io/github/license/xAkre/Wuthering-Waves-RPC?style=for-the-badge)](https://github.com/xAkre/Wuthering-Waves-RPC/blob/master/LICENSE.md) 2 | [![Downloads](https://img.shields.io/github/downloads/xAkre/Wuthering-Waves-RPC/total?style=for-the-badge)](https://github.com/xAkre/Wuthering-Waves-RPC/releases) 3 | [![PyPresence](https://img.shields.io/badge/using-pypresence-00bb88.svg?style=for-the-badge&logo=discord&logoWidth=20)](https://github.com/qwertyquerty/pypresence) 4 | ![Language](https://img.shields.io/github/languages/top/xAkre/Wuthering-Waves-RPC?style=for-the-badge) 5 | 6 | # Wuthering Waves Discord Rich Presence 7 | 8 | Enables Discord Rich Presence for Wuthering Waves 9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 |
19 | 20 | ## Table of Contents 21 | 22 |
    23 |
  1. Features
  2. 24 |
  3. Installing
  4. 25 |
  5. Usage
  6. 26 |
  7. Building from source
  8. 27 |
  9. Issues
  10. 28 |
  11. Warning
  12. 29 |
30 | 31 | ## Features 32 | 33 | - RPC variants 34 | - Database access variant 35 | - This variant accesses the game's local database to retrieve information about the user's union level and region. You should note, however, that this could violate the game's terms of service, potentially leading to account suspension or banning 36 | - Non-Database access variant 37 | - This variant does not access the game's local database, eliminating the risk of violating the game's terms of service 38 | - Automatic launch on startup 39 | - Allows the RPC application to start automatically when the user logs in, removing the need to manually start the application 40 | 41 | ## Installing 42 | 43 | 1. Download the [latest release]( 44 | https://github.com/xAkre/Wuthering-Waves-RPC/releases/latest 45 | ) 46 | 2. Run the setup executable 47 | 3. Go through the setup process 48 | 4. You're done! 49 | 50 | You may delete the setup executable after installation 51 | 52 | ## Usage 53 | 54 | 1. Simply run the RPC application like any other program 55 | 56 | ## Building from source 57 | 58 | 1. Clone the repository 59 | 2. Run `pip install -r requirements.txt` 60 | 3. Run `build.bat` 61 | 4. The executable will be located in the `dist/` directory 62 | 63 | # Issues 64 | 65 | If you encounter any issues, please open an issue on the [issues page](https://github.com/xAkre/Wuthering-Waves-RPC/issues) 66 | 67 | ## Warning 68 | 69 | This is a third-party application and is not affiliated with Wuthering Waves or its developers. Should you choose to use the RPC with the database access option, you do so at your own risk 70 | 71 | -------------------------------------------------------------------------------- /src/utilities/rpc/database.py: -------------------------------------------------------------------------------- 1 | from sqlite3 import Connection, connect 2 | from json import loads 3 | from src.utilities.rpc import Logger 4 | 5 | 6 | def get_database(path: str) -> Connection: 7 | """ 8 | Get a connection to the local Wuthering Waves database 9 | """ 10 | logger = Logger() 11 | 12 | try: 13 | return connect(path) 14 | except Exception as e: 15 | logger.error(f"An error occurred while connecting to the local database: {e}") 16 | 17 | 18 | def get_player_region(connection: Connection, kuro_games_uid: str) -> str: 19 | """ 20 | Get the player's region from the local database 21 | 22 | :param connection: The connection to the local database 23 | :param kuro_games_uid: The player's Kuro Games UID 24 | :return: The player's region as a string or "Unknown" if an error occurred 25 | """ 26 | logger = Logger() 27 | 28 | try: 29 | sdk_game_data = _get_sdk_level_data(connection, kuro_games_uid) 30 | return sdk_game_data.get("Region") if sdk_game_data.get("Region") else "Unknown" 31 | except Exception as e: 32 | logger.error(f"An error occurred while fetching the user's region: {e}") 33 | return "Unknown" 34 | 35 | 36 | def get_player_union_level(connection: Connection, kuro_games_uid: str) -> str: 37 | """ 38 | Get the player's union level from the local database 39 | 40 | :param connection: The connection to the local database 41 | :param kuro_games_uid: The player's Kuro Games UID 42 | :return: The player's union level as a string or "Unknown" if an error occurred 43 | """ 44 | logger = Logger() 45 | 46 | try: 47 | sdk_game_data = _get_sdk_level_data(connection, kuro_games_uid) 48 | return sdk_game_data.get("Level") if sdk_game_data.get("Level") else "Unknown" 49 | except Exception as e: 50 | logger.error(f"An error occurred while fetching the user's union level: {e}") 51 | return "Unknown" 52 | 53 | 54 | def get_game_version(connection: Connection) -> str: 55 | """ 56 | Get the game version from the local database 57 | 58 | :param connection: The connection to the local database 59 | :return: The game version as a string or "Unknown" if an error occurred 60 | """ 61 | logger = Logger() 62 | 63 | try: 64 | cursor = connection.cursor() 65 | result = cursor.execute( 66 | "SELECT * FROM LocalStorage WHERE key = ?", ("PatchVersion",) 67 | ).fetchone() 68 | version = result[1] 69 | return version if version else "Unknown" 70 | except Exception as e: 71 | logger.error(f"An error occurred while fetching the game version: {e}") 72 | return "Unknown" 73 | 74 | 75 | def _get_sdk_level_data(connection: Connection, kuro_games_uid: str) -> dict: 76 | """ 77 | Get the player's sdk level data from the local database. The level data is 78 | stored as follows: 79 | 80 | { 81 | "___MetaType___":"___Map___", 82 | "Content": [ 83 | ["535414272", [ 84 | { 85 | "Region": "Europe", 86 | "Level": 4 87 | } 88 | ]], 89 | ["536678859", [ 90 | { 91 | "Region": "Europe", 92 | "Level": 3 93 | } 94 | ]], 95 | ["536789175", [ 96 | { 97 | "Region": "Europe", 98 | "Level": 22 99 | }] 100 | ] 101 | ] 102 | } 103 | 104 | Where the first value in the array is the player's Kuro Games UID and the second 105 | is the player's level data 106 | 107 | :param connection: The connection to the local database 108 | :param kuro_games_uid: The player's Kuro Games UID 109 | :return: The player's sdk level data or None if an error occurred or the Kuro Games UID is not found 110 | """ 111 | logger = Logger() 112 | 113 | try: 114 | cursor = connection.cursor() 115 | result = cursor.execute( 116 | "SELECT * FROM LocalStorage WHERE key = ?", ("SdkLevelData",) 117 | ).fetchone() 118 | value = loads(result[1]) 119 | content = value.get("Content") 120 | 121 | for entry in content: 122 | if entry[0] == kuro_games_uid: 123 | data = entry[1][0] 124 | return data 125 | 126 | return None 127 | except Exception as e: 128 | logger.error(f"An error occurred while fetching the user's level data: {e}") 129 | return None 130 | -------------------------------------------------------------------------------- /src/utilities/rpc/presence.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from sqlite3 import Connection 4 | from time import sleep, time 5 | 6 | from psutil import NoSuchProcess, Process, pids 7 | from pypresence import Presence as PyPresence 8 | 9 | from config import Config 10 | from src.utilities.rpc import ( 11 | DiscordAssets, 12 | Logger, 13 | get_database, 14 | get_game_version, 15 | get_player_region, 16 | get_player_union_level, 17 | ) 18 | 19 | 20 | class Presence: 21 | logger: Logger 22 | local_database: Connection | None 23 | """ 24 | Local Wuthering Waves database connection. The database is a sqlite database and 25 | is stored inside the Wuthering Waves game folder at 26 | "{Game Folder}/Client/Saved/LocalStorage" if using the steam version else 27 | "{Game Folder}/Wuthering Waves Game/Client/Saved/LocalStorage" 28 | """ 29 | presence: PyPresence 30 | 31 | def __init__(self, config: dict) -> None: 32 | self.config = config 33 | self.logger = Logger() 34 | 35 | self.database_directory = os.path.join( 36 | self.config["wuwa_install_location"], 37 | "Client/Saved/LocalStorage" 38 | if self.config["using_steam_version"] 39 | else "Wuthering Waves Game/Client/Saved/LocalStorage", 40 | ) 41 | 42 | # If the user wants to access the database, get the database connection 43 | if self.config["database_access_preference"]: 44 | local_storage = self.get_lastest_database_file(self.database_directory) 45 | self.logger.info(f"Found last modified LocalStorage file: {local_storage}") 46 | if local_storage: 47 | database_path = os.path.join(self.database_directory, local_storage) 48 | self.local_database = get_database(database_path) 49 | else: 50 | self.local_database = None 51 | else: 52 | self.local_database = None 53 | 54 | self.presence = PyPresence(Config.APPLICATION_ID) 55 | 56 | def get_lastest_database_file(self, directory: str): 57 | """ 58 | Returns the name of the lastest database file with a '.db' extension 59 | in the specified directory. 60 | 61 | :param directory: The directory to search for the lastest file 62 | :return: The name of the lastest file, or None if no matching file is found 63 | """ 64 | pattern = re.compile(r".*\.db$") 65 | highest_union_level = -1 66 | latest_file = None 67 | 68 | self.logger.info(f"Looking for the lastest LocalStorage file in {directory}") 69 | for file in os.listdir(directory): 70 | if latest_file is None: 71 | latest_file = file 72 | 73 | if pattern.match(file): 74 | self.logger.info(f"Found LocalStorage file: {file}") 75 | 76 | connection = get_database(os.path.join(directory, file)) 77 | union_level = get_player_union_level( 78 | connection, self.config["kuro_games_uid"] 79 | ) 80 | 81 | if union_level == "Unknown": 82 | continue 83 | 84 | if int(union_level) > highest_union_level: 85 | highest_union_level = int(union_level) 86 | latest_file = file 87 | 88 | connection.close() 89 | 90 | return latest_file 91 | 92 | def start(self) -> None: 93 | """ 94 | Start the RPC 95 | """ 96 | try: 97 | self.logger.clear() 98 | 99 | while True: 100 | try: 101 | self.presence.connect() 102 | break 103 | except Exception as e: 104 | self.logger.info( 105 | f"Discord could not be found installed and running on this machine" 106 | ) 107 | sleep(15) 108 | 109 | while not self.wuwa_process_exists(): 110 | self.logger.info("Wuthering Waves is not running, waiting...") 111 | sleep(15) 112 | 113 | self.logger.info("Wuthering Waves and Discord are running, starting RPC...") 114 | self.start_time = time() 115 | self.presence.update(start=self.start_time) 116 | self.rpc_loop() 117 | except Exception as e: 118 | self.logger.error(f"An uncaught error occured: {e}") 119 | 120 | def rpc_loop(self) -> None: 121 | """ 122 | Loop to keep the RPC running 123 | """ 124 | # Loop while Wuthering Waves process is running 125 | while self.wuwa_process_exists(): 126 | self.update() 127 | sleep(15) 128 | 129 | if self.config["keep_running_preference"]: 130 | self.presence.close() 131 | while not self.wuwa_process_exists(): 132 | self.logger.info( 133 | "Wuthering waves has closed, waiting for it to start again..." 134 | ) 135 | sleep(30) 136 | self.start() 137 | 138 | self.logger.info("Wuthering Waves has closed, closing RPC...") 139 | self.presence.close() 140 | 141 | def update(self) -> None: 142 | """ 143 | Update RPC presence 144 | """ 145 | self.logger.info("Updating RPC presence...") 146 | 147 | # Add a button to the RPC to promote the Rich Presence if the user wants to 148 | buttons = ( 149 | [ 150 | { 151 | "label": "Want a status like this?", 152 | "url": "https://github.com/xAkre/Wuthering-Waves-RPC", 153 | } 154 | ] 155 | if self.config["promote_preference"] 156 | else None 157 | ) 158 | 159 | # Update the RPC with only basic information if the user doesn't want to access the database 160 | if self.local_database is None: 161 | self.presence.update( 162 | start=self.start_time, 163 | details="Exploring SOL-III", 164 | large_image=DiscordAssets.LARGE_IMAGE, 165 | large_text="Wuthering Waves", 166 | buttons=buttons, 167 | ) 168 | return 169 | 170 | try: 171 | # Check for the lastest database file 172 | local_storage = self.get_lastest_database_file(self.database_directory) 173 | self.logger.info(f"Found last modified LocalStorage file: {local_storage}") 174 | 175 | if local_storage: 176 | database_path = os.path.join(self.database_directory, local_storage) 177 | try: 178 | self.local_database = get_database(database_path) 179 | except self.local_database.Error as e: 180 | self.logger.error(f"Failed to connect to database: {e}") 181 | self.local_database = None 182 | 183 | region = get_player_region( 184 | self.local_database, self.config["kuro_games_uid"] 185 | ) 186 | union_level = get_player_union_level( 187 | self.local_database, self.config["kuro_games_uid"] 188 | ) 189 | game_version = get_game_version(self.local_database) 190 | except self.local_database.Error as e: 191 | self.logger.error(f"Failed to retrieve game data: {e}") 192 | region = "Unknown" 193 | union_level = "Unknown" 194 | game_version = "Unknown" 195 | 196 | self.presence.update( 197 | start=self.start_time, 198 | details=f"Union Level {union_level}", 199 | state=f"Region: {region}", 200 | large_image=DiscordAssets.LARGE_IMAGE, 201 | large_text="Wuthering Waves", 202 | small_image=DiscordAssets.SMALL_IMAGE, 203 | # For some reason quotes are automatically added around the game version, and i don't want that 204 | small_text=f"Version: {game_version}".replace('"', ""), 205 | buttons=buttons, 206 | ) 207 | 208 | def wuwa_process_exists(self) -> bool: 209 | """ 210 | Check whether the Wuthering Waves process is running 211 | 212 | :return: True if the process is running, False otherwise 213 | """ 214 | for pid in pids(): 215 | try: 216 | if Process(pid).name() == Config.WUWA_PROCESS_NAME: 217 | return True 218 | except NoSuchProcess: 219 | pass 220 | 221 | return False 222 | -------------------------------------------------------------------------------- /src/bin/uninstall.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | import os 4 | import tempfile 5 | from shutil import rmtree 6 | from win32com.client import Dispatch 7 | from os.path import exists, join, abspath, dirname, normcase, normpath, expanduser 8 | from json import loads 9 | from rich.console import Console 10 | from src.utilities.cli import fatal_error, indent, print_divider 11 | from config import Config 12 | 13 | console = Console() 14 | 15 | config_path = join(abspath(dirname(sys.executable)), "config/config.json") 16 | 17 | if not exists(config_path): 18 | fatal_error( 19 | console, 20 | indent(f"Config file does not exist. It should be located at {config_path}"), 21 | ) 22 | 23 | with open(config_path, "r") as f: 24 | config = loads(f.read()) 25 | if normpath(normcase(config["rich_presence_install_location"])) != normpath( 26 | normcase(abspath(dirname(sys.executable))) 27 | ): 28 | fatal_error( 29 | console, 30 | indent( 31 | "The rich presence install location in the config file does not match the actual install location. Please update the config file, or setup the RPC again" 32 | ), 33 | ) 34 | 35 | 36 | def remove_startup_task(console: Console): 37 | """ 38 | Remove the startup task that was created during installation 39 | 40 | :param console: The console to use for input and output 41 | """ 42 | try: 43 | with console.status(indent("Removing the startup task..."), spinner="dots"): 44 | delete_task_command = [ 45 | "schtasks", 46 | "/delete", 47 | "/tn", 48 | "Wuthering Waves RPC", 49 | "/f", 50 | ] 51 | subprocess.run( 52 | delete_task_command, 53 | check=True, 54 | stdout=subprocess.DEVNULL, 55 | stderr=subprocess.DEVNULL, 56 | ) 57 | console.print(indent("Startup task removed."), style="green") 58 | except: 59 | console.print( 60 | indent( 61 | "Failed to remove the startup task.", 62 | "Please remove it manually via the Task Manager application", 63 | ), 64 | style="red", 65 | ) 66 | 67 | 68 | def get_shortcut_target_path(shortcut_path: str) -> str: 69 | """ 70 | Get the target path of a Windows shortcut. 71 | 72 | :param shortcut_path: The path to the shortcut (.lnk) file. 73 | :return: The target path that the shortcut points to. 74 | """ 75 | shell = Dispatch("WScript.Shell") 76 | shortcut = shell.CreateShortcut(shortcut_path) 77 | return shortcut.TargetPath 78 | 79 | 80 | def find_shortcuts_pointing_to_exe(exe_path: str) -> list[str]: 81 | """ 82 | Find all shortcuts that point to a specified executable. 83 | This function searches the user's desktop and the Start Menu. 84 | 85 | :param executable_path: The path to the executable 86 | :return: A list of shortcut paths that point to the specified executable 87 | """ 88 | paths_to_search = [ 89 | join( 90 | os.getenv("APPDATA"), 91 | "Microsoft/Windows/Start Menu/Programs", 92 | ), 93 | expanduser("~/Desktop"), 94 | ] 95 | 96 | shortcuts_pointing_to_exe = [] 97 | 98 | for path in paths_to_search: 99 | for root, _, files in os.walk(path): 100 | for file in files: 101 | if file.endswith(".lnk"): 102 | shortcut_path = join(root, file) 103 | try: 104 | if get_shortcut_target_path(shortcut_path) == exe_path: 105 | shortcuts_pointing_to_exe.append(shortcut_path) 106 | except Exception as e: 107 | console.print( 108 | indent(f"Error reading shortcut {shortcut_path}: {e}"), 109 | style="yellow", 110 | ) 111 | 112 | return shortcuts_pointing_to_exe 113 | 114 | 115 | def remove_shortcuts(console: Console, exe_path: str): 116 | """ 117 | Remove shortcuts that point to a specified executable 118 | 119 | :param console: The console to use for input and output 120 | :param exe_path: The path to the executable 121 | """ 122 | shortcuts = find_shortcuts_pointing_to_exe(exe_path) 123 | if not shortcuts: 124 | console.print( 125 | indent("No shortcuts pointing to the executable were found."), 126 | style="yellow", 127 | ) 128 | return 129 | 130 | console.print( 131 | indent(f"Found {len(shortcuts)} shortcuts pointing to the executable:"), 132 | style="yellow", 133 | ) 134 | for shortcut in shortcuts: 135 | console.print(indent(shortcut), style="yellow") 136 | 137 | for shortcut in shortcuts: 138 | try: 139 | os.remove(shortcut) 140 | console.print(indent(f"Removed shortcut {shortcut}"), style="green") 141 | except Exception as e: 142 | console.print( 143 | indent(f"Failed to remove shortcut {shortcut}: {e}"), style="red" 144 | ) 145 | 146 | 147 | def delete_program_folder(console: Console): 148 | """ 149 | Delete the program folder 150 | 151 | :param console: The console to use for input and output 152 | """ 153 | try: 154 | for root, _, files in os.walk(abspath(dirname(sys.executable))): 155 | for file in files: 156 | if normcase(normpath(join(root, file))) == normcase( 157 | normpath(sys.executable) 158 | ): 159 | continue 160 | with console.status(indent(f"Removing {file}..."), spinner="dots"): 161 | os.remove(join(root, file)) 162 | console.print(indent(f"File {file} removed"), style="green") 163 | 164 | uninstall_exe_path = abspath(sys.executable) 165 | 166 | console.input(indent("Press Enter to finalize the uninstallation")) 167 | 168 | batch_script = f""" 169 | @echo off 170 | timeout 2 171 | rmdir /s /q "{dirname(uninstall_exe_path)}" 172 | if errorlevel 1 ( 173 | echo "Failed to remove the program folder ({dirname(uninstall_exe_path)}). Please remove it manually. You may now close this window" 174 | ) else ( 175 | echo "{dirname(uninstall_exe_path)} was removed. Uninstallation complete. You may now close this window" 176 | ) 177 | """ 178 | 179 | with tempfile.NamedTemporaryFile( 180 | delete=False, suffix=".bat", mode="w" 181 | ) as temp_script: 182 | temp_script.write(batch_script) 183 | temp_script_path = temp_script.name 184 | 185 | subprocess.Popen(["cmd", "/k", "start", "/wait", temp_script_path]) 186 | sys.exit(0) 187 | except Exception as e: 188 | fatal_error( 189 | console, 190 | indent( 191 | "Failed to remove the program folder", 192 | ), 193 | e, 194 | ) 195 | 196 | 197 | print_divider(console, "Wuthering Waves Rich Presence Uninstaller", "white") 198 | console.input(indent("Press Enter to uninstall the Wuthering Waves Rich Presence...")) 199 | 200 | try: 201 | if config["startup_preference"]: 202 | print_divider( 203 | console, 204 | "[green]Removing Wuthering Waves RPC from Windows Task Scheduler[/green]", 205 | "green", 206 | ) 207 | remove_startup_task(console) 208 | 209 | print_divider( 210 | console, 211 | "[green]Removing shortcuts pointing to the main executable[/green]", 212 | "green", 213 | ) 214 | console.print( 215 | indent( 216 | "Note that this only searches for shortcuts on your Desktop and in the Start Menu.", 217 | "If you have shortcuts in other locations, you will need to remove them manually", 218 | ), 219 | style="yellow", 220 | ) 221 | exe_path = join(abspath(dirname(sys.executable)), Config.MAIN_EXECUTABLE_NAME) 222 | remove_shortcuts(console, exe_path) 223 | print_divider( 224 | console, 225 | "[green]Removing shortcuts pointing to the uninstaller[/green]", 226 | "green", 227 | ) 228 | uninstall_exe_path = abspath(sys.executable) 229 | remove_shortcuts(console, uninstall_exe_path) 230 | print_divider(console, "[green]Removing the program folder[/green]", "green") 231 | delete_program_folder(console) 232 | print_divider(console, "[green]Uninstallation complete[/green]", "green") 233 | input(indent("Press Enter to exit...")) 234 | except Exception as e: 235 | fatal_error( 236 | console, 237 | indent( 238 | "An error occurred during uninstallation", 239 | ), 240 | e, 241 | ) 242 | -------------------------------------------------------------------------------- /src/utilities/cli/input.py: -------------------------------------------------------------------------------- 1 | import re 2 | from os import path, listdir, makedirs 3 | from shutil import rmtree 4 | from rich.console import Console 5 | from src.utilities.cli import indent, print_divider, fatal_error 6 | 7 | 8 | def get_boolean_input(console: Console, prompt: str) -> bool: 9 | """ 10 | Get a boolean input from the user 11 | 12 | :param console: The console to use for input and output 13 | :param prompt: The prompt to display to the user 14 | :return: The boolean input from the user 15 | """ 16 | while True: 17 | user_input = console.input(prompt).strip().lower() 18 | 19 | if user_input in ["y", "yes"]: 20 | return True 21 | elif user_input in ["n", "no"]: 22 | return False 23 | else: 24 | console.print( 25 | indent("\n", "Please enter 'Y' for yes or 'N' for no.", "\n"), 26 | style="red", 27 | ) 28 | 29 | 30 | def get_using_steam_version(console: Console) -> bool: 31 | """ 32 | Prompt the user as to whether they use the steam version of the game. 33 | 34 | :param console: The console to use for input and output 35 | :return: Whether the user is using the steam version of the game 36 | """ 37 | 38 | return get_boolean_input( 39 | console, 40 | indent("Are you using the steam version of the game (Y/N): "), 41 | ) 42 | 43 | 44 | def get_wuwa_install_location(console: Console, default_location: str) -> str: 45 | """ 46 | Get the Wuthering Waves install location from the user 47 | 48 | :param console: The console to use for input and output 49 | :param default_location: The default install location 50 | :return: The Wuthering Waves install location 51 | """ 52 | while True: 53 | wuwa_install_location = console.input( 54 | indent( 55 | f"Where is Wuthering Waves installed?", 56 | f'Leave blank for the default location ("{default_location}"): ', 57 | ) 58 | ).strip() 59 | 60 | if wuwa_install_location == "": 61 | return default_location 62 | 63 | if path.exists(wuwa_install_location): 64 | if not path.isdir(wuwa_install_location): 65 | console.print( 66 | indent( 67 | "That path is not a folder. Please enter a valid folder.", 68 | ), 69 | style="red", 70 | ) 71 | continue 72 | 73 | return wuwa_install_location 74 | 75 | console.print( 76 | indent( 77 | "That path does not exist. Please enter a valid path.", 78 | ), 79 | style="red", 80 | ) 81 | 82 | 83 | def get_database_access_preference(console: Console) -> bool: 84 | """ 85 | Get the user's preference for accessing the game's local database 86 | 87 | :param console: The console to use for input and output 88 | :return: The user's preference for accessing the game's local database 89 | """ 90 | return get_boolean_input( 91 | console, 92 | indent( 93 | "Would you like the rich presence to get data from the game's local database?", 94 | "Without this selected, union level and region data will not be displayed.", 95 | "[red]If you choose yes, I am not responsible for any issues and/or bans that may arise.[/red] (Y/N): ", 96 | ), 97 | ) 98 | 99 | 100 | def get_rich_presence_install_location(console: Console, default_location: str) -> str: 101 | """ 102 | Get the rich presence install location from the user 103 | 104 | :param console: The console to use for input and output 105 | :param default_location: The default install location 106 | :return: The rich presence install location 107 | """ 108 | while True: 109 | rich_presence_install_location = console.input( 110 | indent( 111 | "Where would you like the rich presence to be installed?", 112 | f'Leave blank for the default location ("{default_location}"): ', 113 | ) 114 | ).strip() 115 | 116 | if rich_presence_install_location == "": 117 | rich_presence_install_location = default_location 118 | 119 | if path.exists(rich_presence_install_location): 120 | if not path.isdir(rich_presence_install_location): 121 | console.print( 122 | indent( 123 | "That path is a file. Please enter a valid folder.", 124 | ), 125 | style="red", 126 | ) 127 | continue 128 | 129 | if len(listdir(rich_presence_install_location)) == 0: 130 | return rich_presence_install_location 131 | 132 | if get_boolean_input( 133 | console, 134 | indent( 135 | "That folder is not empty. Would you like to clear it? (Y/N): ", 136 | ), 137 | ): 138 | try: 139 | with console.status( 140 | indent("Clearing the folder..."), spinner="dots" 141 | ): 142 | rmtree(rich_presence_install_location) 143 | makedirs(rich_presence_install_location) 144 | console.print(indent("Folder cleared."), style="green") 145 | 146 | return rich_presence_install_location 147 | except Exception as e: 148 | fatal_error( 149 | console, 150 | indent( 151 | f"An error occurred while clearing the folder:", 152 | ), 153 | e, 154 | ) 155 | else: 156 | if rich_presence_install_location == default_location: 157 | with console.status(indent("Creating the folder..."), spinner="dots"): 158 | makedirs(rich_presence_install_location) 159 | console.print(indent("Folder created."), style="green") 160 | 161 | return rich_presence_install_location 162 | 163 | while True: 164 | if get_boolean_input( 165 | console, 166 | indent( 167 | "That folder does not exist. Would you like to create it? (Y/N): ", 168 | ), 169 | ): 170 | try: 171 | with console.status( 172 | indent("Creating the folder..."), spinner="dots" 173 | ): 174 | makedirs(rich_presence_install_location) 175 | console.print(indent("Folder created."), style="green") 176 | 177 | return rich_presence_install_location 178 | except Exception as e: 179 | fatal_error( 180 | console, 181 | indent( 182 | f"An error occurred while creating the folder", 183 | ), 184 | e, 185 | ) 186 | else: 187 | break 188 | 189 | 190 | def get_startup_preference(console: Console) -> bool: 191 | """ 192 | Get the user's preference for starting the rich presence on system startup 193 | 194 | :param console: The console to use for input and output 195 | :return: The user's preference for starting the rich presence on system startup 196 | """ 197 | return get_boolean_input( 198 | console, 199 | indent( 200 | "Would you like to start the rich presence on system startup? (Y/N): ", 201 | ), 202 | ) 203 | 204 | 205 | def get_shortcut_preference(console: Console) -> bool: 206 | """ 207 | Get the user's preference for creating a desktop shortcut 208 | 209 | :param console: The console to use for input and output 210 | :return: The user's preference for creating a desktop shortcut 211 | """ 212 | return get_boolean_input( 213 | console, 214 | indent( 215 | "Would you like to create a desktop shortcut for the rich presence? (Y/N): ", 216 | ), 217 | ) 218 | 219 | 220 | def get_promote_preference(console: Console) -> bool: 221 | """ 222 | Get the user's preference for promoting the rich presence on Discord 223 | 224 | :param console: The console to use for input and output 225 | :return: The user's preference for promoting the rich presence on Discord 226 | """ 227 | return get_boolean_input( 228 | console, 229 | indent( 230 | "Would you like to help promote the rich presence on Discord?", 231 | "This will add a button to the rich presence that links to the GitHub repository. (Y/N): ", 232 | ), 233 | ) 234 | 235 | 236 | def get_keep_running_preference(console: Console) -> bool: 237 | """ 238 | Get the user's preference for keeping the rich presence running after Wuthering Waves is closed 239 | 240 | :param console: The console to use for input and output 241 | :return: The user's preference for keeping the rich presence running in the background 242 | """ 243 | return get_boolean_input( 244 | console, 245 | indent( 246 | "Would you like to keep the rich presence running in the background?", 247 | "This will keep the rich presence running after Wuthering Waves is closed,", 248 | "and it will wait for the next launch (Y/N): ", 249 | ), 250 | ) 251 | 252 | 253 | def get_kuro_games_uid(console: Console) -> str: 254 | """ 255 | Get the Kuro Games UID the user wants to check for 256 | 257 | :param console: The console to use for input and output 258 | :return: The Kuro Games UID the user wants to check for 259 | """ 260 | while True: 261 | kuro_games_uid_regex = re.compile(r"^\d+$") 262 | user_input = console.input( 263 | indent( 264 | "Please enter the Kuro Games UID you would like the RPC to check for.", 265 | "Note that this is not the UID you see in the bottom right corner of the game.", 266 | "To get this UID, go to the game's settings and click on the 'Account' tab,", 267 | "then go into the 'User Center' section, and you will see the UID there: ", 268 | ) 269 | ).strip() 270 | 271 | if user_input and kuro_games_uid_regex.match(user_input): 272 | return user_input 273 | else: 274 | console.print( 275 | indent("\n", "The Kuro Games UID must only contain numbers", "\n"), 276 | style="red", 277 | ) 278 | 279 | 280 | def get_input(console, divider_text, callback) -> any: 281 | """ 282 | Get input from the user using the provided callback 283 | 284 | :param console: The console to use for displaying the divider 285 | :param divider_text: The text to display in the divider 286 | :param callback: The callback to call to get the input 287 | """ 288 | print_divider(console, divider_text, "white") 289 | return callback() 290 | -------------------------------------------------------------------------------- /src/bin/setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | from win32com.client import Dispatch 4 | from os import getenv, path, makedirs 5 | from shutil import copyfile 6 | from json import dumps 7 | from rich.console import Console 8 | from config import Config 9 | from src.utilities.cli import ( 10 | indent, 11 | print_divider, 12 | fatal_error, 13 | get_database_access_preference, 14 | get_input, 15 | get_rich_presence_install_location, 16 | get_shortcut_preference, 17 | get_wuwa_install_location, 18 | get_startup_preference, 19 | get_using_steam_version, 20 | get_promote_preference, 21 | get_keep_running_preference, 22 | get_kuro_games_uid, 23 | ) 24 | 25 | console = Console() 26 | 27 | DEFAULT_WUWA_INSTALL_LOCATION = r"C:\Wuthering Waves" 28 | DEFAULT_RICH_PRESENCE_INSTALL_LOCATION = ( 29 | rf"{getenv('LOCALAPPDATA')}\Wuthering Waves RPC" 30 | ) 31 | LARGE_DIVIDER = r""" 32 | .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-. 33 | / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / 34 | `-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-' 35 | """ 36 | ASCII_ART = r""" 37 | __ __ _ _ _ 38 | \ \ / / _| |_| |__ ___ _ __(_)_ __ __ _ 39 | \ \ /\ / / | | | __| '_ \ / _ \ '__| | '_ \ / _` | 40 | \ V V /| |_| | |_| | | | __/ | | | | | | (_| | 41 | \_/\_/ \__,_|\__|_| |_|\___|_| |_|_| |_|\__, | 42 | __ __ ____ ____ |___/ 43 | \ \ / /_ ___ _____ ___ | _ \| _ \ / ___| 44 | \ \ /\ / / _` \ \ / / _ \/ __| | |_) | |_) | | 45 | \ V V / (_| |\ V / __/\__ \ | _ <| __/| |___ 46 | \_/\_/ \__,_| \_/ \___||___/ |_| \_\_| \____| 47 | 48 | """ 49 | 50 | 51 | def print_welcome_message(console: Console) -> None: 52 | """ 53 | Print a welcome message to the console 54 | 55 | :param console: The console to use for output 56 | """ 57 | console.print( 58 | "\n\n", 59 | LARGE_DIVIDER, 60 | ASCII_ART, 61 | LARGE_DIVIDER, 62 | indent( 63 | "\n\n", 64 | "[blue]Thank you for choosing to use Wuthering Waves Discord Rich Presence![/blue]", 65 | "Source code for this program can be found at https://github.com/xAkre/Wuthering-Waves-RPC", 66 | "A star would be appreciated if you found this program useful!", 67 | "Please follow the instructions below to set up the program.", 68 | "[red]Please note that this program is not affiliated with Wuthering Waves or its developers.[/red]", 69 | ), 70 | highlight=False, 71 | ) 72 | 73 | 74 | def get_config(console: Console) -> dict: 75 | """ 76 | Get the configuration options from the user 77 | 78 | :param console: The console to use for input and output 79 | """ 80 | using_steam_version = get_input( 81 | console, "Steam Version", lambda: get_using_steam_version(console) 82 | ) 83 | wuwa_install_location = get_input( 84 | console, 85 | "Wuthering Waves Install Location", 86 | lambda: get_wuwa_install_location(console, DEFAULT_WUWA_INSTALL_LOCATION), 87 | ) 88 | database_access_preference = get_input( 89 | console, 90 | "Database Access Preference", 91 | lambda: get_database_access_preference(console), 92 | ) 93 | 94 | if database_access_preference: 95 | kuro_games_uid = get_input( 96 | console, 97 | "Kuro Games UID", 98 | lambda: get_kuro_games_uid(console), 99 | ) 100 | 101 | config = { 102 | "using_steam_version": using_steam_version, 103 | "wuwa_install_location": wuwa_install_location, 104 | "database_access_preference": database_access_preference, 105 | "rich_presence_install_location": get_input( 106 | console, 107 | "Rich Presence Install Location", 108 | lambda: get_rich_presence_install_location( 109 | console, DEFAULT_RICH_PRESENCE_INSTALL_LOCATION 110 | ), 111 | ), 112 | "startup_preference": get_input( 113 | console, 114 | "Launch on Startup Preference", 115 | lambda: get_startup_preference(console), 116 | ), 117 | "keep_running_preference": get_input( 118 | console, 119 | "Keep Running Preference", 120 | lambda: get_keep_running_preference(console), 121 | ), 122 | "shortcut_preference": get_input( 123 | console, 124 | "Create Shortcut Preference", 125 | lambda: get_shortcut_preference(console), 126 | ), 127 | "promote_preference": get_input( 128 | console, 129 | "Promote Preference", 130 | lambda: get_promote_preference(console), 131 | ), 132 | } 133 | 134 | if database_access_preference: 135 | config["kuro_games_uid"] = kuro_games_uid 136 | 137 | return config 138 | 139 | 140 | def create_config_folder(console: Console, config: dict) -> None: 141 | """ 142 | Create the config folder in the install location 143 | 144 | :param console: The console to use for output 145 | :param config: The configuration options 146 | """ 147 | try: 148 | with console.status( 149 | indent("Creating the config folder in the install location..."), 150 | spinner="dots", 151 | ): 152 | makedirs(path.join(config["rich_presence_install_location"], "config")) 153 | console.print(indent("Config folder created."), style="green") 154 | except Exception as e: 155 | fatal_error( 156 | console, indent(f"An error occurred while creating the config folder"), e 157 | ) 158 | 159 | 160 | def write_config_to_file(console: Console, config: dict) -> None: 161 | """ 162 | Write the configuration to a file 163 | 164 | :param console: The console to use for output 165 | :param config: The configuration options 166 | """ 167 | try: 168 | with open( 169 | path.join( 170 | config["rich_presence_install_location"], "config", "config.json" 171 | ), 172 | "w", 173 | ) as f: 174 | f.write(dumps(config, indent=4)) 175 | 176 | console.print(indent("Configuration written to file."), style="green") 177 | except Exception as e: 178 | fatal_error( 179 | console, indent(f"An error occurred while writing the config to a file"), e 180 | ) 181 | 182 | 183 | def copy_main_exe_to_install_location(console: Console, config: dict) -> None: 184 | """ 185 | Copy the main executable to the install location 186 | 187 | :param console: The console to use for output 188 | :param config: The configuration options 189 | """ 190 | try: 191 | with console.status( 192 | indent("Copying the main executable to the install location..."), 193 | spinner="dots", 194 | ): 195 | copyfile( 196 | path.join(sys._MEIPASS, Config.MAIN_EXECUTABLE_NAME), 197 | path.join( 198 | config["rich_presence_install_location"], 199 | Config.MAIN_EXECUTABLE_NAME, 200 | ), 201 | ) 202 | console.print( 203 | indent("Main executable copied to install location."), style="green" 204 | ) 205 | except Exception as e: 206 | fatal_error( 207 | console, 208 | indent( 209 | f"An error occurred while copying the main executable to the install location", 210 | ), 211 | e, 212 | ) 213 | 214 | 215 | def copy_uninstall_exe_to_install_location(console: Console, config: dict) -> None: 216 | """ 217 | Copy the uninstall executable to the install location 218 | 219 | :param console: The console to use for output 220 | :param config: The configuration options 221 | """ 222 | try: 223 | with console.status( 224 | indent("Copying the uninstall executable to the install location..."), 225 | spinner="dots", 226 | ): 227 | copyfile( 228 | path.join(sys._MEIPASS, Config.UNINSTALL_EXECUTABLE_NAME), 229 | path.join( 230 | config["rich_presence_install_location"], 231 | Config.UNINSTALL_EXECUTABLE_NAME, 232 | ), 233 | ) 234 | console.print( 235 | indent("Uninstall executable copied to install location."), 236 | style="green", 237 | ) 238 | except Exception as e: 239 | fatal_error( 240 | console, 241 | indent( 242 | f"An error occurred while copying the uninstall executable to the install location", 243 | ), 244 | e, 245 | ) 246 | 247 | 248 | def add_exe_to_windows_apps(console: Console, config: dict) -> None: 249 | """ 250 | Add the executable to the Windows App list 251 | 252 | :param console: The console to use for output 253 | :param config: The configuration options 254 | """ 255 | try: 256 | with console.status( 257 | indent("Adding the executable to the Windows App list..."), spinner="dots" 258 | ): 259 | programs_folder = path.join( 260 | getenv("APPDATA"), 261 | "Microsoft/Windows/Start Menu/Programs", 262 | ) 263 | 264 | if not path.exists(programs_folder): 265 | makedirs(programs_folder) 266 | 267 | main_exe_shortcut_path = path.join( 268 | programs_folder, Config.MAIN_EXECUTABLE_NAME.replace(".exe", ".lnk") 269 | ) 270 | main_exe_shortcut_target = path.join( 271 | config["rich_presence_install_location"], Config.MAIN_EXECUTABLE_NAME 272 | ) 273 | 274 | shell = Dispatch("WScript.Shell") 275 | shortcut = shell.CreateShortcut(main_exe_shortcut_path) 276 | shortcut.TargetPath = main_exe_shortcut_target 277 | shortcut.Save() 278 | 279 | uninstall_exe_shortcut_path = path.join( 280 | programs_folder, 281 | Config.UNINSTALL_EXECUTABLE_NAME.replace(".exe", ".lnk"), 282 | ) 283 | uninstall_exe_shortcut_target = path.join( 284 | config["rich_presence_install_location"], 285 | Config.UNINSTALL_EXECUTABLE_NAME, 286 | ) 287 | 288 | shortcut = shell.CreateShortcut(uninstall_exe_shortcut_path) 289 | shortcut.TargetPath = uninstall_exe_shortcut_target 290 | shortcut.Save() 291 | 292 | console.print( 293 | indent("Executable added to Windows App list."), style="green" 294 | ) 295 | except Exception as e: 296 | console.print( 297 | indent( 298 | "An error occurred while adding the executable to the Windows App list:" 299 | ), 300 | style="red", 301 | ) 302 | console.print_exception() 303 | console.print( 304 | indent("The executable will not be added to the Windows App list.") 305 | ) 306 | console.print(indent("Setup will continue...")) 307 | 308 | 309 | def launch_exe_on_startup(console: Console, config: dict) -> None: 310 | """ 311 | Launch the executable on system startup 312 | 313 | :param console: The console to use for output 314 | :param config: The configuration options 315 | """ 316 | try: 317 | with console.status( 318 | indent("Setting the executable to launch on startup..."), spinner="dots" 319 | ): 320 | shortcut_target = path.join( 321 | config["rich_presence_install_location"], 322 | Config.MAIN_EXECUTABLE_NAME, 323 | ) 324 | 325 | # Thank god ChatGPT for this 326 | create_task_command = [ 327 | "schtasks", 328 | "/create", 329 | "/tn", 330 | "Wuthering Waves RPC", 331 | "/tr", 332 | f'"{shortcut_target}"', 333 | "/sc", 334 | "onlogon", 335 | "/rl", 336 | "highest", 337 | "/f", 338 | ] 339 | 340 | subprocess.run(create_task_command, check=True, stdout=subprocess.DEVNULL) 341 | console.print(indent("Executable set to launch on startup."), style="green") 342 | except Exception as e: 343 | console.print( 344 | indent( 345 | "An error occurred while setting the executable to launch on startup:" 346 | ), 347 | style="red", 348 | ) 349 | console.print_exception() 350 | console.print( 351 | indent("The executable will not launch on startup. Setup"), style="red" 352 | ) 353 | console.print(indent("Setup will continue...")) 354 | 355 | 356 | def create_windows_shortcut(console: Console, config: dict) -> None: 357 | """ 358 | Create a desktop shortcut for the executable 359 | 360 | :param console: The console to use for output 361 | :param config: The configuration options 362 | """ 363 | try: 364 | with console.status(indent("Creating a desktop shortcut..."), spinner="dots"): 365 | shortcut_path = path.join( 366 | path.expanduser("~/Desktop"), 367 | Config.MAIN_EXECUTABLE_NAME.replace(".exe", ".lnk"), 368 | ) 369 | shortcut_target = path.join( 370 | config["rich_presence_install_location"], Config.MAIN_EXECUTABLE_NAME 371 | ) 372 | shell = Dispatch("WScript.Shell") 373 | shortcut = shell.CreateShortcut(shortcut_path) 374 | shortcut.TargetPath = shortcut_target 375 | shortcut.Save() 376 | console.print(indent("Desktop shortcut created."), style="green") 377 | except Exception as e: 378 | console.print( 379 | indent("An error occurred while creating the desktop shortcut:"), 380 | style="red", 381 | ) 382 | console.print_exception() 383 | console.print(indent("The desktop shortcut will not be created."), style="red") 384 | console.print(indent("Setup will continue...")) 385 | 386 | 387 | print_welcome_message(console) 388 | config = get_config(console) 389 | print_divider(console, "[green]Options Finalised[/green]", "green") 390 | create_config_folder(console, config) 391 | write_config_to_file(console, config) 392 | copy_main_exe_to_install_location(console, config) 393 | copy_uninstall_exe_to_install_location(console, config) 394 | add_exe_to_windows_apps(console, config) 395 | if config["startup_preference"]: 396 | launch_exe_on_startup(console, config) 397 | if config["shortcut_preference"]: 398 | create_windows_shortcut(console, config) 399 | print_divider(console, "[green]Setup Completed[/green]", "green") 400 | console.show_cursor(False) 401 | 402 | # For some reason using console.input() here doesn't work, so I'm using input() instead 403 | input(indent("Press Enter to exit...")) 404 | exit(0) 405 | --------------------------------------------------------------------------------