XIVLauncherWrapper/wrapper.py

564 lines
21 KiB
Python
Executable file

#!/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 ~/.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.")
if sys.stdin.isatty():
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 sys.stdin.isatty():
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 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 sys.stdin.isatty():
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 sys.stdin.isatty():
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()