Initial commit

This commit is contained in:
CPTN Cosmo 2026-01-09 22:54:55 +01:00
commit 85515c9528
No known key found for this signature in database
5 changed files with 160 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
config.json

37
README.md Normal file
View file

@ -0,0 +1,37 @@
# XIVLauncher OTP Wrapper
This is a simple wrapper script for Linux that launches `xivlauncher-core` and automatically injects your One-Time Password (OTP) when the launcher requests it.
## Setup
1. **Configure:**
Copy the example config and edit it with your details.
```bash
cp config.example.json config.json
nano config.json
```
* `secret`: Enter your TOTP secret key (in Base32 format, usually provided when you set up your authenticator app).
* `launcher_cmd`: The command to launch XIVLauncher (default: `xivlauncher-core`).
2. **Make Executable:**
Ensure the script is executable:
```bash
chmod +x wrapper.py
```
3. **XIVLauncher Settings:**
Ensure that "Enable XL Authenticator app/OTP macro support" is enabled in XIVLauncher settings.
## Usage
Simply run the wrapper instead of the launcher:
```bash
./wrapper.py
```
The script will:
1. Launch XIVLauncher.
2. Wait for the launcher to start its local HTTP server (port 4646).
3. Generate the current OTP code.
4. Send it to the launcher automatically.

Binary file not shown.

4
config.example.json Normal file
View file

@ -0,0 +1,4 @@
{
"secret": "YOUR_BASE32_SECRET_HERE",
"launcher_cmd": "xivlauncher-core"
}

118
wrapper.py Executable file
View file

@ -0,0 +1,118 @@
#!/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()