add support for XLM pre-launch script integration and non-interactive configuration modes
This commit is contained in:
parent
72c301be78
commit
6f7d8c9d7c
4 changed files with 188 additions and 66 deletions
36
README.md
36
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).
|
- **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`
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
45
installer.sh
45
installer.sh
|
|
@ -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."
|
||||||
|
|
|
||||||
75
wrapper.py
75
wrapper.py
|
|
@ -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.")
|
||||||
|
if sys.stdin.isatty():
|
||||||
|
try:
|
||||||
input("Press Enter to continue once you have done this (or to try anyway)...")
|
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 = 'n'
|
||||||
|
if sys.stdin.isatty():
|
||||||
|
try:
|
||||||
choice = input("Enable 'IsOtpServer' now? [Y/n]: ").strip().lower()
|
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,9 +363,16 @@ 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
|
||||||
|
|
||||||
|
# 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
|
# 2. Auto-detect command if missing
|
||||||
if not cmd:
|
if not cmd:
|
||||||
print("\n'launcher_cmd' not set in config. detecting installed XIVLauncher versions...")
|
print("\n'launcher_cmd' not set in config. detecting installed XIVLauncher versions...")
|
||||||
|
|
@ -367,7 +405,10 @@ def main():
|
||||||
print(f"\n'{gamemode_cmd}' was detected on your system.")
|
print(f"\n'{gamemode_cmd}' was detected on your system.")
|
||||||
print("Would you like to enable it for XIVLauncher? This can improve game performance.")
|
print("Would you like to enable it for XIVLauncher? This can improve game performance.")
|
||||||
try:
|
try:
|
||||||
|
if sys.stdin.isatty():
|
||||||
choice = input(f"Enable {gamemode_cmd}? [Y/n]: ").strip().lower()
|
choice = input(f"Enable {gamemode_cmd}? [Y/n]: ").strip().lower()
|
||||||
|
else:
|
||||||
|
choice = 'y' # auto-enable if not interactive
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
choice = 'n'
|
choice = 'n'
|
||||||
print("\nPrompt interrupted, defaulting to No.")
|
print("\nPrompt interrupted, defaulting to No.")
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue