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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +