commit 85515c952869cca8bb43e7ad9ad7c2210a84f521 Author: CPTN Cosmo Date: Fri Jan 9 22:54:55 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cffcb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b09926e --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# XIVLauncher OTP Wrapper + +This is a simple wrapper script for Linux that launches `xivlauncher-core` and automatically injects your One-Time Password (OTP) when the launcher requests it. + +## Setup + +1. **Configure:** + Copy the example config and edit it with your details. + ```bash + cp config.example.json config.json + nano config.json + ``` + * `secret`: Enter your TOTP secret key (in Base32 format, usually provided when you set up your authenticator app). + * `launcher_cmd`: The command to launch XIVLauncher (default: `xivlauncher-core`). + +2. **Make Executable:** + Ensure the script is executable: + ```bash + chmod +x wrapper.py + ``` + +3. **XIVLauncher Settings:** + Ensure that "Enable XL Authenticator app/OTP macro support" is enabled in XIVLauncher settings. + +## Usage + +Simply run the wrapper instead of the launcher: + +```bash +./wrapper.py +``` + +The script will: +1. Launch XIVLauncher. +2. Wait for the launcher to start its local HTTP server (port 4646). +3. Generate the current OTP code. +4. Send it to the launcher automatically. diff --git a/__pycache__/wrapper.cpython-313.pyc b/__pycache__/wrapper.cpython-313.pyc new file mode 100644 index 0000000..c4a8e99 Binary files /dev/null and b/__pycache__/wrapper.cpython-313.pyc differ diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..7157883 --- /dev/null +++ b/config.example.json @@ -0,0 +1,4 @@ +{ + "secret": "YOUR_BASE32_SECRET_HERE", + "launcher_cmd": "xivlauncher-core" +} \ No newline at end of file diff --git a/wrapper.py b/wrapper.py new file mode 100755 index 0000000..4087f20 --- /dev/null +++ b/wrapper.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +import sys +import json +import time +import subprocess +import threading +import urllib.request +import urllib.error +import hmac +import base64 +import struct +import hashlib +import os + +def get_totp_token(secret): + """Generates a TOTP code from the secret.""" + try: + # Pad the secret if needed for base32 decoding + secret_clean = secret.replace(" ", "").upper() + padding = '=' * ((8 - len(secret_clean) % 8) % 8) + key = base64.b32decode(secret_clean + padding) + + # Time step is 30 seconds + msg = struct.pack(">Q", int(time.time() / 30)) + h = hmac.new(key, msg, hashlib.sha1).digest() + o = h[19] & 15 + h = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000 + return f"{h:06d}" + except Exception as e: + print(f"Error generating TOTP: {e}") + return None + +def send_otp(secret): + """Attempts to send the OTP to the local XIVLauncher server.""" + url_base = "http://127.0.0.1:4646/ffxivlauncher/" + print("Waiting for XIVLauncher to accept OTP...") + + # Try for up to 5 minutes (300 seconds) - launcher might take time to load + start_time = time.time() + while time.time() - start_time < 300: + otp = get_totp_token(secret) + if not otp: + print("Failed to generate OTP token.") + return + + try: + # Short timeout so we don't block + with urllib.request.urlopen(f"{url_base}{otp}", timeout=1) as response: + if response.status == 200: + print(f"SUCCESS: OTP {otp} sent to XIVLauncher!") + # We can stop trying once successful + return + except (urllib.error.URLError, ConnectionRefusedError, ConnectionResetError): + # Server not up or not accepting connections yet + pass + except Exception as e: + # Some other error (e.g. malformed URL if secret is bad?) + print(f"Debug: Connection error: {e}") + + time.sleep(1) + + print("Timed out waiting for XIVLauncher OTP prompt.") + +def main(): + # Determine config path (look in script directory) + script_dir = os.path.dirname(os.path.realpath(__file__)) + config_path = os.path.join(script_dir, "config.json") + + if not os.path.exists(config_path): + print(f"Config file not found at: {config_path}") + print("Please copy config.example.json to config.json and fill in your secret.") + sys.exit(1) + + try: + with open(config_path, 'r') as f: + config = json.load(f) + except json.JSONDecodeError: + print(f"Error parsing {config_path}. Invalid JSON.") + sys.exit(1) + + secret = config.get("secret") + cmd = config.get("launcher_cmd", "xivlauncher-core") + + if not secret or secret == "YOUR_BASE32_SECRET_HERE": + print("Error: Invalid or missing 'secret' in config.json.") + sys.exit(1) + + print(f"Launching: {cmd}") + + try: + # Launch the process + # If the user provided arguments to this script, we could pass them through? + # But for now, just launch the command from config + proc = subprocess.Popen(cmd.split()) + except FileNotFoundError: + print(f"Error: Command '{cmd}' not found in PATH.") + sys.exit(1) + + # Start the OTP injector in a background thread + injector_thread = threading.Thread(target=send_otp, args=(secret,)) + injector_thread.daemon = True + injector_thread.start() + + try: + # Wait for the launcher to exit + proc.wait() + except KeyboardInterrupt: + print("\nStopping wrapper...") + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + sys.exit(proc.returncode) + +if __name__ == "__main__": + main()