#!/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 (XDG standard) xdg_config_home = os.environ.get('XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config')) config_dir = os.path.join(xdg_config_home, 'xivlauncher-wrapper') config_path = os.path.join(config_dir, 'config.json') # Fallback to script directory (legacy/development) if not os.path.exists(config_path): script_dir = os.path.dirname(os.path.realpath(__file__)) local_config = os.path.join(script_dir, "config.json") if os.path.exists(local_config): config_path = local_config if not os.path.exists(config_path): print(f"Config file not found.") print(f"Expected at: {config_path}") print(f"Please copy config.example.json to {config_path} 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()