XIVLauncherWrapper/wrapper.py
2026-01-09 22:54:55 +01:00

118 lines
3.8 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
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()