better installation handling

This commit is contained in:
CPTN Cosmo 2026-01-11 06:25:27 +01:00
parent 8208c3735b
commit a9ba614537
No known key found for this signature in database
4 changed files with 215 additions and 50 deletions

View file

@ -1,61 +1,64 @@
# XIVLauncher OTP Wrapper # 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. This is a wrapper script for Linux that launches `xivlauncher-core` (Native or Flatpak) and automatically injects your One-Time Password (OTP) when the launcher requests it.
## Setup ## Features
- **Auto-Detection**: Automatically detects if you are using the native `xivlauncher-core` (Arch/AUR) or the Flatpak version.
- **OTP Injection**: Generates TOTP codes and sends them to the launcher's internal server.
- **URL Support**: Accepts standard Base32 secrets OR full `otpauth://` URLs (e.g. from QR codes).
- **Auto-Configuration**: Prompts to fix `launcher.ini` settings if they are incorrect.
## Installation (Steam Deck / Linux Desktop)
The easiest way to install is using the provided installer script.
1. **Run the Installer:**
```bash
./installer.sh
```
2. **Enter Secret:**
When prompted, enter your **TOTP Secret** or paste a full **otpauth:// URL**.
**What it does:**
* Creates `~/.config/xivlauncher-wrapper/config.json`.
* Installs the wrapper to `~/.local/bin/xivlauncher-wrapper`.
* Installs a `.desktop` file to `~/.local/share/applications/` so it appears in your application menu.
### Steam Integration (Steam Deck)
1. Switch to Desktop Mode.
2. Open Steam.
3. "Games" -> "Add a Non-Steam Game to My Library".
4. Select "XIVLauncher Wrapper" from the list.
5. Return to Gaming Mode and launch "XIVLauncher Wrapper".
## Manual Setup
1. **Configure:** 1. **Configure:**
Create the configuration directory and copy the example config.
```bash ```bash
mkdir -p ~/.config/xivlauncher-wrapper mkdir -p ~/.config/xivlauncher-wrapper
cp config.example.json ~/.config/xivlauncher-wrapper/config.json cp config.example.json ~/.config/xivlauncher-wrapper/config.json
nano ~/.config/xivlauncher-wrapper/config.json nano ~/.config/xivlauncher-wrapper/config.json
``` ```
* `secret`: Enter your TOTP secret key (in Base32 format, usually provided when you set up your authenticator app). * `secret`: Enter your TOTP secret key or `otpauth://` URL.
* `launcher_cmd`: The command to launch XIVLauncher (default: `xivlauncher-core`). * `launcher_cmd`: (Optional) The command to launch XIVLauncher. If omitted, the wrapper will auto-detect it.
2. **Make Executable:** 2. **Make Executable:**
Ensure the script is executable:
```bash ```bash
chmod +x wrapper.py chmod +x wrapper.py
``` ```
3. **XIVLauncher Settings:** 3. **XIVLauncher Settings:**
Ensure that "Enable XL Authenticator app/OTP macro support" is enabled in XIVLauncher settings. Ensure that "Enable XL Authenticator app/OTP macro support" is enabled in XIVLauncher settings. The wrapper will check for this and prompt you if it's disabled.
## Usage ## Usage
Simply run the wrapper instead of the launcher: Simply run the wrapper:
```bash ```bash
./wrapper.py ./wrapper.py
``` ```
The script will: Or launch "XIVLauncher Wrapper" from your application menu/Steam.
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.
## Steam Deck / Linux Desktop Installation
For Steam Deck or standard Linux desktop users, you can use the provided installer script to set everything up automatically.
1. **Run the Installer:**
```bash
./install-steamdeck.sh
```
Follow the prompts to enter your OTP secret.
2. **What it does:**
* Creates the configuration file with your secret.
* Installs the wrapper to `~/.local/bin/xivlauncher-wrapper`.
* Installs a `.desktop` file to `~/.local/share/applications/` so it appears in your application menu (and can be added to Steam as a non-Steam game).
3. **Steam Integration:**
* Switch to Desktop Mode.
* Open Steam.
* "Games" -> "Add a Non-Steam Game to My Library".
* Select "XIVLauncher Wrapper" from the list.
* Return to Gaming Mode and launch "XIVLauncher Wrapper".

View file

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

View file

