diff --git a/README.md b/README.md index e88464e..e03891d 100644 --- a/README.md +++ b/README.md @@ -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). - **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. +- **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) @@ -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`. * **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. +* **XLM Setup:** Asks to install itself as an XLM pre-launch script by default. -### 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". +### Steam Integration Methods + +You can use the wrapper with Steam in three different ways: + +#### 1. XLM Pre-Launch Script (Recommended for Steam Deck) +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 @@ -64,6 +84,8 @@ Simply run the wrapper: ./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` diff --git a/config.example.json b/config.example.json index f96f9bb..53a849f 100644 --- a/config.example.json +++ b/config.example.json @@ -1,6 +1,12 @@ { - "launcher_cmd": "flatpak run dev.goats.xivlauncher", + "launcher_cmd": "", "secret": "", "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/" + } } \ No newline at end of file diff --git a/installer.sh b/installer.sh index 398a9b8..94f93e9 100755 --- a/installer.sh +++ b/installer.sh @@ -191,6 +191,51 @@ else echo "update-desktop-database not found, skipping." 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 "Installation complete!" echo "You may need to add $BIN_DIR to your PATH if it's not already there." diff --git a/wrapper.py b/wrapper.py index a6b6258..8cdf31a 100755 --- a/wrapper.py +++ b/wrapper.py @@ -13,6 +13,7 @@ import hashlib import os import shutil import configparser +import argparse try: import keyring HAS_KEYRING = True @@ -142,7 +143,11 @@ def check_and_update_launcher_ini(): 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)...") + if sys.stdin.isatty(): + try: + input("Press Enter to continue once you have done this (or to try anyway)...") + except EOFError: + pass return config = configparser.ConfigParser() @@ -164,7 +169,16 @@ def check_and_update_launcher_ini(): 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() + 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'): config.set('Main', 'IsOtpServer', 'true') try: @@ -257,6 +271,10 @@ def get_secret(config, config_path): if not secret_input: 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.") while True: try: @@ -268,6 +286,9 @@ def get_secret(config, config_path): except KeyboardInterrupt: print("\nOperation cancelled.") sys.exit(1) + except EOFError: + print("\nError: EOF reading secret.") + return None # Basic validation/parsing check parsed = parse_secret(secret_input) @@ -298,6 +319,16 @@ def get_secret(config, config_path): return parsed 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) 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') @@ -332,52 +363,62 @@ def main(): check_and_update_launcher_ini() cmd = config.get("launcher_cmd") - config_dirty = False + cmd_args = None - # 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] - 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.") + # Determine command to launch (if not prelaunch/inject-only) + if not args.prelaunch and not args.inject_only: + if unknown_args: + # Command passed via args (Steam %command% mode) + cmd_args = unknown_args + cmd = " ".join(shlex.quote(a) for a in cmd_args) + else: + # 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 choice in ('', 'y', 'yes'): - config["use_gamemode"] = True - config["gamemode_cmd"] = gamemode_cmd - else: - config["use_gamemode"] = False - config_dirty = True + 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] + 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) if config_dirty: @@ -395,18 +436,25 @@ def main(): print("Error: No secret available.") 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}") # 3. Launch Logic - try: - # Launch the process - # 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) + if not cmd_args: + cmd_args = shlex.split(cmd) # Prepend gamemode wrapper if enabled if config.get("use_gamemode"): @@ -417,16 +465,17 @@ def main(): elif shutil.which("gamemoderun"): g_cmd = "gamemoderun" 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}") + try: # Inject XL_SECRET_PROVIDER=file environment variable env = os.environ.copy() env['XL_SECRET_PROVIDER'] = 'file' - proc = subprocess.Popen(args, env=env) + proc = subprocess.Popen(cmd_args, env=env) 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) except Exception as e: print(f"Error launching command '{cmd}': {e}")