#!/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 import shutil import configparser import urllib.parse def parse_secret(secret_input): """Clean and extract secret from input (handles raw secret or otpauth:// URL).""" if not secret_input: return "" secret_clean = secret_input.strip() # Handle otpauth URL if secret_clean.lower().startswith('otpauth://'): try: parsed = urllib.parse.urlparse(secret_clean) params = urllib.parse.parse_qs(parsed.query) if 'secret' in params: # parse_qs returns a list return params['secret'][0].replace(" ", "").upper() else: print("Warning: otpauth URL provided but no 'secret' parameter found.") return "" except Exception as e: print(f"Warning: Failed to parse otpauth URL: {e}") return "" return secret_clean.replace(" ", "").upper() def get_totp_token(secret): """Generates a TOTP code from the secret.""" try: # Clean/extract secret first secret_clean = parse_secret(secret) if not secret_clean: return None # Pad the secret if needed for base32 decoding 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 check_and_update_launcher_ini(): """Checks ~/.xlcore/launcher.ini for IsOtpServer setting. If file is missing: prompts user to enable it manually in launcher. If file exists but setting is off: prompts user to auto-enable it. """ ini_path = os.path.expanduser("~/.xlcore/launcher.ini") if not os.path.exists(ini_path): print(f"\nConfiguration Check: '{ini_path}' not found.") print("Please open XIVLauncher, go to Settings, and enable 'Start internal OTP server'.") print("This is required for the wrapper to function.") input("Press Enter to continue once you have done this (or to try anyway)...") return config = configparser.ConfigParser() config.optionxform = str # Preserve case sensitivity try: config.read(ini_path) except configparser.Error as e: print(f"Warning: Could not read {ini_path}: {e}") return # Check if section exists, if not we assume malformed or empty, so we proceed to check/fix if not config.has_section('Main'): config.add_section('Main') current_value = config.get('Main', 'IsOtpServer', fallback='false').lower() if current_value != 'true': print(f"\nConfiguration Check: '{ini_path}'") print("XIVLauncher requires 'IsOtpServer' to be enabled to accept OTP codes from this wrapper.") choice = input("Enable 'IsOtpServer' now? [Y/n]: ").strip().lower() if choice in ('', 'y', 'yes'): config.set('Main', 'IsOtpServer', 'true') try: with open(ini_path, 'w') as f: config.write(f) print("Updated launcher.ini successfully.") except OSError as e: print(f"Error writing to {ini_path}: {e}") else: print("Warning: OTP injection will likely fail without this setting.") def detect_launch_command(): """Detects available XIVLauncher installations.""" commands = [] # Check for Flatpak if shutil.which('flatpak'): try: # Check if installed result = subprocess.run(['flatpak', 'list', '--app', '--columns=application'], capture_output=True, text=True) if 'dev.goats.xivlauncher' in result.stdout: commands.append(('Flatpak', 'flatpak run dev.goats.xivlauncher')) # Check for XL Core flatpak (sometimes named differently? no, usually dev.goats.xivlauncher) except Exception: pass # Check for Native # Common names for native packages for cmd in ['xivlauncher', 'xivlauncher-core', 'xivlauncher-rb']: path = shutil.which(cmd) if path: commands.append(('Native', path)) return commands 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) # 1. Check and Update launcher.ini check_and_update_launcher_ini() secret = config.get("secret") cmd = config.get("launcher_cmd") config_dirty = False # 2. Auto-detect command if missing if not cmd: print("\n'launcher_cmd' not set in config. detecting installed XIVLauncher versions...") available = detect_launch_command() if not available: print("Error: No XIVLauncher installation found (Flatpak or Native).") print("Please install XIVLauncher or manually set 'launcher_cmd' in config.json.") sys.exit(1) elif len(available) == 1: name, command = available[0] print(f"Detected {name}: {command}") cmd = command config['launcher_cmd'] = cmd config_dirty = True else: print("Multiple XIVLauncher installations found:") for i, (name, command) in enumerate(available): print(f"{i+1}) {name} ({command})") while True: try: selection = input("Select which one to launch (number): ").strip() idx = int(selection) - 1 if 0 <= idx < len(available): cmd = available[idx][1] config['launcher_cmd'] = cmd config_dirty = True break except ValueError: pass print("Invalid selection. Please enter a number from the list.") # Save config if updated if config_dirty: try: with open(config_path, 'w') as f: json.dump(config, f, indent=4) print(f"Updated configuration file: {config_path}") except OSError as e: print(f"Warning: Could not save updated config: {e}") if not secret or secret == "YOUR_BASE32_SECRET_HERE": print("Error: Invalid or missing 'secret' in config.json.") sys.exit(1) print(f"Launch Command: {cmd}") # 3. Launch Logic try: # Launch the process # We use shell=True if the command contains arguments like 'flatpak run ...' # But split() and direct arg list is safer and cleaner if possible. # However, 'flatpak run ...' is multiple args. # If the user put a complex command string in config, we should respect it. # Simple split logic: import shlex args = shlex.split(cmd) proc = subprocess.Popen(args) except FileNotFoundError: print(f"Error: Command executable not found: {cmd}") sys.exit(1) except Exception as e: print(f"Error launching command '{cmd}': {e}") 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()