From ca9817d42717c5d2d05a8d1dd5e2d2c13aa3f147 Mon Sep 17 00:00:00 2001 From: Stephen Parente Date: Fri, 28 Nov 2025 00:03:23 -0500 Subject: [PATCH] Add support for fetching OTP from shell --- .../Shell/OtpShellReceiver.cs | 117 ++++++++++++++++++ src/XIVLauncher/App.xaml.cs | 2 +- .../Settings/ILauncherSettingsV3.cs | 2 + .../Windows/OtpInputDialog.xaml.cs | 39 +++++- src/XIVLauncher/Windows/SettingsControl.xaml | 6 + .../Windows/SettingsControl.xaml.cs | 5 +- .../ViewModel/SettingsControlViewModel.cs | 3 + 7 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 src/XIVLauncher.Common/Shell/OtpShellReceiver.cs diff --git a/src/XIVLauncher.Common/Shell/OtpShellReceiver.cs b/src/XIVLauncher.Common/Shell/OtpShellReceiver.cs new file mode 100644 index 000000000..0ba17cd89 --- /dev/null +++ b/src/XIVLauncher.Common/Shell/OtpShellReceiver.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using static XIVLauncher.Common.Http.OtpListener; + +namespace XIVLauncher.Common.Shell +{ + public class OtpShellReceiver + { + private readonly string fileName; + private readonly string args; + + + /// + /// Create a shell command receiver that takes the given configured shell command + /// and executes it to derive the OTP + /// + /// The configured command. The first space position is treated as the separator between filename and args + public OtpShellReceiver(string shellCommand) + { + // Parse shell command. Just get the first part and then the arguments afterward + var firstSpacePosition = shellCommand.IndexOf(" "); + + if (firstSpacePosition == -1) + { + this.fileName = shellCommand; + this.args = string.Empty; + } else + { + this.fileName = shellCommand.Substring(0, firstSpacePosition); + this.args = shellCommand.Substring(firstSpacePosition + 1); + } + } + + public async Task<(bool wasGotten, string? oneTimePassword)> TryGetOneTimePasswordAsync(CancellationToken cts = default) + { + Process proc = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = this.fileName, // "op", + Arguments = this.args, // "item get jxtm37qvd5f6tjezazg6grwq7u --otp", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + } + }; + + using var _ = cts.Register(() => + { + try + { + proc.Kill(); + } + catch + { + + } + }); + + try + { + var pass = await Task.Run(() => + { + if (this.TryGetOneTimePassword(proc, out string otp)) + { + return otp; + } else + { + return null; + } + }); + + if (pass == null) + { + return (false, null); + } + return (true, pass); + } catch + { + return (false, null); + } + } + + public bool TryGetOneTimePassword(Process proc, out String? otp) + { + proc.Start(); + var builder = new StringBuilder(); + while (!proc.StandardOutput.EndOfStream) + { + builder.AppendLine(proc.StandardOutput.ReadLine()); + } + + + if (proc.ExitCode != 0) + { + otp = null; + return false; + } + + var result = builder.ToString().Trim(); + if (string.IsNullOrWhiteSpace(result)) + { + otp = null; + return false; + } + + otp = result; + return true; + + } + } +} diff --git a/src/XIVLauncher/App.xaml.cs b/src/XIVLauncher/App.xaml.cs index a98796ac0..dbee8e98e 100644 --- a/src/XIVLauncher/App.xaml.cs +++ b/src/XIVLauncher/App.xaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; using System.IO; using System.Linq; diff --git a/src/XIVLauncher/Settings/ILauncherSettingsV3.cs b/src/XIVLauncher/Settings/ILauncherSettingsV3.cs index 9b1428ae2..6cee2aab4 100644 --- a/src/XIVLauncher/Settings/ILauncherSettingsV3.cs +++ b/src/XIVLauncher/Settings/ILauncherSettingsV3.cs @@ -19,6 +19,8 @@ public interface ILauncherSettingsV3 string AdditionalLaunchArgs { get; set; } bool InGameAddonEnabled { get; set; } DalamudLoadMethod? InGameAddonLoadMethod { get; set; } + bool OtpShellEnabled { get; set; } + string OtpShellCommand { get; set; } bool OtpServerEnabled { get; set; } bool OtpAlwaysOnTopEnabled { get; set; } ClientLanguage? Language { get; set; } diff --git a/src/XIVLauncher/Windows/OtpInputDialog.xaml.cs b/src/XIVLauncher/Windows/OtpInputDialog.xaml.cs index 960de7574..0f5e77f44 100644 --- a/src/XIVLauncher/Windows/OtpInputDialog.xaml.cs +++ b/src/XIVLauncher/Windows/OtpInputDialog.xaml.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Input; @@ -9,6 +10,7 @@ using System.Windows.Threading; using Serilog; using XIVLauncher.Common.Http; +using XIVLauncher.Common.Shell; using XIVLauncher.Common.Util; using XIVLauncher.Windows.ViewModel; @@ -25,6 +27,7 @@ public partial class OtpInputDialog : Window private OtpInputDialogViewModel ViewModel => DataContext as OtpInputDialogViewModel; + private CancellationTokenSource cts; private OtpListener _otpListener; private bool _ignoreCurrentOtp; @@ -44,9 +47,14 @@ public OtpInputDialog() public new bool? ShowDialog() { + cts = new CancellationTokenSource(); OtpTextBox.Focus(); - if (App.Settings.OtpServerEnabled) + if (App.Settings.OtpShellEnabled) + { + var receiver = new OtpShellReceiver(App.Settings.OtpShellCommand); + this.GetOneTimePassword(receiver, cts.Token).ConfigureAwait(false); + } else if (App.Settings.OtpServerEnabled) { _otpListener = new OtpListener("legacy-" + AppUtil.GetAssemblyVersion()); _otpListener.OnOtpReceived += TryAcceptOtp; @@ -66,12 +74,41 @@ public OtpInputDialog() return base.ShowDialog(); } + private async Task GetOneTimePassword(OtpShellReceiver receiver, CancellationToken cancellationToken) + { + try + { + var res = await receiver.TryGetOneTimePasswordAsync(cts.Token); + if (res.wasGotten) + { + TryAcceptOtp(res.oneTimePassword); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to get OTP."); + } + } + public void Reset() { OtpInputPrompt.Text = ViewModel.OtpInputPromptLoc; OtpInputPrompt.Foreground = _otpInputPromptDefaultBrush; OtpTextBox.Text = ""; OtpTextBox.Focus(); + if (cts != null) + { + cts.Cancel(); + try + { + cts.Dispose(); + } + catch (ObjectDisposedException) + { + + } + } + cts = new CancellationTokenSource(); } public void IgnoreCurrentResult(string reason) diff --git a/src/XIVLauncher/Windows/SettingsControl.xaml b/src/XIVLauncher/Windows/SettingsControl.xaml index 8f2fc1615..742aec514 100644 --- a/src/XIVLauncher/Windows/SettingsControl.xaml +++ b/src/XIVLauncher/Windows/SettingsControl.xaml @@ -60,6 +60,12 @@ + + + + diff --git a/src/XIVLauncher/Windows/SettingsControl.xaml.cs b/src/XIVLauncher/Windows/SettingsControl.xaml.cs index 550de9801..a57efdddc 100644 --- a/src/XIVLauncher/Windows/SettingsControl.xaml.cs +++ b/src/XIVLauncher/Windows/SettingsControl.xaml.cs @@ -80,7 +80,7 @@ public void ReloadSettings() OtpServerCheckBox.IsChecked = App.Settings.OtpServerEnabled; OtpAlwaysOnTopCheckBox.IsChecked = App.Settings.OtpAlwaysOnTopEnabled; - + OtpShellArgsTextBox.Text = App.Settings.OtpShellCommand; LaunchArgsTextBox.Text = App.Settings.AdditionalLaunchArgs; DpiAwarenessComboBox.SelectedIndex = (int) App.Settings.DpiAwareness.GetValueOrDefault(DpiAwareness.Unaware); @@ -122,6 +122,9 @@ private void AcceptButton_Click(object sender, RoutedEventArgs e) App.Settings.OtpServerEnabled = OtpServerCheckBox.IsChecked == true; + App.Settings.OtpShellEnabled = !string.IsNullOrWhiteSpace(OtpShellArgsTextBox.Text); + App.Settings.OtpShellCommand = OtpShellArgsTextBox.Text; + App.Settings.OtpAlwaysOnTopEnabled = OtpAlwaysOnTopCheckBox.IsChecked == true; App.Settings.AdditionalLaunchArgs = LaunchArgsTextBox.Text; diff --git a/src/XIVLauncher/Windows/ViewModel/SettingsControlViewModel.cs b/src/XIVLauncher/Windows/ViewModel/SettingsControlViewModel.cs index af33adeb7..8a6040a00 100644 --- a/src/XIVLauncher/Windows/ViewModel/SettingsControlViewModel.cs +++ b/src/XIVLauncher/Windows/ViewModel/SettingsControlViewModel.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.IO; using System.Runtime.CompilerServices; +using System.Threading; using CheapLoc; namespace XIVLauncher.Windows.ViewModel @@ -70,6 +71,7 @@ private void SetupLoc() SteamCheckBoxLoc = Loc.Localize("FirstTimeSteamCheckBox", "Enable Steam integration"); OtpServerCheckBoxLoc = Loc.Localize("OtpServerCheckBox", "Enable XL Authenticator app/OTP macro support"); OtpServerTooltipLoc = Loc.Localize("OtpServerTooltip", "This will allow you to send your OTP code to XIVLauncher directly from your phone.\nClick \"Learn more\" to see how to set this up."); + OtpShellArgsLoc = Loc.Localize("OtpShellArgsLoc", "The arguments to use to fetch your OTP code"); LearnMoreLoc = Loc.Localize("LearnMore", "Learn More"); OtpLearnMoreTooltipLoc = Loc.Localize("OtpLearnMoreTooltipLoc", "Open a guide in your web browser."); OtpAlwaysOnTopCheckBoxLoc = Loc.Localize("OtpAlwaysOnTopCheckBox", "Keep the OTP Window Always on Top"); @@ -161,6 +163,7 @@ private void SetupLoc() public string SteamCheckBoxLoc { get; private set; } public string OtpServerCheckBoxLoc { get; private set; } public string OtpServerTooltipLoc { get; private set; } + public string OtpShellArgsLoc { get; private set; } public string LearnMoreLoc { get; private set; } public string OtpLearnMoreTooltipLoc { get; private set; } public string OtpAlwaysOnTopCheckBoxLoc { get; private set; }