From 6dc61d5fdae2722a95e0ff5e9e8762e9a3ea87ee Mon Sep 17 00:00:00 2001
From: netoyou <2671501@qq.com>
Date: Wed, 26 Aug 2026 20:33:42 +0800
Subject: [PATCH 01/16] Add Windows CI and WPF management client
---
.github/workflows/windows-build.yml | 94 ++++
MultiOtpManager/App.config | 6 +
MultiOtpManager/App.xaml | 5 +
MultiOtpManager/App.xaml.cs | 8 +
.../Core/CredentialProviderRegistryService.cs | 127 +++++
MultiOtpManager/Core/MultiOtpCliClient.cs | 75 +++
.../Core/MultiOtpProcessExecutor.cs | 262 ++++++++++
MultiOtpManager/Core/UserModels.cs | 138 ++++++
MultiOtpManager/MainWindow.xaml | 465 ++++++++++++++++++
MultiOtpManager/MainWindow.xaml.cs | 427 ++++++++++++++++
MultiOtpManager/MultiOtpManager.csproj | 73 +++
MultiOtpManager/Properties/AssemblyInfo.cs | 10 +
MultiOtpManager/README.md | 28 ++
MultiOtpManager/app.manifest | 23 +
14 files changed, 1741 insertions(+)
create mode 100644 .github/workflows/windows-build.yml
create mode 100644 MultiOtpManager/App.config
create mode 100644 MultiOtpManager/App.xaml
create mode 100644 MultiOtpManager/App.xaml.cs
create mode 100644 MultiOtpManager/Core/CredentialProviderRegistryService.cs
create mode 100644 MultiOtpManager/Core/MultiOtpCliClient.cs
create mode 100644 MultiOtpManager/Core/MultiOtpProcessExecutor.cs
create mode 100644 MultiOtpManager/Core/UserModels.cs
create mode 100644 MultiOtpManager/MainWindow.xaml
create mode 100644 MultiOtpManager/MainWindow.xaml.cs
create mode 100644 MultiOtpManager/MultiOtpManager.csproj
create mode 100644 MultiOtpManager/Properties/AssemblyInfo.cs
create mode 100644 MultiOtpManager/README.md
create mode 100644 MultiOtpManager/app.manifest
diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml
new file mode 100644
index 0000000..027a177
--- /dev/null
+++ b/.github/workflows/windows-build.yml
@@ -0,0 +1,94 @@
+name: Windows build
+
+on:
+ push:
+ branches:
+ - master
+ - main
+ 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..7cdd324
--- /dev/null
+++ b/MultiOtpManager/App.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows;
+
+namespace MultiOtpManager
+{
+ public partial class App : Application
+ {
+ }
+}
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..93470d0
--- /dev/null
+++ b/MultiOtpManager/Core/MultiOtpCliClient.cs
@@ -0,0 +1,75 @@
+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; }
+
+ public Task VerifyAsync(
+ string username,
+ string otp,
+ TimeSpan timeout,
+ CancellationToken cancellationToken)
+ {
+ ValidateRequiredText(username, "username");
+ ValidateRequiredText(otp, "OTP");
+
+ List arguments = new List();
+ if (UseVerifySwitch)
+ {
+ arguments.Add("-verify");
+ }
+
+ arguments.Add(username);
+ arguments.Add(otp);
+ return executor.ExecuteAsync(arguments, timeout, cancellationToken);
+ }
+
+ public Task GetUsersAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ List arguments = new List();
+ arguments.Add("-userslist");
+ return executor.ExecuteAsync(arguments, timeout, cancellationToken);
+ }
+
+ public Task GetUserAsync(
+ string username,
+ TimeSpan timeout,
+ CancellationToken cancellationToken)
+ {
+ ValidateRequiredText(username, "username");
+
+ List arguments = new List();
+ arguments.Add("-user-info");
+ arguments.Add(username);
+ return executor.ExecuteAsync(arguments, timeout, cancellationToken);
+ }
+
+ 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..c9d4ff3
--- /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