diff --git a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/App.axaml.cs b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/App.axaml.cs
index 5a591f7..bcbea0e 100644
--- a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/App.axaml.cs
+++ b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/App.axaml.cs
@@ -5,6 +5,7 @@ using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModels;
using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Views;
+using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Services;
using Serilog;
using System;
using System.Linq;
@@ -15,6 +16,7 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance
public partial class App : Application
{
private static bool exceptionHandlersRegistered;
+ private readonly MachineLicenseService licenseService = new();
public override void Initialize()
{
@@ -27,15 +29,51 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
- desktop.MainWindow = new MainWindow
+ var check = licenseService.Check(updateHeartbeat: true);
+ if (check.CanUseSoftware)
{
- DataContext = new MainWindowViewModel(),
- };
+ ShowMainWindow(desktop, showImmediately: false);
+ }
+ else
+ {
+ var mode = check.State switch
+ {
+ Models.LicenseCheckState.NotInitialized => LicenseWindowMode.Initialization,
+ Models.LicenseCheckState.Expired => LicenseWindowMode.Unlock,
+ _ => LicenseWindowMode.Blocked
+ };
+ var licenseWindow = new LicenseWindow(licenseService, mode, check.Message);
+ desktop.MainWindow = licenseWindow;
+ licenseWindow.Closed += (_, _) =>
+ {
+ if (licenseWindow.Succeeded)
+ {
+ ShowMainWindow(desktop, showImmediately: true);
+ }
+ else
+ {
+ desktop.Shutdown();
+ }
+ };
+ }
}
base.OnFrameworkInitializationCompleted();
}
+ private void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately)
+ {
+ var viewModel = new MainWindowViewModel(licenseService);
+ desktop.MainWindow = new MainWindow(licenseService)
+ {
+ DataContext = viewModel,
+ };
+ if (showImmediately)
+ {
+ desktop.MainWindow.Show();
+ }
+ }
+
private static void RegisterExceptionHandlers()
{
if (exceptionHandlersRegistered)
diff --git a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Footwear Test methodsfor wholeshoe Slipresistanceperformance.csproj b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Footwear Test methodsfor wholeshoe Slipresistanceperformance.csproj
index b79c0d4..cc3afc4 100644
--- a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Footwear Test methodsfor wholeshoe Slipresistanceperformance.csproj
+++ b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Footwear Test methodsfor wholeshoe Slipresistanceperformance.csproj
@@ -28,6 +28,7 @@
+
diff --git a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Models/LicenseModels.cs b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Models/LicenseModels.cs
new file mode 100644
index 0000000..bd6bdfc
--- /dev/null
+++ b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Models/LicenseModels.cs
@@ -0,0 +1,42 @@
+using System;
+
+namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Models
+{
+ public enum LicenseStage
+ {
+ FirstPeriod,
+ SecondPeriod,
+ Permanent
+ }
+
+ public enum LicenseCheckState
+ {
+ NotInitialized,
+ Valid,
+ Expired,
+ Tampered
+ }
+
+ public sealed record PasswordSecret(string Salt, string Hash);
+
+ public sealed record LicenseData(
+ string InstallId,
+ LicenseStage Stage,
+ DateTime StageStartedUtc,
+ DateTime LastTrustedUtc,
+ int FirstPeriodMonths,
+ int SecondPeriodMonths,
+ PasswordSecret AdminPassword,
+ PasswordSecret FirstUnlockPassword,
+ PasswordSecret SecondUnlockPassword);
+
+ public sealed record LicenseCheckResult(
+ LicenseCheckState State,
+ LicenseStage Stage,
+ DateTime? ExpiresUtc,
+ string Message)
+ {
+ public bool CanUseSoftware => State == LicenseCheckState.Valid;
+ public bool RequiresUnlock => State == LicenseCheckState.Expired;
+ }
+}
diff --git a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Program.cs b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Program.cs
index 104b674..fda3e79 100644
--- a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Program.cs
+++ b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Program.cs
@@ -1,4 +1,5 @@
using Avalonia;
+using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Services;
using Serilog;
using System;
using System.IO;
@@ -14,6 +15,11 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance
try
{
+ if (args.Length == 2 && string.Equals(args[0], MachineLicenseService.InstallArgument, StringComparison.Ordinal))
+ {
+ return MachineLicenseService.RunElevatedInstall(args[1]);
+ }
+
Log.Information("鞋类整鞋防滑性能试验程序启动");
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return 0;
diff --git a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Services/MachineLicenseService.cs b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Services/MachineLicenseService.cs
new file mode 100644
index 0000000..94867f5
--- /dev/null
+++ b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Services/MachineLicenseService.cs
@@ -0,0 +1,446 @@
+using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Models;
+using Microsoft.Win32;
+using Serilog;
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Security.Principal;
+using System.Text.Json;
+
+#pragma warning disable CA1416
+
+namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Services
+{
+ public sealed class MachineLicenseService
+ {
+ public const string InstallArgument = "--install-license";
+ private const string RegistryPath = @"SOFTWARE\FootwearSlipResistance\License";
+ private const string RegistryInstallIdName = "InstallId";
+ private static readonly TimeSpan ClockRollbackTolerance = TimeSpan.FromMinutes(5);
+ private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromMinutes(10);
+ private static readonly byte[] AdditionalEntropy = "FootwearSlipResistance-License-v1"u8.ToArray();
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ private LicenseData? current;
+
+ public LicenseData? Current => current;
+
+ public static string LicenseFilePath =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "FootwearSlipResistance",
+ "license.dat");
+
+ public LicenseCheckResult Check(bool updateHeartbeat = false)
+ {
+ var loadResult = TryLoad(out var data);
+ if (loadResult is not null)
+ {
+ current = null;
+ return loadResult;
+ }
+
+ current = data;
+ var now = DateTime.UtcNow;
+ if (now + ClockRollbackTolerance < data!.LastTrustedUtc)
+ {
+ return new LicenseCheckResult(
+ LicenseCheckState.Tampered,
+ data.Stage,
+ GetExpiry(data),
+ "检测到系统时间回退,软件已锁定,请联系管理员处理。");
+ }
+
+ if (updateHeartbeat && now - data.LastTrustedUtc >= HeartbeatInterval)
+ {
+ try
+ {
+ current = data with { LastTrustedUtc = now };
+ SaveCurrent();
+ data = current;
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "更新授权可信时间失败");
+ return new LicenseCheckResult(
+ LicenseCheckState.Tampered,
+ data.Stage,
+ GetExpiry(data),
+ "授权状态无法安全更新,软件已锁定,请联系管理员处理。");
+ }
+ }
+
+ var expiry = GetExpiry(data);
+ if (expiry.HasValue && now >= expiry.Value)
+ {
+ return new LicenseCheckResult(
+ LicenseCheckState.Expired,
+ data.Stage,
+ expiry,
+ data.Stage == LicenseStage.FirstPeriod
+ ? "第一阶段使用时效已到,请输入第一次时效密码。"
+ : "第二阶段使用时效已到,请输入第二次时效密码。");
+ }
+
+ return new LicenseCheckResult(
+ LicenseCheckState.Valid,
+ data.Stage,
+ expiry,
+ data.Stage == LicenseStage.Permanent ? "软件已永久授权。" : "软件授权有效。");
+ }
+
+ public void Initialize(
+ string adminPassword,
+ string firstUnlockPassword,
+ string secondUnlockPassword,
+ int firstPeriodMonths,
+ int secondPeriodMonths)
+ {
+ ValidateSettings(adminPassword, firstUnlockPassword, secondUnlockPassword, firstPeriodMonths, secondPeriodMonths);
+ var now = DateTime.UtcNow;
+ var data = new LicenseData(
+ Guid.NewGuid().ToString("N"),
+ LicenseStage.FirstPeriod,
+ now,
+ now,
+ firstPeriodMonths,
+ secondPeriodMonths,
+ CreateSecret(adminPassword),
+ CreateSecret(firstUnlockPassword),
+ CreateSecret(secondUnlockPassword));
+
+ if (IsAdministrator())
+ {
+ InstallInitialLicense(data);
+ current = data;
+ return;
+ }
+
+ var tempPath = Path.Combine(Path.GetTempPath(), $"footwear-license-{Guid.NewGuid():N}.dat");
+ try
+ {
+ File.WriteAllBytes(tempPath, Protect(data));
+ var executable = Environment.ProcessPath ?? throw new InvalidOperationException("无法确定当前程序路径。");
+ using var process = Process.Start(new ProcessStartInfo
+ {
+ FileName = executable,
+ Arguments = $"{InstallArgument} \"{tempPath}\"",
+ UseShellExecute = true,
+ Verb = "runas"
+ }) ?? throw new InvalidOperationException("无法启动授权初始化程序。");
+ process.WaitForExit();
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException("授权初始化未完成或管理员权限确认被取消。");
+ }
+
+ var result = Check();
+ if (!result.CanUseSoftware)
+ {
+ throw new InvalidOperationException(result.Message);
+ }
+ }
+ finally
+ {
+ TryDelete(tempPath);
+ }
+ }
+
+ public static int RunElevatedInstall(string packagePath)
+ {
+ try
+ {
+ if (!IsAdministrator())
+ {
+ return 2;
+ }
+
+ var bytes = File.ReadAllBytes(packagePath);
+ var data = Unprotect(bytes);
+ InstallInitialLicense(data);
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "整机授权初始化失败");
+ return 1;
+ }
+ }
+
+ public bool VerifyAdminPassword(string password) =>
+ EnsureCurrent() && VerifySecret(password, current!.AdminPassword);
+
+ public bool UnlockCurrentStage(string password)
+ {
+ if (!EnsureCurrent() || current!.Stage == LicenseStage.Permanent)
+ {
+ return current?.Stage == LicenseStage.Permanent;
+ }
+
+ var secret = current.Stage == LicenseStage.FirstPeriod
+ ? current.FirstUnlockPassword
+ : current.SecondUnlockPassword;
+ if (!VerifySecret(password, secret))
+ {
+ return false;
+ }
+
+ var now = DateTime.UtcNow;
+ current = current.Stage == LicenseStage.FirstPeriod
+ ? current with { Stage = LicenseStage.SecondPeriod, StageStartedUtc = now, LastTrustedUtc = now }
+ : current with { Stage = LicenseStage.Permanent, StageStartedUtc = now, LastTrustedUtc = now };
+ SaveCurrent();
+ return true;
+ }
+
+ public void UpdateSettings(
+ string? newAdminPassword,
+ string? newFirstUnlockPassword,
+ string? newSecondUnlockPassword,
+ int firstPeriodMonths,
+ int secondPeriodMonths)
+ {
+ if (!EnsureCurrent())
+ {
+ throw new InvalidOperationException("授权状态未加载。");
+ }
+
+ ValidateMonths(firstPeriodMonths, secondPeriodMonths);
+ current = current! with
+ {
+ FirstPeriodMonths = firstPeriodMonths,
+ SecondPeriodMonths = secondPeriodMonths,
+ AdminPassword = string.IsNullOrWhiteSpace(newAdminPassword) ? current.AdminPassword : CreateSecret(newAdminPassword),
+ FirstUnlockPassword = string.IsNullOrWhiteSpace(newFirstUnlockPassword) ? current.FirstUnlockPassword : CreateSecret(newFirstUnlockPassword),
+ SecondUnlockPassword = string.IsNullOrWhiteSpace(newSecondUnlockPassword) ? current.SecondUnlockPassword : CreateSecret(newSecondUnlockPassword),
+ LastTrustedUtc = DateTime.UtcNow
+ };
+ SaveCurrent();
+ }
+
+ public void RestartTiming()
+ {
+ if (!EnsureCurrent())
+ {
+ throw new InvalidOperationException("授权状态未加载。");
+ }
+
+ var now = DateTime.UtcNow;
+ current = current! with
+ {
+ Stage = LicenseStage.FirstPeriod,
+ StageStartedUtc = now,
+ LastTrustedUtc = now
+ };
+ SaveCurrent();
+ }
+
+ public string DescribeCurrent()
+ {
+ if (!EnsureCurrent())
+ {
+ return "授权状态不可用";
+ }
+
+ var expiry = GetExpiry(current!);
+ var stageText = current!.Stage switch
+ {
+ LicenseStage.FirstPeriod => "第一阶段",
+ LicenseStage.SecondPeriod => "第二阶段",
+ _ => "永久授权"
+ };
+ return expiry.HasValue
+ ? $"{stageText};开始:{current.StageStartedUtc.ToLocalTime():yyyy-MM-dd HH:mm};到期:{expiry.Value.ToLocalTime():yyyy-MM-dd HH:mm}"
+ : stageText;
+ }
+
+ private LicenseCheckResult? TryLoad(out LicenseData? data)
+ {
+ data = null;
+ var registryInstallId = ReadRegistryInstallId();
+ var fileExists = File.Exists(LicenseFilePath);
+ if (!fileExists && string.IsNullOrWhiteSpace(registryInstallId))
+ {
+ return new LicenseCheckResult(LicenseCheckState.NotInitialized, LicenseStage.FirstPeriod, null, "首次使用,请先完成时效授权初始化。");
+ }
+
+ if (!fileExists || string.IsNullOrWhiteSpace(registryInstallId))
+ {
+ return new LicenseCheckResult(LicenseCheckState.Tampered, LicenseStage.FirstPeriod, null, "授权文件或整机安装标记缺失,软件已锁定。");
+ }
+
+ try
+ {
+ data = Unprotect(File.ReadAllBytes(LicenseFilePath));
+ if (!string.Equals(data.InstallId, registryInstallId, StringComparison.Ordinal))
+ {
+ data = null;
+ return new LicenseCheckResult(LicenseCheckState.Tampered, LicenseStage.FirstPeriod, null, "授权文件与整机安装标记不匹配,软件已锁定。");
+ }
+
+ return null;
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "读取整机授权失败:Path={Path}", LicenseFilePath);
+ return new LicenseCheckResult(LicenseCheckState.Tampered, LicenseStage.FirstPeriod, null, "授权文件损坏或无法读取,软件已锁定。");
+ }
+ }
+
+ private bool EnsureCurrent()
+ {
+ if (current is not null)
+ {
+ return true;
+ }
+
+ var result = Check();
+ return result.State is LicenseCheckState.Valid or LicenseCheckState.Expired;
+ }
+
+ private void SaveCurrent()
+ {
+ if (current is null)
+ {
+ return;
+ }
+
+ var directory = Path.GetDirectoryName(LicenseFilePath)!;
+ Directory.CreateDirectory(directory);
+ var tempPath = Path.Combine(directory, $"license-{Guid.NewGuid():N}.tmp");
+ File.WriteAllBytes(tempPath, Protect(current));
+ File.Move(tempPath, LicenseFilePath, true);
+ }
+
+ private static void InstallInitialLicense(LicenseData data)
+ {
+ var directory = Path.GetDirectoryName(LicenseFilePath)!;
+ Directory.CreateDirectory(directory);
+ File.WriteAllBytes(LicenseFilePath, Protect(data));
+ using var key = Registry.LocalMachine.CreateSubKey(RegistryPath, true)
+ ?? throw new InvalidOperationException("无法创建整机授权注册表标记。");
+ key.SetValue(RegistryInstallIdName, data.InstallId, RegistryValueKind.String);
+
+ if (OperatingSystem.IsWindows())
+ {
+ using var permissions = Process.Start(new ProcessStartInfo
+ {
+ FileName = "icacls.exe",
+ Arguments = $"\"{directory}\" /grant *S-1-5-32-545:(OI)(CI)M /T /C",
+ UseShellExecute = false,
+ CreateNoWindow = true
+ });
+ permissions?.WaitForExit();
+ }
+ }
+
+ private static byte[] Protect(LicenseData data) =>
+ ProtectedData.Protect(
+ JsonSerializer.SerializeToUtf8Bytes(data, JsonOptions),
+ AdditionalEntropy,
+ DataProtectionScope.LocalMachine);
+
+ private static LicenseData Unprotect(byte[] protectedBytes) =>
+ JsonSerializer.Deserialize(
+ ProtectedData.Unprotect(protectedBytes, AdditionalEntropy, DataProtectionScope.LocalMachine))
+ ?? throw new InvalidDataException("授权数据为空。");
+
+ private static string? ReadRegistryInstallId()
+ {
+ try
+ {
+ using var key = Registry.LocalMachine.OpenSubKey(RegistryPath, false);
+ return key?.GetValue(RegistryInstallIdName) as string;
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "读取整机授权注册表标记失败");
+ return null;
+ }
+ }
+
+ private static PasswordSecret CreateSecret(string password)
+ {
+ ValidatePassword(password, "密码");
+ var salt = RandomNumberGenerator.GetBytes(16);
+ var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100_000, HashAlgorithmName.SHA256, 32);
+ return new PasswordSecret(Convert.ToBase64String(salt), Convert.ToBase64String(hash));
+ }
+
+ private static bool VerifySecret(string password, PasswordSecret secret)
+ {
+ try
+ {
+ var salt = Convert.FromBase64String(secret.Salt);
+ var expected = Convert.FromBase64String(secret.Hash);
+ var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100_000, HashAlgorithmName.SHA256, expected.Length);
+ return CryptographicOperations.FixedTimeEquals(actual, expected);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static DateTime? GetExpiry(LicenseData data) =>
+ data.Stage switch
+ {
+ LicenseStage.FirstPeriod => data.StageStartedUtc.AddMonths(data.FirstPeriodMonths),
+ LicenseStage.SecondPeriod => data.StageStartedUtc.AddMonths(data.SecondPeriodMonths),
+ _ => null
+ };
+
+ private static void ValidateSettings(string admin, string first, string second, int firstMonths, int secondMonths)
+ {
+ ValidatePassword(admin, "管理密码");
+ ValidatePassword(first, "第一次时效密码");
+ ValidatePassword(second, "第二次时效密码");
+ ValidateMonths(firstMonths, secondMonths);
+ }
+
+ private static void ValidatePassword(string password, string label)
+ {
+ if (string.IsNullOrWhiteSpace(password) || password.Length is < 4 or > 64)
+ {
+ throw new ArgumentException($"{label}长度必须为 4-64 个字符。");
+ }
+ }
+
+ private static void ValidateMonths(int firstMonths, int secondMonths)
+ {
+ if (firstMonths is < 1 or > 120 || secondMonths is < 1 or > 120)
+ {
+ throw new ArgumentException("两段时效必须为 1-120 个自然月。");
+ }
+ }
+
+ private static bool IsAdministrator()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return false;
+ }
+
+ using var identity = WindowsIdentity.GetCurrent();
+ return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
+ }
+
+ private static void TryDelete(string path)
+ {
+ try
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ catch
+ {
+ }
+ }
+ }
+}
+
+#pragma warning restore CA1416
diff --git a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/ViewModels/MainWindowViewModel.cs b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/ViewModels/MainWindowViewModel.cs
index fe7d395..f537701 100644
--- a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/ViewModels/MainWindowViewModel.cs
+++ b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/ViewModels/MainWindowViewModel.cs
@@ -44,9 +44,11 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel
private static readonly TimeSpan ResetButtonPendingTimeout = TimeSpan.FromMilliseconds(800);
private static readonly TimeSpan TestButtonPendingTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan RealtimeCurveTraceInterval = TimeSpan.FromSeconds(1);
+ private static readonly TimeSpan LicenseCheckInterval = TimeSpan.FromMinutes(1);
private readonly SlipResistanceDeviceService deviceService = new();
private readonly SlipExcelExportService excelExportService = new();
+ private readonly MachineLicenseService licenseService;
private readonly DispatcherTimer refreshTimer;
private readonly Stopwatch runStopwatch = new();
private readonly List currentRun = [];
@@ -71,6 +73,12 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel
private double slidingStartDisplacementMm;
private double? slidingStartTimeSeconds;
private int activeRunLubricantIndex;
+ private DateTime lastLicenseCheckAt = DateTime.MinValue;
+ private bool isLicenseLockPending;
+ private bool licenseLockRequestedRaised;
+ private string licenseLockMessage = string.Empty;
+
+ public event Action? LicenseLockRequested;
[ObservableProperty]
private string testNumber = $"SLIP-{DateTime.Now:yyyyMMdd-HHmm}";
@@ -275,8 +283,13 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel
}
];
- public MainWindowViewModel()
+ public MainWindowViewModel() : this(new MachineLicenseService())
{
+ }
+
+ public MainWindowViewModel(MachineLicenseService licenseService)
+ {
+ this.licenseService = licenseService;
Log.Information("初始化主页面 ViewModel");
Series =
[
@@ -294,6 +307,7 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel
refreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(20) };
refreshTimer.Tick += (_, _) => RefreshFromDevice();
refreshTimer.Start();
+ RefreshLicenseState();
}
[RelayCommand]
@@ -325,7 +339,7 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel
[RelayCommand]
private async Task StartTest()
{
- if (!IsTestButtonEnabled)
+ if (!IsTestButtonEnabled || isLicenseLockPending)
{
return;
}
@@ -487,16 +501,16 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel
}
[RelayCommand]
- private Task LiftMotion() => RunDeviceCommand(deviceService.ApplyOldLiftAsync(), "提升按老代码逻辑执行:M4=0, M5=1");
+ private Task LiftMotion() => RunLicensedDeviceCommand(deviceService.ApplyOldLiftAsync, "提升按老代码逻辑执行:M4=0, M5=1");
[RelayCommand]
- private Task LowerMotion() => RunDeviceCommand(deviceService.ApplyOldLowerAsync(), "下降按老代码逻辑执行:M5=0, M4=1");
+ private Task LowerMotion() => RunLicensedDeviceCommand(deviceService.ApplyOldLowerAsync, "下降按老代码逻辑执行:M5=0, M4=1");
[RelayCommand]
- private Task MoveLeftMotion() => RunDeviceCommand(deviceService.ToggleOldMoveLeftAsync(), "左移按老代码逻辑切换 M1");
+ private Task MoveLeftMotion() => RunLicensedDeviceCommand(deviceService.ToggleOldMoveLeftAsync, "左移按老代码逻辑切换 M1");
[RelayCommand]
- private Task MoveRightMotion() => RunDeviceCommand(deviceService.ToggleOldMoveRightAsync(), "右移按老代码逻辑切换 M2");
+ private Task MoveRightMotion() => RunLicensedDeviceCommand(deviceService.ToggleOldMoveRightAsync, "右移按老代码逻辑切换 M2");
public Task StopAllMotionAsync() => RunDeviceCommand(deviceService.StopAllMotionAsync(), "全部运动已停止");
@@ -624,9 +638,24 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel
deviceService.Dispose();
}
+ public void RefreshLicenseState()
+ {
+ var check = licenseService.Check(updateHeartbeat: true);
+ lastLicenseCheckAt = DateTime.UtcNow;
+ isLicenseLockPending = !check.CanUseSoftware;
+ licenseLockMessage = check.Message;
+ if (!isLicenseLockPending)
+ {
+ licenseLockRequestedRaised = false;
+ }
+
+ UpdateTestButtonState(deviceService.CurrentSnapshot);
+ }
+
private void RefreshFromDevice()
{
var device = deviceService.CurrentSnapshot;
+ CheckRuntimeLicense(device);
if (device.IsConnected)
{
DeviceStatus = device.IsTestRunning ? "联机 / 测试中" : device.IsResetting ? "联机 / 复位中" : "联机 / 待机";
@@ -699,6 +728,14 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel
private void UpdateTestButtonState(SlipDeviceSnapshot device)
{
+ if (isLicenseLockPending)
+ {
+ ClearTestButtonFeedback();
+ TestButtonText = "授权到期";
+ IsTestButtonEnabled = false;
+ return;
+ }
+
if (device.IsTestRunning)
{
ClearTestButtonFeedback();
@@ -720,6 +757,26 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel
IsTestButtonEnabled = true;
}
+ private void CheckRuntimeLicense(SlipDeviceSnapshot device)
+ {
+ if (DateTime.UtcNow - lastLicenseCheckAt >= LicenseCheckInterval)
+ {
+ var check = licenseService.Check(updateHeartbeat: true);
+ lastLicenseCheckAt = DateTime.UtcNow;
+ isLicenseLockPending = !check.CanUseSoftware;
+ licenseLockMessage = check.Message;
+ }
+
+ if (isLicenseLockPending
+ && !device.IsTestRunning
+ && !device.IsResetting
+ && !licenseLockRequestedRaised)
+ {
+ licenseLockRequestedRaised = true;
+ LicenseLockRequested?.Invoke(licenseLockMessage);
+ }
+ }
+
private void BeginResetButtonFeedback()
{
isResetButtonPending = true;
@@ -1313,6 +1370,17 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel
}
}
+ private Task RunLicensedDeviceCommand(Func command, string successMessage)
+ {
+ if (isLicenseLockPending)
+ {
+ CurrentStatus = "软件使用时效已到,仅允许停止或复位设备。";
+ return Task.CompletedTask;
+ }
+
+ return RunDeviceCommand(command(), successMessage);
+ }
+
private void WriteNumericSetting(string value, Func writer, string label)
{
if (isLoadingDeviceSettings || !TryParseDouble(value, out var numericValue))
diff --git a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Views/LicenseWindow.axaml b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Views/LicenseWindow.axaml
new file mode 100644
index 0000000..704ed0b
--- /dev/null
+++ b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Views/LicenseWindow.axaml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Views/LicenseWindow.axaml.cs b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Views/LicenseWindow.axaml.cs
new file mode 100644
index 0000000..4e5a99e
--- /dev/null
+++ b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Views/LicenseWindow.axaml.cs
@@ -0,0 +1,214 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Models;
+using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Services;
+using Serilog;
+using System;
+
+namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Views
+{
+ public enum LicenseWindowMode
+ {
+ Initialization,
+ Unlock,
+ Administration,
+ Blocked
+ }
+
+ public partial class LicenseWindow : Window
+ {
+ private readonly MachineLicenseService licenseService;
+ private readonly LicenseWindowMode mode;
+ private bool adminAuthenticated;
+
+ public bool Succeeded { get; private set; }
+
+ public LicenseWindow() : this(new MachineLicenseService(), LicenseWindowMode.Blocked, "授权窗口需要由软件启动流程打开。")
+ {
+ }
+
+ public LicenseWindow(MachineLicenseService licenseService, LicenseWindowMode mode, string message = "")
+ {
+ this.licenseService = licenseService;
+ this.mode = mode;
+ InitializeComponent();
+
+ MessageText.Text = message;
+ InitializationPanel.IsVisible = mode == LicenseWindowMode.Initialization;
+ UnlockPanel.IsVisible = mode == LicenseWindowMode.Unlock;
+ AdminLoginPanel.IsVisible = mode == LicenseWindowMode.Administration;
+ CancelButton.Content = mode == LicenseWindowMode.Administration ? "关闭" : "退出";
+
+ switch (mode)
+ {
+ case LicenseWindowMode.Initialization:
+ TitleText.Text = "首次使用授权设置";
+ PrimaryButton.Content = "保存并开始使用";
+ break;
+ case LicenseWindowMode.Unlock:
+ TitleText.Text = "软件使用时效已到";
+ PrimaryButton.Content = "校验并继续使用";
+ break;
+ case LicenseWindowMode.Administration:
+ TitleText.Text = "时效授权管理";
+ PrimaryButton.Content = "验证管理密码";
+ break;
+ default:
+ TitleText.Text = "软件授权已锁定";
+ PrimaryButton.IsVisible = false;
+ break;
+ }
+ }
+
+ private void OnPrimaryClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ ErrorText.Foreground = Brushes.Crimson;
+ ErrorText.Text = string.Empty;
+ try
+ {
+ switch (mode)
+ {
+ case LicenseWindowMode.Initialization:
+ InitializeLicense();
+ break;
+ case LicenseWindowMode.Unlock:
+ UnlockLicense();
+ break;
+ case LicenseWindowMode.Administration:
+ HandleAdministration();
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "授权窗口操作失败:Mode={Mode}", mode);
+ ErrorText.Text = ex.Message;
+ }
+ }
+
+ private void InitializeLicense()
+ {
+ RequireMatch(InitialAdminPassword.Text, InitialAdminConfirm.Text, "管理密码");
+ RequireMatch(InitialFirstPassword.Text, InitialFirstConfirm.Text, "第一次时效密码");
+ RequireMatch(InitialSecondPassword.Text, InitialSecondConfirm.Text, "第二次时效密码");
+ licenseService.Initialize(
+ InitialAdminPassword.Text ?? string.Empty,
+ InitialFirstPassword.Text ?? string.Empty,
+ InitialSecondPassword.Text ?? string.Empty,
+ ReadMonths(InitialFirstMonths),
+ ReadMonths(InitialSecondMonths));
+ Succeeded = true;
+ Close();
+ }
+
+ private void UnlockLicense()
+ {
+ if (!licenseService.UnlockCurrentStage(UnlockPassword.Text ?? string.Empty))
+ {
+ ErrorText.Text = "时效密码不正确。";
+ UnlockPassword.Text = string.Empty;
+ return;
+ }
+
+ Succeeded = true;
+ Close();
+ }
+
+ private void HandleAdministration()
+ {
+ if (!adminAuthenticated)
+ {
+ if (!licenseService.VerifyAdminPassword(AdminLoginPassword.Text ?? string.Empty))
+ {
+ ErrorText.Text = "管理密码不正确。";
+ AdminLoginPassword.Text = string.Empty;
+ return;
+ }
+
+ adminAuthenticated = true;
+ AdminLoginPanel.IsVisible = false;
+ AdminSettingsPanel.IsVisible = true;
+ PrimaryButton.Content = "保存设置";
+ LoadAdminValues();
+ return;
+ }
+
+ var newAdminPassword = ReadOptionalMatchingPassword(NewAdminPassword.Text, NewAdminConfirm.Text, "新管理密码");
+ var newFirstPassword = ReadOptionalMatchingPassword(NewFirstPassword.Text, NewFirstConfirm.Text, "新第一次时效密码");
+ var newSecondPassword = ReadOptionalMatchingPassword(NewSecondPassword.Text, NewSecondConfirm.Text, "新第二次时效密码");
+ licenseService.UpdateSettings(
+ newAdminPassword,
+ newFirstPassword,
+ newSecondPassword,
+ ReadMonths(AdminFirstMonths),
+ ReadMonths(AdminSecondMonths));
+ LicenseStatusText.Text = licenseService.DescribeCurrent();
+ ErrorText.Foreground = Avalonia.Media.Brushes.ForestGreen;
+ ErrorText.Text = "时效授权设置已保存。";
+ NewAdminPassword.Text = string.Empty;
+ NewAdminConfirm.Text = string.Empty;
+ NewFirstPassword.Text = string.Empty;
+ NewFirstConfirm.Text = string.Empty;
+ NewSecondPassword.Text = string.Empty;
+ NewSecondConfirm.Text = string.Empty;
+ Succeeded = true;
+ }
+
+ private void LoadAdminValues()
+ {
+ var data = licenseService.Current ?? throw new InvalidOperationException("授权状态未加载。");
+ AdminFirstMonths.Value = data.FirstPeriodMonths;
+ AdminSecondMonths.Value = data.SecondPeriodMonths;
+ LicenseStatusText.Text = licenseService.DescribeCurrent();
+ }
+
+ private void OnRestartTimingClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ ConfirmationPanel.IsVisible = true;
+ }
+
+ private void OnConfirmRestartClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ try
+ {
+ licenseService.RestartTiming();
+ ConfirmationPanel.IsVisible = false;
+ LicenseStatusText.Text = licenseService.DescribeCurrent();
+ ErrorText.Foreground = Avalonia.Media.Brushes.ForestGreen;
+ ErrorText.Text = "已从当前时间重新开始第一阶段计时。";
+ Succeeded = true;
+ }
+ catch (Exception ex)
+ {
+ ErrorText.Text = ex.Message;
+ }
+ }
+
+ private void OnCancelRestartClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
+ ConfirmationPanel.IsVisible = false;
+
+ private void OnCancelClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Close();
+
+ private static int ReadMonths(NumericUpDown input) =>
+ decimal.ToInt32(input.Value ?? throw new ArgumentException("请输入有效的时效月数。"));
+
+ private static void RequireMatch(string? password, string? confirmation, string label)
+ {
+ if (!string.Equals(password, confirmation, StringComparison.Ordinal))
+ {
+ throw new ArgumentException($"{label}与确认输入不一致。");
+ }
+ }
+
+ private static string? ReadOptionalMatchingPassword(string? password, string? confirmation, string label)
+ {
+ if (string.IsNullOrWhiteSpace(password) && string.IsNullOrWhiteSpace(confirmation))
+ {
+ return null;
+ }
+
+ RequireMatch(password, confirmation, label);
+ return password;
+ }
+ }
+}
diff --git a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Views/MainWindow.axaml.cs b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Views/MainWindow.axaml.cs
index 3e3145d..edc8961 100644
--- a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Views/MainWindow.axaml.cs
+++ b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/Views/MainWindow.axaml.cs
@@ -1,15 +1,29 @@
+using Avalonia.Input;
using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModels;
+using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Services;
+using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Models;
using SukiUI.Controls;
using System;
+using System.Threading.Tasks;
namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Views
{
public partial class MainWindow : SukiWindow
{
- public MainWindow()
+ private readonly MachineLicenseService licenseService;
+ private bool isLicenseWindowOpen;
+
+ public MainWindow() : this(new MachineLicenseService())
{
+ }
+
+ public MainWindow(MachineLicenseService licenseService)
+ {
+ this.licenseService = licenseService;
InitializeComponent();
Closed += OnClosed;
+ KeyDown += OnKeyDown;
+ DataContextChanged += OnDataContextChanged;
}
private void OnClosed(object? sender, EventArgs e)
@@ -19,5 +33,83 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Views
viewModel.Dispose();
}
}
+
+ private void OnDataContextChanged(object? sender, EventArgs e)
+ {
+ if (DataContext is MainWindowViewModel viewModel)
+ {
+ viewModel.LicenseLockRequested -= OnLicenseLockRequested;
+ viewModel.LicenseLockRequested += OnLicenseLockRequested;
+ }
+ }
+
+ private void OnKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.F12
+ && e.KeyModifiers.HasFlag(KeyModifiers.Control)
+ && e.KeyModifiers.HasFlag(KeyModifiers.Shift))
+ {
+ e.Handled = true;
+ _ = ShowAdministrationAsync();
+ }
+ }
+
+ private void OnLicenseLockRequested(string message) => _ = ShowLicenseLockAsync(message);
+
+ private async Task ShowAdministrationAsync()
+ {
+ if (isLicenseWindowOpen)
+ {
+ return;
+ }
+
+ isLicenseWindowOpen = true;
+ try
+ {
+ var window = new LicenseWindow(licenseService, LicenseWindowMode.Administration, "验证管理密码后可修改两次密码和时效。");
+ await window.ShowDialog(this);
+ if (DataContext is MainWindowViewModel viewModel)
+ {
+ viewModel.RefreshLicenseState();
+ }
+ }
+ finally
+ {
+ isLicenseWindowOpen = false;
+ }
+ }
+
+ private async Task ShowLicenseLockAsync(string message)
+ {
+ if (isLicenseWindowOpen)
+ {
+ return;
+ }
+
+ isLicenseWindowOpen = true;
+ try
+ {
+ var check = licenseService.Check();
+ var mode = check.State == LicenseCheckState.Expired
+ ? LicenseWindowMode.Unlock
+ : LicenseWindowMode.Blocked;
+ var window = new LicenseWindow(licenseService, mode, message);
+ await window.ShowDialog(this);
+ if (!window.Succeeded)
+ {
+ Close();
+ return;
+ }
+
+ if (DataContext is MainWindowViewModel viewModel)
+ {
+ viewModel.RefreshLicenseState();
+ }
+ }
+ finally
+ {
+ isLicenseWindowOpen = false;
+ }
+ }
}
}