better installation handling
This commit is contained in:
parent
8208c3735b
commit
a9ba614537
4 changed files with 215 additions and 50 deletions
71
README.md
71
README.md
|
|
@ -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".
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
{
|
{
|
||||||
"secret": "YOUR_BASE32_SECRET_HERE",
|
"secret": "YOUR_BASE32_SECRET_HERE"
|
||||||
"launcher_cmd": "xivlauncher-core"
|
|
||||||
}
|
}
|
||||||
|
|
@ -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 ""
|
||||||
178
wrapper.py
178
wrapper.py
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue