#!/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 argparse try: import keyring HAS_KEYRING = True except ImportError: HAS_KEYRING = False import getpass import shlex import urllib.parse def prompt_gui_secret(): """Prompts for the OTP secret using a DE-appropriate GUI dialog (kdialog or zenity).""" desktop = os.environ.get('XDG_CURRENT_DESKTOP', '').lower() dialogs = [] if 'kde' in desktop and shutil.which('kdialog'): dialogs.append('kdialog') if shutil.which('zenity'): dialogs.append('zenity') else: if shutil.which('zenity'): dialogs.append('zenity') if shutil.which('kdialog'): dialogs.append('kdialog') for dialog in dialogs: if dialog == 'kdialog': try: result = subprocess.run( ['kdialog', '--password', 'Please enter your XIVLauncher TOTP Secret (base32) or otpauth:// URL:'], capture_output=True, text=True ) if result.returncode == 0: return result.stdout.strip() except Exception as e: print(f"Warning: kdialog failed: {e}") elif dialog == 'zenity': try: result = subprocess.run( ['zenity', '--password', '--title=XIVLauncher Wrapper', '--text=Please enter your TOTP Secret (base32) or otpauth:// URL:'], capture_output=True, text=True ) if result.returncode == 0: return result.stdout.strip() except Exception as e: print(f"Warning: zenity failed: {e}") return None 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 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. """ native_ini = os.path.expanduser("~/.xlcore/launcher.ini") flatpak_ini = os.path.expanduser("~/.var/app/dev.goats.xivlauncher/data/xivlauncher/launcher.ini") ini_path = None if os.path.exists(native_ini): ini_path = native_ini elif os.path.exists(flatpak_ini): ini_path = flatpak_ini if not ini_path: print(f"\nConfiguration Check: Could not find launcher.ini (checked ~/.xlcore and flatpak paths).") print("Please open XIVLauncher, go to Settings, and enable 'Start internal OTP server'.") print("This is required for the wrapper to function.") if is_interactive(): try: input("Press Enter to continue once you have done this (or to try anyway)...") except EOFError: pass return try: with open(ini_path, 'r') as f: lines = f.readlines() except Exception as e: print(f"Warning: Could not read {ini_path}: {e}") return has_otp = False is_true = False for line in lines: if line.strip().lower().startswith('isotpserver='): has_otp = True if line.strip().lower().endswith('true'): is_true = True break if has_otp and is_true: return print(f"\nConfiguration Check: '{ini_path}'") print("XIVLauncher requires 'IsOtpServer' to be enabled to accept OTP codes from this wrapper.") choice = 'n' if is_interactive(): try: choice = input("Enable 'IsOtpServer' now? [Y/n]: ").strip().lower() except EOFError: choice = 'y' else: print("Non-interactive mode detected. Auto-enabling 'IsOtpServer'.") choice = 'y' if choice in ('', 'y', 'yes'): try: new_lines = [] replaced = False for line in lines: if line.strip().lower().startswith('isotpserver='): new_lines.append('IsOtpServer=true\n') replaced = True else: new_lines.append(line) if not replaced: new_lines.append('IsOtpServer=true\n') with open(ini_path, 'w') as f: f.writelines(new_lines) 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 return commands def is_steam_env(): """Detects if we are running under Steam, Steam Deck Gaming Mode, or as a Steam Compat Tool.""" if os.environ.get('XDG_CURRENT_DESKTOP', '').lower() == 'gamescope': return True if 'STEAM_COMPAT_DATA_PATH' in os.environ: return True if 'STEAM_RUNTIME' in os.environ: return True return False def is_interactive(): """Checks if we are in a truly interactive terminal session (not running under Steam).""" return sys.stdin.isatty() and not is_steam_env() def get_secret(config, config_path, allow_prompt=True): """ Retrieves the OTP secret in the following priority: 1. System Keyring (SERVICE_NAME, USERNAME) - if available and not in Steam environment 2. config.json - if Keyring unavailable/disabled or secret found there 3. User Input - prompts user and saves to Keyring/config (if allowed and not in Steam environment) """ SERVICE_NAME = "XIVLauncherWrapper" USERNAME = "OTP_SECRET" steam_env = is_steam_env() use_keyring = HAS_KEYRING and not steam_env # 1. Try Keyring if use_keyring: try: secret = keyring.get_password(SERVICE_NAME, USERNAME) if secret: return secret except Exception as e: print(f"Warning: Failed to access keyring: {e}") # 2. Try Config if config and "secret" in config: secret = config["secret"] if secret and secret != "YOUR_BASE32_SECRET_HERE": if use_keyring: print("Migrating secret from config.json to system keyring...") try: keyring.set_password(SERVICE_NAME, USERNAME, secret) # Remove from config del config["secret"] with open(config_path, 'w') as f: json.dump(config, f, indent=4) print("Secret migrated successfully and removed from config.json.") return secret except Exception as e: print(f"Error migrating to keyring: {e}") print("Continuing with secret from config...") return secret else: # No keyring, just use config return secret if not allow_prompt or steam_env: return None # 3. Prompt User if use_keyring: print("\nOTP Secret not found in keyring.") else: print("\nOTP Secret not found in config.json and keyring module is missing.") print("Attempting GUI prompt...") secret_input = prompt_gui_secret() if not secret_input: print("GUI prompt unavailable or cancelled. Falling back to CLI.") if not is_interactive(): print("Error: No interactive terminal available and GUI prompt failed. Cannot prompt for secret.") return None print("Please enter your TOTP Secret (base32) or otpauth:// URL.") while True: try: secret_input = getpass.getpass("Secret: ").strip() if not secret_input: print("Secret cannot be empty.") continue break except KeyboardInterrupt: print("\nOperation cancelled.") sys.exit(1) except EOFError: print("\nError: EOF reading secret.") return None # Basic validation/parsing check parsed = parse_secret(secret_input) while not parsed: print("Invalid secret format.") secret_input = getpass.getpass("Secret: ").strip() parsed = parse_secret(secret_input) if use_keyring: try: keyring.set_password(SERVICE_NAME, USERNAME, parsed) print("Secret saved to system keyring.") return parsed except Exception as e: print(f"Error saving to keyring: {e}") print("Proceeding with in-memory secret for this session.") return parsed else: # Save to config.json try: config['secret'] = parsed with open(config_path, 'w') as f: json.dump(config, f, indent=4) print(f"Secret saved to {config_path}") return parsed except Exception as e: print(f"Error saving to config.json: {e}") return parsed def main(): parser = argparse.ArgumentParser(description="XIVLauncher Wrapper") parser.add_argument('--prelaunch', action='store_true', help="Run as a prelaunch script (spawns injector and exits)") parser.add_argument('--inject-only', action='store_true', help=argparse.SUPPRESS) args, unknown_args = parser.parse_known_args() # Auto-detect if running from a prelaunch.d directory script_path = os.path.abspath(sys.argv[0]) if 'prelaunch.d' in script_path.split(os.sep): args.prelaunch = True # 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. Creating default empty configuration at {config_path}...") os.makedirs(config_dir, exist_ok=True) config = {"launcher_cmd": ""} try: with open(config_path, 'w') as f: json.dump(config, f, indent=4) except OSError as e: print(f"Error creating config file: {e}") sys.exit(1) else: 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() # Retrieve Secret (Keyring refactor) - Moved up so users can configure secret before launch check fails secret = get_secret(config, config_path, allow_prompt=not (args.prelaunch or args.inject_only)) if not secret: error_msg = "Error: OTP secret not configured. Please run 'xivlauncher-wrapper' manually in Desktop Mode to configure it." print(error_msg) # Log the error so user can see it if they check logs error_log = os.path.join(config_dir, 'error.log') try: with open(error_log, 'a') as f: f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {error_msg}\n") except Exception: pass if args.prelaunch: print("Continuing prelaunch without OTP injection so the game can still start.") sys.exit(0) elif args.inject_only: sys.exit(1) else: # If we are acting as a wrapper for %command%, continue without OTP. if unknown_args: print("Continuing command launch without OTP injection.") else: sys.exit(1) if args.inject_only: # Only run the OTP injection loop and exit send_otp(secret) sys.exit(0) if args.prelaunch: print("Running in prelaunch mode. Spawning OTP injector in background...") # Spawn ourselves in background with --inject-only subprocess.Popen([sys.executable, os.path.realpath(__file__), '--inject-only'], start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) sys.exit(0) cmd = config.get("launcher_cmd") config_dirty = False cmd_args = None # Determine command to launch (if not prelaunch/inject-only) if not args.prelaunch and not args.inject_only: if unknown_args: # Command passed via args (Steam %command% mode) cmd_args = unknown_args cmd = " ".join(shlex.quote(a) for a in cmd_args) else: # 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("\nError: No XIVLauncher installation found (Flatpak or Native).") print("If you are using XLM (Steam Compatibility Tool), you can ignore this error and just launch the game via Steam!") print("Otherwise, please install XIVLauncher or manually set 'launcher_cmd' in config.json.") sys.exit(0) # Exit 0 instead of 1 so it's not treated as a failure if they just wanted to set up the secret elif len(available) >= 1: name, command = available[0] if len(available) > 1: print(f"Multiple XIVLauncher installations found. Automatically selecting first available: {name}") else: print(f"Detected {name}: {command}") cmd = command config['launcher_cmd'] = cmd config_dirty = True # 2.5 Auto-detect gamemode/game-performance if "use_gamemode" not in config: gamemode_cmd = None if shutil.which("game-performance"): gamemode_cmd = "game-performance" elif shutil.which("gamemoderun"): gamemode_cmd = "gamemoderun" if gamemode_cmd: print(f"\n'{gamemode_cmd}' was detected on your system.") print("Would you like to enable it for XIVLauncher? This can improve game performance.") try: if is_interactive(): choice = input(f"Enable {gamemode_cmd}? [Y/n]: ").strip().lower() else: choice = 'y' # auto-enable if not interactive except (EOFError, KeyboardInterrupt): choice = 'n' print("\nPrompt interrupted, defaulting to No.") if choice in ('', 'y', 'yes'): config["use_gamemode"] = True config["gamemode_cmd"] = gamemode_cmd else: config["use_gamemode"] = False config_dirty = True # Save config if updated (e.g. launcher_cmd detected) 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}") print(f"Launch Command: {cmd}") # 3. Launch Logic if not cmd_args: cmd_args = shlex.split(cmd) # Prepend gamemode wrapper if enabled if config.get("use_gamemode"): g_cmd = config.get("gamemode_cmd") if not g_cmd: if shutil.which("game-performance"): g_cmd = "game-performance" elif shutil.which("gamemoderun"): g_cmd = "gamemoderun" if g_cmd and shutil.which(g_cmd): cmd_args.insert(0, g_cmd) print(f"Using game performance wrapper: {g_cmd}") try: # Inject XL_SECRET_PROVIDER=file environment variable env = os.environ.copy() env['XL_SECRET_PROVIDER'] = 'file' proc = subprocess.Popen(cmd_args, env=env) except FileNotFoundError: print(f"Error: Command executable not found: {cmd_args[0] if cmd_args else 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 if secret: injector_thread = threading.Thread(target=send_otp, args=(secret,)) injector_thread.daemon = True injector_thread.start() else: print("OTP injection skipped.") 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()