diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml
new file mode 100644
index 0000000..dfa1dca
--- /dev/null
+++ b/.github/workflows/windows-build.yml
@@ -0,0 +1,91 @@
+name: Windows build
+
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+env:
+ BUILD_CONFIGURATION: Release
+
+jobs:
+ credential-provider:
+ name: Credential Provider (${{ matrix.artifactName }})
+ runs-on: windows-2022
+ timeout-minutes: 60
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: x64
+ artifactName: x64
+ - platform: Win32
+ artifactName: x86
+
+ steps:
+ - name: Check out sources
+ uses: actions/checkout@v4
+
+ - name: Locate MSBuild
+ uses: microsoft/setup-msbuild@v2
+
+ - name: Build solution
+ shell: pwsh
+ run: >
+ msbuild multiOTPCredentialProvider.sln
+ /m
+ /nologo
+ /p:Configuration=${{ env.BUILD_CONFIGURATION }}
+ /p:Platform=${{ matrix.platform }}
+ /p:PlatformToolset=v143
+ /bl:${{ github.workspace }}\msbuild-${{ matrix.artifactName }}.binlog
+
+ - name: Upload ${{ matrix.artifactName }} artifacts
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: multiotp-credential-provider-${{ matrix.artifactName }}
+ if-no-files-found: error
+ path: |
+ CredentialProvider/${{ matrix.platform }}/${{ env.BUILD_CONFIGURATION }}/*.dll
+ CredentialProviderFilter/${{ matrix.platform }}/${{ env.BUILD_CONFIGURATION }}/*.dll
+ CppClientCore/CppClientCore/${{ matrix.platform }}/${{ env.BUILD_CONFIGURATION }}/*.lib
+ WixInstall/multiOTPCredentialProviderInstaller/bin/${{ matrix.artifactName }}/${{ env.BUILD_CONFIGURATION }}/*.msi
+ msbuild-${{ matrix.artifactName }}.binlog
+
+ wpf-manager:
+ name: WPF manager
+ runs-on: windows-2022
+ timeout-minutes: 30
+
+ steps:
+ - name: Check out sources
+ uses: actions/checkout@v4
+
+ - name: Locate MSBuild
+ uses: microsoft/setup-msbuild@v2
+
+ - name: Install .NET Framework 4.5.2 Developer Pack
+ shell: pwsh
+ run: choco install netfx-4.5.2-devpack --no-progress -y
+
+ - name: Build manager
+ shell: pwsh
+ run: >
+ msbuild MultiOtpManager/MultiOtpManager.csproj
+ /m
+ /nologo
+ /p:Configuration=${{ env.BUILD_CONFIGURATION }}
+ /p:Platform=AnyCPU
+ /bl:${{ github.workspace }}\msbuild-manager.binlog
+
+ - name: Upload manager artifacts
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: multiotp-manager
+ if-no-files-found: error
+ path: |
+ MultiOtpManager/bin/${{ env.BUILD_CONFIGURATION }}/*
+ msbuild-manager.binlog
diff --git a/MultiOtpManager/App.config b/MultiOtpManager/App.config
new file mode 100644
index 0000000..c845ae4
--- /dev/null
+++ b/MultiOtpManager/App.config
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/MultiOtpManager/App.xaml b/MultiOtpManager/App.xaml
new file mode 100644
index 0000000..8e52a96
--- /dev/null
+++ b/MultiOtpManager/App.xaml
@@ -0,0 +1,5 @@
+
+
diff --git a/MultiOtpManager/App.xaml.cs b/MultiOtpManager/App.xaml.cs
new file mode 100644
index 0000000..c8ee035
--- /dev/null
+++ b/MultiOtpManager/App.xaml.cs
@@ -0,0 +1,66 @@
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using System.Windows;
+using MultiOtpManager.Core;
+
+namespace MultiOtpManager
+{
+ public partial class App : Application
+ {
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ // Pick the UI culture before any window is created so that
+ // MainWindow's {x:Static p:Resources.Key} bindings resolve against
+ // the chosen language.
+ AppSettings settings = AppSettings.Load();
+
+ // First-run auto-detection: if there is no settings file on disk,
+ // guess the best matching built-in language from the system UI
+ // culture and persist the choice so the picker reflects it later.
+ if (!File.Exists(AppSettings.SettingsFilePath))
+ {
+ settings.Language = GuessInitialLanguage();
+ settings.Save();
+ }
+
+ ApplyLanguage(settings.Language);
+
+ base.OnStartup(e);
+ }
+
+ private static string GuessInitialLanguage()
+ {
+ CultureInfo current = CultureInfo.CurrentUICulture;
+ string name = current != null ? current.Name : string.Empty;
+
+ // Anything in the zh family maps to Simplified Chinese; otherwise
+ // fall through to English, which is the Resources.resx fallback.
+ if (name.StartsWith("zh", System.StringComparison.OrdinalIgnoreCase))
+ {
+ return "zh-Hans";
+ }
+ return "en";
+ }
+
+ private static void ApplyLanguage(string cultureName)
+ {
+ if (string.IsNullOrEmpty(cultureName))
+ {
+ return;
+ }
+
+ try
+ {
+ CultureInfo culture = CultureInfo.GetCultureInfo(cultureName);
+ CultureInfo.DefaultThreadCurrentUICulture = culture;
+ Thread.CurrentThread.CurrentUICulture = culture;
+ }
+ catch (CultureNotFoundException)
+ {
+ // Unknown culture name in settings: ignore and fall back to the
+ // process default, which will resolve to Resources.resx.
+ }
+ }
+ }
+}
diff --git a/MultiOtpManager/Core/AppSettings.cs b/MultiOtpManager/Core/AppSettings.cs
new file mode 100644
index 0000000..3f14f0a
--- /dev/null
+++ b/MultiOtpManager/Core/AppSettings.cs
@@ -0,0 +1,133 @@
+using System;
+using System.IO;
+
+namespace MultiOtpManager.Core
+{
+ ///
+ /// Per-user preferences persisted as JSON in %LocalAppData%. The file is
+ /// best-effort: corrupted or unreadable settings silently fall back to
+ /// defaults so a malformed disk file cannot stop the app from starting.
+ /// Serialization is hand-written to keep the project free of any
+ /// external JSON dependency (System.Text.Json is not available on
+ /// .NET Framework 4.5.2 without an extra reference assembly).
+ ///
+ public sealed class AppSettings
+ {
+ ///
+ /// UI culture name (for example "zh-Hans") or empty for system default.
+ /// Looked up via CultureInfo.GetCultureInfo during startup.
+ ///
+ public string Language { get; set; } = string.Empty;
+
+ private static readonly string SettingsDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "MultiOtpManager");
+
+ private static readonly string SettingsPath = Path.Combine(
+ SettingsDirectory, "settings.json");
+
+ public static string SettingsFilePath
+ {
+ get { return SettingsPath; }
+ }
+
+ public static AppSettings Load()
+ {
+ try
+ {
+ if (File.Exists(SettingsPath))
+ {
+ string text = File.ReadAllText(SettingsPath);
+ return Parse(text) ?? new AppSettings();
+ }
+ }
+ catch (Exception)
+ {
+ // Corrupted or unreadable; fall through to defaults.
+ }
+ return new AppSettings();
+ }
+
+ public void Save()
+ {
+ try
+ {
+ Directory.CreateDirectory(SettingsDirectory);
+ File.WriteAllText(SettingsPath, Serialize());
+ }
+ catch (Exception)
+ {
+ // Best-effort persistence; ignore write errors so a read-only
+ // profile does not break the rest of the app.
+ }
+ }
+
+ private string Serialize()
+ {
+ // Hand-rolled JSON keeps the file readable while avoiding any
+ // external JSON dependency. Language is the only field, so a
+ // constant template is sufficient.
+ return "{\n \"Language\": \"" + EscapeJsonString(Language ?? string.Empty) + "\"\n}\n";
+ }
+
+ private static AppSettings Parse(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return null;
+ }
+
+ AppSettings settings = new AppSettings();
+ int searchFrom = 0;
+ while (searchFrom < text.Length)
+ {
+ int keyStart = text.IndexOf('"', searchFrom);
+ if (keyStart < 0)
+ {
+ break;
+ }
+ int keyEnd = text.IndexOf('"', keyStart + 1);
+ if (keyEnd < 0)
+ {
+ break;
+ }
+ string key = text.Substring(keyStart + 1, keyEnd - keyStart - 1);
+
+ int colon = text.IndexOf(':', keyEnd);
+ if (colon < 0)
+ {
+ break;
+ }
+ int valueStart = text.IndexOf('"', colon);
+ if (valueStart < 0)
+ {
+ break;
+ }
+ int valueEnd = text.IndexOf('"', valueStart + 1);
+ if (valueEnd < 0)
+ {
+ break;
+ }
+ string value = UnescapeJsonString(
+ text.Substring(valueStart + 1, valueEnd - valueStart - 1));
+
+ if (key == "Language")
+ {
+ settings.Language = value;
+ }
+ searchFrom = valueEnd + 1;
+ }
+ return settings;
+ }
+
+ private static string EscapeJsonString(string value)
+ {
+ return value.Replace("\\", "\\\\").Replace("\"", "\\\"");
+ }
+
+ private static string UnescapeJsonString(string value)
+ {
+ return value.Replace("\\\"", "\"").Replace("\\\\", "\\");
+ }
+ }
+}
diff --git a/MultiOtpManager/Core/CredentialProviderRegistryService.cs b/MultiOtpManager/Core/CredentialProviderRegistryService.cs
new file mode 100644
index 0000000..da2062f
--- /dev/null
+++ b/MultiOtpManager/Core/CredentialProviderRegistryService.cs
@@ -0,0 +1,127 @@
+using Microsoft.Win32;
+using System;
+
+namespace MultiOtpManager.Core
+{
+ public sealed class CredentialProviderSettings
+ {
+ public string LogonMode { get; set; }
+ public string UnlockMode { get; set; }
+ public bool TwoStepHideOtp { get; set; }
+ }
+
+ public sealed class CredentialProviderRegistryService
+ {
+ private const string RegistryPath = "CLSID\\{FCEFDFAB-B0A1-4C4D-8B2B-4FF4E0A3D978}";
+
+ public CredentialProviderSettings Load()
+ {
+ using (RegistryKey classesRoot = OpenClassesRoot())
+ using (RegistryKey key = classesRoot.OpenSubKey(RegistryPath))
+ {
+ if (key == null)
+ {
+ return new CredentialProviderSettings
+ {
+ LogonMode = "3d",
+ UnlockMode = "3d",
+ TwoStepHideOtp = false
+ };
+ }
+
+ return new CredentialProviderSettings
+ {
+ LogonMode = ReadString(key, "cpus_logon", "3d"),
+ UnlockMode = ReadString(key, "cpus_unlock", "3d"),
+ TwoStepHideOtp = ReadInteger(key, "two_step_hide_otp", 0) != 0
+ };
+ }
+ }
+
+ public void Save(CredentialProviderSettings settings)
+ {
+ if (settings == null)
+ {
+ throw new ArgumentNullException("settings");
+ }
+
+ string logonMode = NormalizeScenario(settings.LogonMode, "3d");
+ string unlockMode = NormalizeScenario(settings.UnlockMode, "3d");
+
+ using (RegistryKey classesRoot = OpenClassesRoot())
+ using (RegistryKey key = classesRoot.CreateSubKey(RegistryPath))
+ {
+ key.SetValue("cpus_logon", logonMode, RegistryValueKind.String);
+ key.SetValue("cpus_unlock", unlockMode, RegistryValueKind.String);
+ key.SetValue("two_step_hide_otp", settings.TwoStepHideOtp ? "1" : "0", RegistryValueKind.String);
+ }
+ }
+
+ private static RegistryKey OpenClassesRoot()
+ {
+ RegistryView view = Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Default;
+ return RegistryKey.OpenBaseKey(RegistryHive.ClassesRoot, view);
+ }
+
+ private static string ReadString(RegistryKey key, string name, string fallback)
+ {
+ object value = key.GetValue(name);
+ if (value == null)
+ {
+ return fallback;
+ }
+
+ string text = Convert.ToString(value);
+ return string.IsNullOrWhiteSpace(text) ? fallback : text;
+ }
+
+ private static int ReadInteger(RegistryKey key, string name, int fallback)
+ {
+ object value = key.GetValue(name);
+ if (value == null)
+ {
+ return fallback;
+ }
+
+ try
+ {
+ return Convert.ToInt32(value);
+ }
+ catch (FormatException)
+ {
+ return fallback;
+ }
+ catch (InvalidCastException)
+ {
+ return fallback;
+ }
+ catch (OverflowException)
+ {
+ return fallback;
+ }
+ }
+
+ private static string NormalizeScenario(string value, string fallback)
+ {
+ string text = (value ?? string.Empty).Trim();
+ if (text.Length == 0)
+ {
+ text = fallback;
+ }
+
+ char scope = text[0];
+ if (scope != '0' && scope != '1' && scope != '2' && scope != '3')
+ {
+ scope = fallback[0];
+ }
+
+ char availability = text.Length > 1 ? char.ToLowerInvariant(text[text.Length - 1]) : 'd';
+ if (availability != 'e' && availability != 'd')
+ {
+ availability = 'd';
+ }
+
+ return string.Concat(scope.ToString(), availability.ToString());
+ }
+ }
+}
diff --git a/MultiOtpManager/Core/MultiOtpCliClient.cs b/MultiOtpManager/Core/MultiOtpCliClient.cs
new file mode 100644
index 0000000..0a62b6a
--- /dev/null
+++ b/MultiOtpManager/Core/MultiOtpCliClient.cs
@@ -0,0 +1,265 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace MultiOtpManager.Core
+{
+ public sealed class MultiOtpCliClient
+ {
+ private readonly MultiOtpProcessExecutor executor;
+
+ public MultiOtpCliClient()
+ {
+ string baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
+ string executablePath = Path.Combine(baseDirectory, "multiotp.exe");
+ executor = new MultiOtpProcessExecutor(executablePath);
+ }
+
+ public string ExecutablePath
+ {
+ get { return executor.ExecutablePath; }
+ }
+
+ public bool UseVerifySwitch { get; set; }
+
+ // --- Authentication ---
+
+ public Task VerifyAsync(string username, string otp, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ ValidateRequiredText(otp, "OTP");
+ List args = new List();
+ if (UseVerifySwitch) args.Add("-verify");
+ args.Add(username);
+ args.Add(otp);
+ return executor.ExecuteAsync(args, timeout, ct);
+ }
+
+ // --- User Management ---
+
+ public Task GetUsersAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-userslist" }, timeout, ct);
+ }
+
+ public Task GetUserAsync(string username, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ return executor.ExecuteAsync(new List { "-user-info", username }, timeout, ct);
+ }
+
+ public Task CreateUserAsync(string username, string algorithm, string seed, string pin, string digits, string interval, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ List args = new List { "-create", username };
+ if (!string.IsNullOrWhiteSpace(algorithm)) args.Add(algorithm);
+ if (!string.IsNullOrWhiteSpace(seed)) args.Add(seed);
+ if (!string.IsNullOrWhiteSpace(pin)) args.Add(pin);
+ if (!string.IsNullOrWhiteSpace(digits)) args.Add(digits);
+ if (!string.IsNullOrWhiteSpace(interval)) args.Add(interval);
+ return executor.ExecuteAsync(args, timeout, ct);
+ }
+
+ public Task FastCreateUserAsync(string username, string pin, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ List args = new List { "-fastcreate", username };
+ if (!string.IsNullOrWhiteSpace(pin)) args.Add(pin);
+ return executor.ExecuteAsync(args, timeout, ct);
+ }
+
+ public Task FastCreateNoPinUserAsync(string username, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ return executor.ExecuteAsync(new List { "-fastcreatenopin", username }, timeout, ct);
+ }
+
+ public Task CreateGoogleAuthUserAsync(string username, string base32Seed, string pin, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ ValidateRequiredText(base32Seed, "base32 seed");
+ List args = new List { "-createga", username, base32Seed };
+ if (!string.IsNullOrWhiteSpace(pin)) args.Add(pin);
+ return executor.ExecuteAsync(args, timeout, ct);
+ }
+
+ public Task DeleteUserAsync(string username, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ return executor.ExecuteAsync(new List { "-delete", username }, timeout, ct);
+ }
+
+ public Task ActivateUserAsync(string username, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ return executor.ExecuteAsync(new List { "-activate", username }, timeout, ct);
+ }
+
+ public Task DeactivateUserAsync(string username, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ return executor.ExecuteAsync(new List { "-deactivate", username }, timeout, ct);
+ }
+
+ public Task LockUserAsync(string username, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ return executor.ExecuteAsync(new List { "-lock", username }, timeout, ct);
+ }
+
+ public Task UnlockUserAsync(string username, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ return executor.ExecuteAsync(new List { "-unlock", username }, timeout, ct);
+ }
+
+ public Task ResyncTokenAsync(string username, string token1, string token2, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ ValidateRequiredText(token1, "token1");
+ ValidateRequiredText(token2, "token2");
+ return executor.ExecuteAsync(new List { "-resync", username, token1, token2 }, timeout, ct);
+ }
+
+ public Task UpdatePinAsync(string username, string pin, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ ValidateRequiredText(pin, "pin");
+ return executor.ExecuteAsync(new List { "-update-pin", username, pin }, timeout, ct);
+ }
+
+ public Task SetUserAttributeAsync(string username, string attribute, string value, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ ValidateRequiredText(attribute, "attribute");
+ return executor.ExecuteAsync(new List { "-set", username, attribute + "=" + value }, timeout, ct);
+ }
+
+ public Task GetLockedUsersAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-lockeduserslist" }, timeout, ct);
+ }
+
+ // --- Provisioning ---
+
+ public Task GetUrlLinkAsync(string username, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ return executor.ExecuteAsync(new List { "-urllink", username }, timeout, ct);
+ }
+
+ public Task CreateQrCodeAsync(string username, string pngFilePath, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ ValidateRequiredText(pngFilePath, "PNG file path");
+ return executor.ExecuteAsync(new List { "-qrcode", username, pngFilePath }, timeout, ct);
+ }
+
+ // --- Token Management ---
+
+ public Task GetTokensAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-tokenslist" }, timeout, ct);
+ }
+
+ public Task AssignTokenAsync(string username, string tokenId, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ ValidateRequiredText(tokenId, "token ID");
+ return executor.ExecuteAsync(new List { "-assign-token", username, tokenId }, timeout, ct);
+ }
+
+ public Task RemoveTokenAsync(string username, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ return executor.ExecuteAsync(new List { "-remove-token", username }, timeout, ct);
+ }
+
+ public Task DeleteTokenAsync(string tokenId, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(tokenId, "token ID");
+ return executor.ExecuteAsync(new List { "-delete-token", tokenId }, timeout, ct);
+ }
+
+ // --- Logs & Diagnostics ---
+
+ public Task ShowLogAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-showlog" }, timeout, ct);
+ }
+
+ public Task ClearLogAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-clearlog" }, timeout, ct);
+ }
+
+ public Task GetErrorCodesAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-error-codes" }, timeout, ct);
+ }
+
+ public Task GetVersionAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-version" }, timeout, ct);
+ }
+
+ // --- AD/LDAP ---
+
+ public Task LdapCheckAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-ldap-check" }, timeout, ct);
+ }
+
+ public Task LdapUsersListAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-ldap-users-list" }, timeout, ct);
+ }
+
+ public Task LdapUsersSyncAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-ldap-users-sync" }, timeout, ct);
+ }
+
+ public Task LdapUserInfoAsync(string username, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(username, "username");
+ return executor.ExecuteAsync(new List { "-ldap-user-info", username }, timeout, ct);
+ }
+
+ // --- Backup & Maintenance ---
+
+ public Task BackupConfigAsync(string password, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(password, "password");
+ return executor.ExecuteAsync(new List { "-backup-config", password }, timeout, ct);
+ }
+
+ public Task RestoreConfigAsync(string password, TimeSpan timeout, CancellationToken ct)
+ {
+ ValidateRequiredText(password, "password");
+ return executor.ExecuteAsync(new List { "-restore-config", password }, timeout, ct);
+ }
+
+ public Task PurgeLockFolderAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-purge-lock-folder" }, timeout, ct);
+ }
+
+ public Task PurgeLdapCacheAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ return executor.ExecuteAsync(new List { "-purge-ldap-cache-folder" }, timeout, ct);
+ }
+
+ // --- Helpers ---
+
+ private static void ValidateRequiredText(string value, string fieldName)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException(fieldName + " is required.", fieldName);
+ }
+ }
+ }
+}
diff --git a/MultiOtpManager/Core/MultiOtpProcessExecutor.cs b/MultiOtpManager/Core/MultiOtpProcessExecutor.cs
new file mode 100644
index 0000000..0e913a4
--- /dev/null
+++ b/MultiOtpManager/Core/MultiOtpProcessExecutor.cs
@@ -0,0 +1,262 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace MultiOtpManager.Core
+{
+ public sealed class ProcessRunResult
+ {
+ public int ExitCode { get; set; }
+ public string StandardOutput { get; set; }
+ public string StandardError { get; set; }
+ }
+
+ public sealed class MultiOtpProcessExecutor
+ {
+ private const int FlushTimeoutMilliseconds = 2000;
+
+ public MultiOtpProcessExecutor(string executablePath)
+ {
+ if (string.IsNullOrWhiteSpace(executablePath))
+ {
+ throw new ArgumentException("The executable path is required.", "executablePath");
+ }
+
+ ExecutablePath = Path.GetFullPath(executablePath);
+ }
+
+ public string ExecutablePath { get; private set; }
+
+ public static string BuildArguments(IEnumerable arguments)
+ {
+ if (arguments == null)
+ {
+ throw new ArgumentNullException("arguments");
+ }
+
+ string[] escapedArguments = arguments
+ .Select(EscapeArgument)
+ .ToArray();
+
+ return string.Join(" ", escapedArguments);
+ }
+
+ public static string EscapeArgument(string argument)
+ {
+ if (argument == null)
+ {
+ throw new ArgumentNullException("argument");
+ }
+
+ if (argument.Length == 0)
+ {
+ return "\"\"";
+ }
+
+ StringBuilder escaped = new StringBuilder(argument.Length + 8);
+ int trailingBackslashes = 0;
+
+ foreach (char character in argument)
+ {
+ if (character == '\\')
+ {
+ trailingBackslashes++;
+ continue;
+ }
+
+ if (character == '"')
+ {
+ escaped.Append('\\', (trailingBackslashes * 2) + 1);
+ escaped.Append('"');
+ }
+ else
+ {
+ escaped.Append('\\', trailingBackslashes);
+ escaped.Append(character);
+ }
+
+ trailingBackslashes = 0;
+ }
+
+ if (trailingBackslashes > 0)
+ {
+ escaped.Append('\\', trailingBackslashes * 2);
+ }
+
+ escaped.Insert(0, '"');
+ escaped.Append('"');
+ return escaped.ToString();
+ }
+
+ public async Task ExecuteAsync(
+ IList arguments,
+ TimeSpan timeout,
+ CancellationToken cancellationToken)
+ {
+ if (arguments == null)
+ {
+ throw new ArgumentNullException("arguments");
+ }
+
+ if (!File.Exists(ExecutablePath))
+ {
+ throw new FileNotFoundException("multiotp.exe was not found beside MultiOtpManager.exe.", ExecutablePath);
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ ProcessStartInfo startInfo = new ProcessStartInfo();
+ startInfo.FileName = ExecutablePath;
+ startInfo.Arguments = BuildArguments(arguments);
+ startInfo.WorkingDirectory = Path.GetDirectoryName(ExecutablePath);
+ startInfo.UseShellExecute = false;
+ startInfo.CreateNoWindow = true;
+ startInfo.RedirectStandardOutput = true;
+ startInfo.RedirectStandardError = true;
+ startInfo.StandardOutputEncoding = Encoding.UTF8;
+ startInfo.StandardErrorEncoding = Encoding.UTF8;
+
+ using (Process process = new Process())
+ using (CancellationTokenSource timeoutSource = new CancellationTokenSource())
+ using (CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ timeoutSource.Token))
+ {
+ process.StartInfo = startInfo;
+ process.EnableRaisingEvents = true;
+
+ StringBuilder standardOutput = new StringBuilder();
+ StringBuilder standardError = new StringBuilder();
+ object outputLock = new object();
+ TaskCompletionSource