add support for XLM pre-launch script integration and non-interactive configuration modes

This commit is contained in:
CPTN Cosmo 2026-04-18 15:23:24 +02:00
parent 72c301be78
commit 6f7d8c9d7c
4 changed files with 188 additions and 66 deletions

View file

@ -10,6 +10,8 @@ This is a wrapper script for Linux that launches `xivlauncher-core` (Native or F
- **URL Support**: Accepts standard Base32 secrets OR full `otpauth://` URLs (e.g. from QR codes). - **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. - **Auto-Configuration**: Prompts to fix `launcher.ini` settings if they are incorrect.
- **Performance Optimization**: Automatically detects and integrates with `gamemoderun` or `game-performance` to optimize your game instance. - **Performance Optimization**: Automatically detects and integrates with `gamemoderun` or `game-performance` to optimize your game instance.
- **XLM Pre-Launch Support**: Seamlessly integrates as a pre-launch script for the Steam Compatibility Tool XLM.
- **Steam %command% Wrapper**: Can wrap arbitrary launch arguments natively in Steam launch options.
## Installation (Steam Deck / Linux Desktop) ## Installation (Steam Deck / Linux Desktop)
@ -27,13 +29,31 @@ The easiest way to install is using the provided installer script. This script w
* **Creates Config:** Creates `~/.config/xivlauncher-wrapper/config.json`. * **Creates Config:** Creates `~/.config/xivlauncher-wrapper/config.json`.
* **Installs Wrapper:** Installs the wrapper to `~/.local/bin/xivlauncher-wrapper`. * **Installs Wrapper:** Installs the wrapper to `~/.local/bin/xivlauncher-wrapper`.
* **Installs Shortcut:** Installs a `.desktop` file to `~/.local/share/applications/` so it appears in your application menu. * **Installs Shortcut:** Installs a `.desktop` file to `~/.local/share/applications/` so it appears in your application menu.
* **XLM Setup:** Asks to install itself as an XLM pre-launch script by default.
### Steam Integration (Steam Deck) ### Steam Integration Methods
1. Switch to Desktop Mode.
2. Open Steam. You can use the wrapper with Steam in three different ways:
3. "Games" -> "Add a Non-Steam Game to My Library".
4. Select "XIVLauncher Wrapper" from the list. #### 1. XLM Pre-Launch Script (Recommended for Steam Deck)
5. Return to Gaming Mode and launch "XIVLauncher Wrapper". If you use XLM (XIVLauncher Manager) as a Steam Compatibility Tool for FFXIV, the installer can automatically set up the wrapper to run right before the game launches.
If you skip this during installation, you can manually symlink the wrapper into your XLM directory:
```bash
mkdir -p ~/.local/share/Steam/compatibilitytools.d/XLCore/prelaunch.d
ln -sf ~/.local/bin/xivlauncher-wrapper ~/.local/share/Steam/compatibilitytools.d/XLCore/prelaunch.d/xivlauncher-wrapper
```
*(The script automatically detects it is in a `prelaunch.d` directory and runs in `--prelaunch` background mode).*
#### 2. Steam Launch Options Wrap
If you want to use it directly in the Steam properties of FFXIV without XLM, you can set your launch options to:
```
xivlauncher-wrapper %command%
```
#### 3. Standalone Non-Steam Game
1. Switch to Desktop Mode and open Steam.
2. "Games" -> "Add a Non-Steam Game to My Library".
3. Select "XIVLauncher Wrapper" from the list.
## Manual Setup ## Manual Setup
@ -64,6 +84,8 @@ Simply run the wrapper:
./wrapper.py ./wrapper.py
``` ```
Or launch "XIVLauncher Wrapper" from your application menu/Steam. **Advanced CLI options:**
- `--prelaunch`: Spawns the OTP injector in the background and exits immediately. Useful for custom wrapper scripts.
- Wrapping a specific command: `./wrapper.py flatpak run dev.goats.xivlauncher`

View file

@ -1,6 +1,12 @@
{ {
"launcher_cmd": "flatpak run dev.goats.xivlauncher", "launcher_cmd": "",
"secret": "", "secret": "",
"use_gamemode": false, "use_gamemode": false,
"gamemode_cmd": "gamemoderun" "gamemode_cmd": "gamemoderun",
"_notes": {
"launcher_cmd": "Leave empty for auto-detection. Can also be passed via CLI args (e.g., Steam %command%)",
"secret": "Leave empty to use system keyring or prompt on launch",
"use_gamemode": "Set to true to use a performance tool like gamemoderun or game-performance",
"prelaunch": "The wrapper supports running automatically as an XLM prelaunch script if placed in prelaunch.d/"
}
} }

View file

@ -191,6 +191,51 @@ else
echo "update-desktop-database not found, skipping." echo "update-desktop-database not found, skipping."
fi fi
echo ""
echo "Checking for XLM compatibility tool..."
XLM_PATHS=(
"$HOME/.local/share/Steam/compatibilitytools.d/XLCore"
"$HOME/.local/share/Steam/compatibilitytools.d/XLM"
"$HOME/.local/share/Steam/compatibilitytools.d/xlm"
"$HOME/.steam/root/compatibilitytools.d/XLCore"
"$HOME/.steam/root/compatibilitytools.d/XLM"
"$HOME/.steam/root/compatibilitytools.d/xlm"
)
XLM_FOUND=""
for p in "${XLM_PATHS[@]}"; do
if [ -d "$p" ]; then
XLM_FOUND="$p"
break
fi
done
echo "Would you like to install the wrapper as a pre-launch script for XLM?"
echo "This allows the OTP injector to run automatically when launching FFXIV via Steam with XLM."
read -p "Install as XLM pre-launch script? [Y/n]: " INSTALL_PRELAUNCH
if [[ ! "$INSTALL_PRELAUNCH" =~ ^[Nn]$ ]]; then
if [ -n "$XLM_FOUND" ]; then
echo "Found XLM at $XLM_FOUND"
mkdir -p "$XLM_FOUND/prelaunch.d"
ln -sf "$BIN_DIR/xivlauncher-wrapper" "$XLM_FOUND/prelaunch.d/xivlauncher-wrapper"
echo "Successfully linked wrapper to $XLM_FOUND/prelaunch.d/"
else
echo "Could not automatically detect XLM installation path."
echo "If you have XLM installed, please enter the path to your XLM compatibility tool folder."
echo "(Leave blank to skip)"
read -p "> " CUSTOM_XLM_PATH
if [ -n "$CUSTOM_XLM_PATH" ] && [ -d "$CUSTOM_XLM_PATH" ]; then
mkdir -p "$CUSTOM_XLM_PATH/prelaunch.d"
ln -sf "$BIN_DIR/xivlauncher-wrapper" "$CUSTOM_XLM_PATH/prelaunch.d/xivlauncher-wrapper"
echo "Successfully linked wrapper to $CUSTOM_XLM_PATH/prelaunch.d/"
else
echo "Skipping XLM pre-launch script installation."
fi
fi
else
echo "Skipping XLM pre-launch script setup."
fi
echo "" echo ""
echo "Installation complete!" echo "Installation complete!"
echo "You may need to add $BIN_DIR to your PATH if it's not already there." echo "You may need to add $BIN_DIR to your PATH if it's not already there."

View file

@ -13,6 +13,7 @@ import hashlib
import os import os
import shutil import shutil
import configparser import configparser
import argparse
try: try:
import keyring import keyring
HAS_KEYRING = True HAS_KEYRING = True
@ -142,7 +143,11 @@ def check_and_update_launcher_ini():
print(f"\nConfiguration Check: '{ini_path}' not found.") print(f"\nConfiguration Check: '{ini_path}' not found.")
print("Please open XIVLauncher, go to Settings, and enable 'Start internal OTP server'.") print("Please open XIVLauncher, go to Settings, and enable 'Start internal OTP server'.")
print("This is required for the wrapper to function.") print("This is required for the wrapper to function.")
input("Press Enter to continue once you have done this (or to try anyway)...") if sys.stdin.isatty():
try:
input("Press Enter to continue once you have done this (or to try anyway)...")
except EOFError:
pass
return return
config = configparser.ConfigParser() config = configparser.ConfigParser()
@ -164,7 +169,16 @@ def check_and_update_launcher_ini():
print(f"\nConfiguration Check: '{ini_path}'") print(f"\nConfiguration Check: '{ini_path}'")
print("XIVLauncher requires 'IsOtpServer' to be enabled to accept OTP codes from this wrapper.") print("XIVLauncher requires 'IsOtpServer' to be enabled to accept OTP codes from this wrapper.")
choice = input("Enable 'IsOtpServer' now? [Y/n]: ").strip().lower() 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'): if choice in ('', 'y', 'yes'):
config.set('Main', 'IsOtpServer', 'true') config.set('Main', 'IsOtpServer', 'true')
try: try:
@ -257,6 +271,10 @@ def get_secret(config, config_path):
if not secret_input: if not secret_input:
print("GUI prompt unavailable or cancelled. Falling back to CLI.") 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.") print("Please enter your TOTP Secret (base32) or otpauth:// URL.")
while True: while True:
try: try:
@ -268,6 +286,9 @@ def get_secret(config, config_path):
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nOperation cancelled.") print("\nOperation cancelled.")
sys.exit(1) sys.exit(1)
except EOFError:
print("\nError: EOF reading secret.")
return None
# Basic validation/parsing check # Basic validation/parsing check
parsed = parse_secret(secret_input) parsed = parse_secret(secret_input)
@ -298,6 +319,16 @@ def get_secret(config, config_path):
return parsed return parsed
def main(): 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) # 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'))
config_dir = os.path.join(xdg_config_home, 'xivlauncher-wrapper') config_dir = os.path.join(xdg_config_home, 'xivlauncher-wrapper')
@ -332,52 +363,62 @@ def main():
check_and_update_launcher_ini() check_and_update_launcher_ini()
cmd = config.get("launcher_cmd") cmd = config.get("launcher_cmd")
config_dirty = False config_dirty = False
cmd_args = None
# 2. Auto-detect command if missing # Determine command to launch (if not prelaunch/inject-only)
if not cmd: if not args.prelaunch and not args.inject_only:
print("\n'launcher_cmd' not set in config. detecting installed XIVLauncher versions...") if unknown_args:
available = detect_launch_command() # Command passed via args (Steam %command% mode)
cmd_args = unknown_args
if not available: cmd = " ".join(shlex.quote(a) for a in cmd_args)
print("Error: No XIVLauncher installation found (Flatpak or Native).") else:
print("Please install XIVLauncher or manually set 'launcher_cmd' in config.json.") # 2. Auto-detect command if missing
sys.exit(1) if not cmd:
print("\n'launcher_cmd' not set in config. detecting installed XIVLauncher versions...")
elif len(available) >= 1: available = detect_launch_command()
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:
choice = input(f"Enable {gamemode_cmd}? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
choice = 'n'
print("\nPrompt interrupted, defaulting to No.")
if choice in ('', 'y', 'yes'): if not available:
config["use_gamemode"] = True print("Error: No XIVLauncher installation found (Flatpak or Native).")
config["gamemode_cmd"] = gamemode_cmd print("Please install XIVLauncher or manually set 'launcher_cmd' in config.json.")
else: sys.exit(1)
config["use_gamemode"] = False
config_dirty = True 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) # Save config if updated (e.g. launcher_cmd detected)
if config_dirty: if config_dirty:
@ -395,18 +436,25 @@ def main():
print("Error: No secret available.") print("Error: No secret available.")
sys.exit(1) 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)
print(f"Launch Command: {cmd}") print(f"Launch Command: {cmd}")
# 3. Launch Logic # 3. Launch Logic
try: if not cmd_args:
# Launch the process cmd_args = shlex.split(cmd)
# We use shell=True if the command contains arguments like 'flatpak run ...'
# But split() and direct arg list is safer and cleaner if possible.
# 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)
# Prepend gamemode wrapper if enabled # Prepend gamemode wrapper if enabled
if config.get("use_gamemode"): if config.get("use_gamemode"):
@ -417,16 +465,17 @@ def main():
elif shutil.which("gamemoderun"): elif shutil.which("gamemoderun"):
g_cmd = "gamemoderun" g_cmd = "gamemoderun"
if g_cmd and shutil.which(g_cmd): if g_cmd and shutil.which(g_cmd):
args.insert(0, g_cmd) cmd_args.insert(0, g_cmd)
print(f"Using game performance wrapper: {g_cmd}") print(f"Using game performance wrapper: {g_cmd}")
try:
# Inject XL_SECRET_PROVIDER=file environment variable # Inject XL_SECRET_PROVIDER=file environment variable
env = os.environ.copy() env = os.environ.copy()
env['XL_SECRET_PROVIDER'] = 'file' env['XL_SECRET_PROVIDER'] = 'file'
proc = subprocess.Popen(args, env=env) proc = subprocess.Popen(cmd_args, env=env)
except FileNotFoundError: except FileNotFoundError:
print(f"Error: Command executable not found: {cmd}") print(f"Error: Command executable not found: {cmd_args[0] if cmd_args else cmd}")
sys.exit(1) sys.exit(1)
except Exception as e: except Exception as e:
print(f"Error launching command '{cmd}': {e}") print(f"Error launching command '{cmd}': {e}")