@ -2,14 +2,14 @@
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/xivlauncher-wrapper" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/xivlauncher-wrapper"
CONFIG_FILE="$CONFIG_DIR/config.json" CONFIG_FILE="$CONFIG_DIR/config.json"
LAUNCHER_CMD="flatpak run dev.goats.xivlauncher"
# Install paths # Install paths
BIN_DIR="$HOME/.local/bin" BIN_DIR="$HOME/.local/bin"
APP_DIR="$HOME/.local/share/applications" APP_DIR="$HOME/.local/share/applications"
ICON_DIR="$HOME/.local/share/icons/hicolor/512x512/apps" ICON_DIR="$HOME/.local/share/icons/hicolor/512x512/apps"
echo "XIVLauncher Wrapper - Steam Deck Installer" echo "XIVLauncher Wrapper - Installer"
echo "==========================================" echo "=========================================="
# Create config directory if it doesn't exist # Create config directory if it doesn't exist
@ -27,8 +27,8 @@ fi
if [ "$SETUP_CONFIG" = true ]; then if [ "$SETUP_CONFIG" = true ]; then
echo "We will now set up the configuration file." echo "We will now set up the configuration file."
echo "You will need your generic TOTP secret (base32 format)." echo "You will need your generic TOTP secret (base32 format) OR a full otpauth:// URL."
read -p "Enter your TOTP Secret: " SECRET read -p "Enter your TOTP Secret or URL: " SECRET
if [ -z "$SECRET" ]; then if [ -z "$SECRET" ]; then
echo "Error: Secret cannot be empty." echo "Error: Secret cannot be empty."
@ -40,12 +40,11 @@ if [ "$SETUP_CONFIG" = true ]; then
cat > "$CONFIG_FILE" <<EOF cat > "$CONFIG_FILE" <<EOF
{ {
"secret": "$SECRET", "secret": "$SECRET"
"launcher_cmd": "$LAUNCHER_CMD"
} }
EOF EOF
echo "Configuration created successfully at: $CONFIG_FILE" echo "Configuration created successfully at: $CONFIG_FILE"
echo "Launcher command set to: $LAUNCHER_CMD"
fi fi
echo "" echo ""

View file

@ -11,12 +11,44 @@ import base64
import struct import struct
import hashlib import hashlib
import os 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): def get_totp_token(secret):
"""Generates a TOTP code from the secret.""" """Generates a TOTP code from the secret."""
try: try:
# Clean/extract secret first
secret_clean = parse_secret(secret)
if not secret_clean:
return None
# Pad the secret if needed for base32 decoding # Pad the secret if needed for base32 decoding
secret_clean = secret.replace(" ", "").upper()
padding = '=' * ((8 - len(secret_clean) % 8) % 8) padding = '=' * ((8 - len(secret_clean) % 8) % 8)
key = base64.b32decode(secret_clean + padding) key = base64.b32decode(secret_clean + padding)
@ -61,6 +93,78 @@ def send_otp(secret):
print("Timed out waiting for XIVLauncher OTP prompt.") 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(): def main():
# Determine config path (XDG standard) # Determine config path (XDG standard)
xdg_config_home = os.environ.get('XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config')) xdg_config_home = os.environ.get('XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config'))
@ -86,23 +190,82 @@ def main():
except json.JSONDecodeError: except json.JSONDecodeError:
print(f"Error parsing {config_path}. Invalid JSON.") print(f"Error parsing {config_path}. Invalid JSON.")
sys.exit(1) sys.exit(1)
# 1. Check and Update launcher.ini
check_and_update_launcher_ini()
secret = config.get("secret") secret = config.get("secret")
cmd = config.get("launcher_cmd", "xivlauncher-core") 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": if not secret or secret == "YOUR_BASE32_SECRET_HERE":
print("Error: Invalid or missing 'secret' in config.json.") print("Error: Invalid or missing 'secret' in config.json.")
sys.exit(1) sys.exit(1)
print(f"Launching: {cmd}") print(f"Launch Command: {cmd}")
# 3. Launch Logic
try: try:
# Launch the process # Launch the process
# If the user provided arguments to this script, we could pass them through? # We use shell=True if the command contains arguments like 'flatpak run ...'
# But for now, just launch the command from config # But split() and direct arg list is safer and cleaner if possible.
proc = subprocess.Popen(cmd.split()) # 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: except FileNotFoundError:
print(f"Error: Command '{cmd}' not found in PATH.") 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) sys.exit(1)
# Start the OTP injector in a background thread # Start the OTP injector in a background thread
@ -125,3 +288,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()