From b88ff9195c35f14cf01a37dbe35beefe90400afa Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 18 Sep 2026 00:49:34 +0700 Subject: [PATCH] fix: harden terminal scanner and background operation --- apps/KronaStaffTerminal/App.xaml.cs | 59 +++++ apps/KronaStaffTerminal/MainPage.xaml | 4 +- apps/KronaStaffTerminal/MainPage.xaml.cs | 238 +++++++++++++++++- apps/KronaStaffTerminal/MainWindow.xaml.cs | 6 + .../Models/TerminalModels.cs | 1 + apps/KronaStaffTerminal/README.md | 4 +- .../Services/AdminPinService.cs | 17 +- .../Services/FingerprintScanner.cs | 115 +++++++-- .../Services/TerminalStore.cs | 102 ++++++-- 9 files changed, 490 insertions(+), 56 deletions(-) diff --git a/apps/KronaStaffTerminal/App.xaml.cs b/apps/KronaStaffTerminal/App.xaml.cs index 82d6e65..8a981ee 100644 --- a/apps/KronaStaffTerminal/App.xaml.cs +++ b/apps/KronaStaffTerminal/App.xaml.cs @@ -16,7 +16,13 @@ namespace KronaStaffTerminal; /// public partial class App : Application { + private const string InstanceMutexName = @"Local\KronaStaffTerminal.Singleton"; + private const string ActivationEventName = @"Local\KronaStaffTerminal.Activate"; private TrayService? _trayService; + private Microsoft.UI.Dispatching.DispatcherQueueTimer? _autostartTimer; + private Mutex? _instanceMutex; + private EventWaitHandle? _activationEvent; + private CancellationTokenSource? _activationCancellation; /// /// The main application window. Use App.Window from any class that needs /// the window reference (for dialogs, pickers, interop, etc.). @@ -53,10 +59,55 @@ public partial class App : Application /// Details about the launch request and process. protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) { + _instanceMutex = new Mutex(true, InstanceMutexName, out var isFirstInstance); + if (!isFirstInstance) + { + try + { + EventWaitHandle.OpenExisting(ActivationEventName).Set(); + } + catch (WaitHandleCannotBeOpenedException) + { + // The first instance is still starting; exiting avoids competing for the scanner. + } + Environment.Exit(0); + return; + } + Window = new MainWindow(); DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); _trayService = new TrayService(ShowWindow, RequestExit); + _activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName); + _activationCancellation = new CancellationTokenSource(); + _ = Task.Run(() => ListenForActivation(_activationCancellation.Token)); Window.Activate(); + if (Environment.GetCommandLineArgs().Any(argument => + argument.Equals("--autostart", StringComparison.OrdinalIgnoreCase))) + { + _autostartTimer = DispatcherQueue.CreateTimer(); + _autostartTimer.Interval = TimeSpan.FromSeconds(1); + _autostartTimer.IsRepeating = false; + _autostartTimer.Tick += (_, _) => + { + if (Window is MainWindow mainWindow) + { + mainWindow.HideToTray(); + } + _autostartTimer = null; + }; + _autostartTimer.Start(); + } + } + + private void ListenForActivation(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + if (_activationEvent?.WaitOne(TimeSpan.FromSeconds(1)) == true) + { + DispatcherQueue.TryEnqueue(ShowWindow); + } + } } public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon(); @@ -80,6 +131,14 @@ public partial class App : Application public void CompleteExit() { + _activationCancellation?.Cancel(); + _activationCancellation?.Dispose(); + _activationCancellation = null; + _activationEvent?.Dispose(); + _activationEvent = null; + _instanceMutex?.ReleaseMutex(); + _instanceMutex?.Dispose(); + _instanceMutex = null; _trayService?.Dispose(); _trayService = null; if (Window is MainWindow mainWindow) diff --git a/apps/KronaStaffTerminal/MainPage.xaml b/apps/KronaStaffTerminal/MainPage.xaml index 251919e..5ce01f8 100644 --- a/apps/KronaStaffTerminal/MainPage.xaml +++ b/apps/KronaStaffTerminal/MainPage.xaml @@ -125,7 +125,9 @@ + + @@ -157,7 +159,7 @@ - + diff --git a/apps/KronaStaffTerminal/MainPage.xaml.cs b/apps/KronaStaffTerminal/MainPage.xaml.cs index 1e24099..fb49c67 100644 --- a/apps/KronaStaffTerminal/MainPage.xaml.cs +++ b/apps/KronaStaffTerminal/MainPage.xaml.cs @@ -18,6 +18,10 @@ public sealed partial class MainPage : Page private readonly DispatcherQueueTimer _adminSessionTimer; private bool _adminSessionActive; private bool _adminNavigationInProgress; + private bool _stateLoadFailed; + private bool _scannerReconnectPending; + private int _failedPinAttempts; + private DateTimeOffset _pinBlockedUntilUtc; private string? _registrationEmployeeId; private DateTimeOffset _adminSessionExpiresAtUtc; private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5); @@ -27,8 +31,9 @@ public sealed partial class MainPage : Page InitializeComponent(); _scanner.StatusChanged += message => Enqueue(() => SetStatus(message)); _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3")); - _scanner.RegistrationCompleted += (id, template) => Enqueue(() => _ = CompleteRegistrationAsync(id, template)); - _scanner.FingerRecognized += (id, score) => Enqueue(() => _ = HandleRecognizedFingerAsync(id, score)); + _scanner.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template)); + _scanner.FingerRecognized += (id, score) => EnqueueTask(() => HandleRecognizedFingerAsync(id, score)); + _scanner.ConnectionLost += () => EnqueueTask(ReconnectScannerAsync); _adminSessionTimer = DispatcherQueue.CreateTimer(); _adminSessionTimer.Interval = TimeSpan.FromSeconds(15); _adminSessionTimer.IsRepeating = true; @@ -46,7 +51,19 @@ public sealed partial class MainPage : Page { if (_loaded) return; _loaded = true; - _state = await _store.LoadAsync(); + try + { + _state = await _store.LoadAsync(); + } + catch (Exception exception) + { + _stateLoadFailed = true; + CrashLogService.Write(exception); + MainTabs.SelectedItem = TerminalTab; + TerminalMessage.Text = "Хранилище данных не открыто\nОбратитесь к администратору"; + SetStatus(exception.Message, InfoBarSeverity.Error); + return; + } LoadSettings(); ApplyStartupSetting(); RefreshLists(); @@ -56,10 +73,31 @@ public sealed partial class MainPage : Page private void Page_Unloaded(object sender, RoutedEventArgs e) { + _loaded = false; _adminSessionTimer.Stop(); _scanner.Dispose(); } + private async Task ReconnectScannerAsync() + { + if (_scannerReconnectPending || !_loaded) return; + _scannerReconnectPending = true; + try + { + _scanner.Disconnect(); + while (_loaded && !_scanner.IsConnected) + { + await Task.Delay(TimeSpan.FromSeconds(3)); + if (!_loaded) return; + ConnectScanner(); + } + } + finally + { + _scannerReconnectPending = false; + } + } + private async void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (_adminNavigationInProgress || ReferenceEquals(MainTabs.SelectedItem, TerminalTab)) return; @@ -88,6 +126,7 @@ public sealed partial class MainPage : Page private void LockAdminSession(string message) { + CancelRegistration(); _adminSessionActive = false; _adminSessionExpiresAtUtc = default; _adminAuthCompletion?.TrySetResult(false); @@ -109,6 +148,11 @@ public sealed partial class MainPage : Page private async Task EnsureAdminSessionAsync(string operation) { + if (_stateLoadFailed) + { + SetStatus("Административные действия заблокированы: хранилище данных не открыто.", InfoBarSeverity.Error); + return false; + } if (HasActiveAdminSession) { TouchAdminSession(); @@ -157,9 +201,10 @@ public sealed partial class MainPage : Page return false; } - var pin = AdminPinService.Hash(pinBox.Password); + var pin = await Task.Run(() => AdminPinService.Hash(pinBox.Password)); _state.Settings.AdminPinSalt = pin.Salt; _state.Settings.AdminPinHash = pin.Hash; + _state.Settings.AdminPinIterations = pin.Iterations; await _store.SaveAsync(_state); return true; } @@ -181,7 +226,7 @@ public sealed partial class MainPage : Page if (_state.Settings.ReaderIndex != _scanner.ReaderIndex) { _state.Settings.ReaderIndex = _scanner.ReaderIndex; - _ = _store.SaveAsync(_state); + SaveStateInBackground(); } if (_scanner.SkippedTemplateCount > 0) { @@ -246,6 +291,96 @@ public sealed partial class MainPage : Page SetStatus($"{name} добавлен. Теперь зарегистрируйте отпечаток.", InfoBarSeverity.Success); } + private async void EditEmployee_Click(object sender, RoutedEventArgs e) + { + if (!await EnsureAdminSessionAsync("редактирование сотрудника")) return; + if (EmployeesList.SelectedItem is not EmployeeRecord employee) + { + SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); + return; + } + + var personnelNumberBox = new TextBox + { + Header = "Табельный номер", + Text = employee.PersonnelNumber, + MinWidth = 360 + }; + var nameBox = new TextBox + { + Header = "Имя сотрудника", + Text = employee.DisplayName, + MinWidth = 360 + }; + var positionBox = new TextBox + { + Header = "Должность", + Text = employee.Position, + MinWidth = 360 + }; + var genderBox = new ComboBox + { + Header = "Пол", + MinWidth = 360, + ItemsSource = new[] { "Не указан", "Мужской", "Женский" }, + SelectedIndex = employee.Gender switch + { + "Мужской" => 1, + "Женский" => 2, + _ => 0 + } + }; + var content = new StackPanel { Spacing = 12 }; + content.Children.Add(personnelNumberBox); + content.Children.Add(nameBox); + content.Children.Add(positionBox); + content.Children.Add(genderBox); + + var dialog = new ContentDialog + { + XamlRoot = XamlRoot, + Title = $"Редактирование: {employee.DisplayName}", + Content = content, + PrimaryButtonText = "Сохранить", + CloseButtonText = "Отмена", + DefaultButton = ContentDialogButton.Primary + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + + var number = personnelNumberBox.Text.Trim(); + var name = nameBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number)) + { + SetStatus("Имя и табельный номер не могут быть пустыми.", InfoBarSeverity.Warning); + return; + } + if (_state.Employees.Any(item => !ReferenceEquals(item, employee) && + item.PersonnelNumber.Equals(number, StringComparison.OrdinalIgnoreCase))) + { + SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning); + return; + } + + employee.PersonnelNumber = number; + employee.DisplayName = name; + employee.Position = positionBox.Text.Trim(); + employee.Gender = genderBox.SelectedIndex switch + { + 1 => "Мужской", + 2 => "Женский", + _ => "" + }; + foreach (var attendance in _state.Attendance.Where(item => item.EmployeeId == employee.Id)) + { + attendance.EmployeeName = name; + } + + await _store.SaveAsync(_state); + RefreshLists(); + EmployeesList.SelectedItem = employee; + SetStatus($"Данные сотрудника «{name}» обновлены. Отпечатки и журнал сохранены.", InfoBarSeverity.Success); + } + private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return; @@ -268,6 +403,31 @@ public sealed partial class MainPage : Page _scanner.BeginRegistration(fingerprintId); } + private void CancelRegistration_Click(object sender, RoutedEventArgs e) + { + if (_registrationEmployeeId is null) + { + SetStatus("Активной регистрации отпечатка нет.", InfoBarSeverity.Informational); + return; + } + CancelRegistration(); + SetStatus("Регистрация отпечатка отменена.", InfoBarSeverity.Informational); + } + + private void EmployeesList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_registrationEmployeeId is null) return; + if (EmployeesList.SelectedItem is EmployeeRecord employee && employee.Id == _registrationEmployeeId) return; + CancelRegistration(); + SetStatus("Регистрация отпечатка отменена из-за смены сотрудника.", InfoBarSeverity.Warning); + } + + private void CancelRegistration() + { + _scanner.CancelRegistration(); + _registrationEmployeeId = null; + } + private async Task CompleteRegistrationAsync(int fingerprintId, string template) { var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId); @@ -390,7 +550,7 @@ public sealed partial class MainPage : Page } else { - SetStatus($"{employee.DisplayName} не имеет прав администратора.", InfoBarSeverity.Warning); + await RecordAttendanceAsync(employee, score); } return; } @@ -483,6 +643,12 @@ public sealed partial class MainPage : Page private async Task AuthorizeAdminAsync(string operation) { + var remainingLockout = _pinBlockedUntilUtc - DateTimeOffset.UtcNow; + if (remainingLockout > TimeSpan.Zero) + { + SetStatus($"Слишком много неверных попыток PIN. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек.", InfoBarSeverity.Warning); + return false; + } var hasAdministratorFingerprint = AdministratorFingerprintConfigured; var pinBox = new PasswordBox { @@ -514,9 +680,34 @@ public sealed partial class MainPage : Page var fingerprintAuthorized = _adminAuthCompletion.Task.IsCompletedSuccessfully && _adminAuthCompletion.Task.Result; _adminAuthCompletion.TrySetResult(false); - var authorized = fingerprintAuthorized || - (dialogResult == ContentDialogResult.Primary && - AdminPinService.Verify(pinBox.Password, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash)); + var pinAuthorized = false; + if (dialogResult == ContentDialogResult.Primary) + { + pinAuthorized = await Task.Run(() => + AdminPinService.Verify( + pinBox.Password, + _state.Settings.AdminPinSalt, + _state.Settings.AdminPinHash, + _state.Settings.AdminPinIterations) || + (!string.Equals(pinBox.Password, pinBox.Password.Trim(), StringComparison.Ordinal) && + AdminPinService.Verify( + pinBox.Password.Trim(), + _state.Settings.AdminPinSalt, + _state.Settings.AdminPinHash, + _state.Settings.AdminPinIterations))); + } + var authorized = fingerprintAuthorized || pinAuthorized; + if (authorized) + { + _failedPinAttempts = 0; + _pinBlockedUntilUtc = default; + } + else if (dialogResult == ContentDialogResult.Primary) + { + _failedPinAttempts++; + var delaySeconds = Math.Min(30, 1 << Math.Min(_failedPinAttempts - 1, 5)); + _pinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); + } _adminAuthCompletion = null; _adminAuthDialog = null; SetStatus(authorized @@ -536,9 +727,10 @@ public sealed partial class MainPage : Page return; } if (!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && !await AuthorizeAdminAsync("изменение PIN")) return; - var pin = AdminPinService.Hash(newPin); + var pin = await Task.Run(() => AdminPinService.Hash(newPin)); _state.Settings.AdminPinSalt = pin.Salt; _state.Settings.AdminPinHash = pin.Hash; + _state.Settings.AdminPinIterations = pin.Iterations; NewAdminPinBox.Password = ""; await _store.SaveAsync(_state); SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success); @@ -616,4 +808,30 @@ public sealed partial class MainPage : Page } private static void Enqueue(Action action) => App.DispatcherQueue.TryEnqueue(() => action()); + + private void EnqueueTask(Func operation) => App.DispatcherQueue.TryEnqueue(async () => + { + try + { + await operation(); + } + catch (Exception exception) + { + CrashLogService.Write(exception); + SetStatus($"Операция не выполнена: {exception.Message}", InfoBarSeverity.Error); + } + }); + + private async void SaveStateInBackground() + { + try + { + await _store.SaveAsync(_state); + } + catch (Exception exception) + { + CrashLogService.Write(exception); + SetStatus($"Не удалось сохранить данные: {exception.Message}", InfoBarSeverity.Error); + } + } } diff --git a/apps/KronaStaffTerminal/MainWindow.xaml.cs b/apps/KronaStaffTerminal/MainWindow.xaml.cs index 739d116..71f5b15 100644 --- a/apps/KronaStaffTerminal/MainWindow.xaml.cs +++ b/apps/KronaStaffTerminal/MainWindow.xaml.cs @@ -57,6 +57,12 @@ public sealed partial class MainWindow : Window Activate(); } + public void HideToTray() + { + if (RootFrame.Content is MainPage page) page.LockAdminSession(); + AppWindow.Hide(); + } + public async Task RequestExitAsync() { if (RootFrame.Content is MainPage page && await page.AuthorizeExitAsync()) diff --git a/apps/KronaStaffTerminal/Models/TerminalModels.cs b/apps/KronaStaffTerminal/Models/TerminalModels.cs index a38886c..68dcfd4 100644 --- a/apps/KronaStaffTerminal/Models/TerminalModels.cs +++ b/apps/KronaStaffTerminal/Models/TerminalModels.cs @@ -66,6 +66,7 @@ public sealed class TerminalSettings public bool StartWithWindows { get; set; } = true; public string AdminPinSalt { get; set; } = ""; public string AdminPinHash { get; set; } = ""; + public int AdminPinIterations { get; set; } = 120_000; public int DuplicateScanGuardSeconds { get; set; } = 120; public bool TelegramEnabled { get; set; } public string TelegramBotToken { get; set; } = ""; diff --git a/apps/KronaStaffTerminal/README.md b/apps/KronaStaffTerminal/README.md index fc1f232..6679d4f 100644 --- a/apps/KronaStaffTerminal/README.md +++ b/apps/KronaStaffTerminal/README.md @@ -5,14 +5,14 @@ ## Возможности - выбор одного из подключённых сканеров с автоматическим переходом на рабочее устройство; -- добавление сотрудников с должностью, полом и правами администратора; +- добавление и редактирование сотрудников с должностью, полом и правами администратора без потери отпечатков и журнала; - несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам; - чередование событий «Приход» и «Уход»; - настраиваемая защита от повторного сканирования, по умолчанию 120 секунд; - локальный журнал последних отметок; - защищённая очистка журнала без удаления сотрудников и отпечатков; - уведомления через Telegram Bot API и MAX Bot API; -- автозагрузка вместе с Windows и постоянная работа в системном трее; +- автозагрузка сразу в системный трей и постоянное фоновое сканирование, даже когда открыта другая программа; - закрытый административный раздел с входом по PIN или отпечатку администратора; - автоматическая блокировка административного раздела через 5 минут и при сворачивании; - шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI. diff --git a/apps/KronaStaffTerminal/Services/AdminPinService.cs b/apps/KronaStaffTerminal/Services/AdminPinService.cs index 265fee3..bc55a16 100644 --- a/apps/KronaStaffTerminal/Services/AdminPinService.cs +++ b/apps/KronaStaffTerminal/Services/AdminPinService.cs @@ -4,16 +4,16 @@ namespace KronaStaffTerminal.Services; public static class AdminPinService { - private const int Iterations = 120_000; + public const int CurrentIterations = 600_000; - public static (string Salt, string Hash) Hash(string pin) + public static (string Salt, string Hash, int Iterations) Hash(string pin) { var salt = RandomNumberGenerator.GetBytes(16); - var hash = Rfc2898DeriveBytes.Pbkdf2(pin, salt, Iterations, HashAlgorithmName.SHA256, 32); - return (Convert.ToBase64String(salt), Convert.ToBase64String(hash)); + var hash = Rfc2898DeriveBytes.Pbkdf2(pin, salt, CurrentIterations, HashAlgorithmName.SHA256, 32); + return (Convert.ToBase64String(salt), Convert.ToBase64String(hash), CurrentIterations); } - public static bool Verify(string pin, string saltBase64, string hashBase64) + public static bool Verify(string pin, string saltBase64, string hashBase64, int iterations) { if (string.IsNullOrWhiteSpace(pin) || string.IsNullOrWhiteSpace(saltBase64) || string.IsNullOrWhiteSpace(hashBase64)) { @@ -24,7 +24,12 @@ public static class AdminPinService { var salt = Convert.FromBase64String(saltBase64); var expected = Convert.FromBase64String(hashBase64); - var actual = Rfc2898DeriveBytes.Pbkdf2(pin, salt, Iterations, HashAlgorithmName.SHA256, expected.Length); + var actual = Rfc2898DeriveBytes.Pbkdf2( + pin, + salt, + Math.Clamp(iterations, 120_000, CurrentIterations), + HashAlgorithmName.SHA256, + expected.Length); return CryptographicOperations.FixedTimeEquals(actual, expected); } catch (FormatException) diff --git a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs index 56e5c80..110615e 100644 --- a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs +++ b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs @@ -10,8 +10,10 @@ public sealed class FingerprintScanner : IDisposable private IntPtr _device = IntPtr.Zero; private IntPtr _database = IntPtr.Zero; private CancellationTokenSource? _captureCancellation; + private Task? _captureTask; private int _registrationCount; private int? _registrationFingerprintId; + private DateTimeOffset _registrationExpiresAtUtc; private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue; private bool _fingerOnSensor; @@ -19,6 +21,7 @@ public sealed class FingerprintScanner : IDisposable public event Action? RegistrationProgress; public event Action? RegistrationCompleted; public event Action? StatusChanged; + public event Action? ConnectionLost; public int DeviceCount { get; private set; } public int ReaderIndex { get; private set; } @@ -35,7 +38,9 @@ public sealed class FingerprintScanner : IDisposable throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result})."); } - DeviceCount = zkfp2.GetDeviceCount(); + // ZKFinger SDK 5.3 reports one service slot in addition to physical readers. + // The vendor C# sample uses GetDeviceCount() - 1 for this SDK generation. + DeviceCount = Math.Max(0, zkfp2.GetDeviceCount() - 1); if (DeviceCount <= 0) { zkfp2.Terminate(); @@ -85,7 +90,9 @@ public sealed class FingerprintScanner : IDisposable } _captureCancellation = new CancellationTokenSource(); - _ = Task.Run(() => CaptureLoop(_captureCancellation.Token)); + var device = _device; + var database = _database; + _captureTask = Task.Run(() => CaptureLoop(device, database, _captureCancellation.Token)); StatusChanged?.Invoke($"Сканер №{ReaderIndex + 1} подключён. Приложите палец."); } @@ -95,6 +102,7 @@ public sealed class FingerprintScanner : IDisposable { _registrationFingerprintId = fingerprintId; _registrationCount = 0; + _registrationExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1); } StatusChanged?.Invoke("Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла."); @@ -106,51 +114,103 @@ public sealed class FingerprintScanner : IDisposable { _registrationFingerprintId = null; _registrationCount = 0; + _registrationExpiresAtUtc = default; } } - private void CaptureLoop(CancellationToken cancellationToken) + private void CaptureLoop(IntPtr device, IntPtr database, CancellationToken cancellationToken) { var parameter = new byte[4]; var size = 4; var width = 0; var height = 0; - zkfp2.GetParameters(_device, 1, parameter, ref size); + zkfp2.GetParameters(device, 1, parameter, ref size); zkfp2.ByteArray2Int(parameter, ref width); size = 4; - zkfp2.GetParameters(_device, 2, parameter, ref size); + zkfp2.GetParameters(device, 2, parameter, ref size); zkfp2.ByteArray2Int(parameter, ref height); var image = new byte[Math.Max(1, width * height)]; var captured = new byte[TemplateSize]; + var nextHealthCheck = DateTimeOffset.UtcNow.AddSeconds(5); - while (!cancellationToken.IsCancellationRequested) + try { - var capturedLength = TemplateSize; - if (zkfp2.AcquireFingerprint(_device, image, captured, ref capturedLength) == 0) + while (!cancellationToken.IsCancellationRequested) { - var now = DateTimeOffset.UtcNow; - if (!_fingerOnSensor && now - _lastProcessedCapture > TimeSpan.FromMilliseconds(500)) + ExpireRegistrationIfNeeded(); + if (DateTimeOffset.UtcNow >= nextHealthCheck) { - _fingerOnSensor = true; - _lastProcessedCapture = now; - ProcessCapture(captured, capturedLength); + var healthSize = 4; + if (zkfp2.GetParameters(device, 1, parameter, ref healthSize) != 0) + { + StatusChanged?.Invoke("Связь со сканером потеряна. Выполняется переподключение…"); + ConnectionLost?.Invoke(); + return; + } + nextHealthCheck = DateTimeOffset.UtcNow.AddSeconds(5); } + + var capturedLength = TemplateSize; + if (zkfp2.AcquireFingerprint(device, image, captured, ref capturedLength) == 0) + { + var now = DateTimeOffset.UtcNow; + if (!_fingerOnSensor && now - _lastProcessedCapture > TimeSpan.FromMilliseconds(500)) + { + _fingerOnSensor = true; + _lastProcessedCapture = now; + ProcessCapture(database, captured, capturedLength); + } + } + else + { + _fingerOnSensor = false; + } + Thread.Sleep(150); } - else + } + catch (Exception exception) + { + CrashLogService.Write(exception); + if (!cancellationToken.IsCancellationRequested) { - _fingerOnSensor = false; + StatusChanged?.Invoke("Ошибка сканера. Выполняется переподключение…"); + ConnectionLost?.Invoke(); } - Thread.Sleep(150); } } - private void ProcessCapture(byte[] captured, int capturedLength) + private void ExpireRegistrationIfNeeded() + { + lock (_gate) + { + if (_registrationFingerprintId is null || DateTimeOffset.UtcNow < _registrationExpiresAtUtc) return; + _registrationFingerprintId = null; + _registrationCount = 0; + _registrationExpiresAtUtc = default; + StatusChanged?.Invoke("Регистрация отпечатка отменена: за 60 секунд не получены три скана."); + } + } + + private void ProcessCapture(IntPtr database, byte[] captured, int capturedLength) { lock (_gate) { if (_registrationFingerprintId is int fingerprintId) { - if (_registrationCount > 0 && zkfp2.DBMatch(_database, captured, _registrationSamples[_registrationCount - 1]) <= 0) + if (_registrationCount == 0) + { + var existingId = 0; + var existingScore = 0; + if (zkfp2.DBIdentify(database, captured, ref existingId, ref existingScore) == 0) + { + _registrationFingerprintId = null; + _registrationExpiresAtUtc = default; + StatusChanged?.Invoke("Этот палец уже зарегистрирован. Регистрация отменена."); + return; + } + } + + if (_registrationCount > 0 && zkfp2.DBMatch(database, captured, _registrationSamples[_registrationCount - 1]) <= 0) { StatusChanged?.Invoke("Это другой палец. Приложите тот же палец ещё раз."); return; @@ -168,15 +228,16 @@ public sealed class FingerprintScanner : IDisposable var merged = new byte[TemplateSize]; var mergedLength = TemplateSize; - var result = zkfp2.DBMerge(_database, _registrationSamples[0], _registrationSamples[1], _registrationSamples[2], merged, ref mergedLength); + var result = zkfp2.DBMerge(database, _registrationSamples[0], _registrationSamples[1], _registrationSamples[2], merged, ref mergedLength); if (result == 0) { - zkfp2.DBDel(_database, fingerprintId); - result = zkfp2.DBAdd(_database, fingerprintId, merged); + zkfp2.DBDel(database, fingerprintId); + result = zkfp2.DBAdd(database, fingerprintId, merged); } _registrationFingerprintId = null; _registrationCount = 0; + _registrationExpiresAtUtc = default; if (result == 0) { RegistrationCompleted?.Invoke(fingerprintId, zkfp2.BlobToBase64(merged, mergedLength)); @@ -191,7 +252,7 @@ public sealed class FingerprintScanner : IDisposable var foundId = 0; var score = 0; - if (zkfp2.DBIdentify(_database, captured, ref foundId, ref score) == 0) + if (zkfp2.DBIdentify(database, captured, ref foundId, ref score) == 0) { FingerRecognized?.Invoke(foundId, score); } @@ -205,8 +266,16 @@ public sealed class FingerprintScanner : IDisposable public void Disconnect() { _captureCancellation?.Cancel(); + try + { + _captureTask?.Wait(TimeSpan.FromSeconds(2)); + } + catch (AggregateException exception) + { + CrashLogService.Write(exception.Flatten()); + } _captureCancellation = null; - Thread.Sleep(200); + _captureTask = null; if (_database != IntPtr.Zero) { zkfp2.DBFree(_database); diff --git a/apps/KronaStaffTerminal/Services/TerminalStore.cs b/apps/KronaStaffTerminal/Services/TerminalStore.cs index 00b41f4..2ae81f4 100644 --- a/apps/KronaStaffTerminal/Services/TerminalStore.cs +++ b/apps/KronaStaffTerminal/Services/TerminalStore.cs @@ -8,6 +8,7 @@ public sealed class TerminalStore { private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray(); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private readonly SemaphoreSlim _saveGate = new(1, 1); private readonly string _filePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Krona", @@ -16,13 +17,12 @@ public sealed class TerminalStore public async Task LoadAsync() { + var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState(); + if (sourcePath is null) return new TerminalState(); + try { - var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState(); - if (sourcePath is null) return new TerminalState(); - var encrypted = await File.ReadAllBytesAsync(sourcePath); - var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); - var state = JsonSerializer.Deserialize(clear, JsonOptions) ?? new TerminalState(); + var state = await ReadStateAsync(sourcePath); var migrated = MigrateFingerprints(state); if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { @@ -30,12 +30,50 @@ public sealed class TerminalStore } return state; } - catch + catch (Exception primaryException) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { - return new TerminalState(); + var corruptPath = _filePath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}"; + try + { + File.Move(_filePath, corruptPath, false); + } + catch (Exception moveException) + { + throw new InvalidDataException( + "Хранилище терминала повреждено и не может быть сохранено отдельно. Работа заблокирована, чтобы не потерять данные.", + new AggregateException(primaryException, moveException)); + } + + foreach (var backupPath in BackupPaths()) + { + if (!File.Exists(backupPath)) continue; + try + { + var recovered = await ReadStateAsync(backupPath); + MigrateFingerprints(recovered); + await SaveAsync(recovered); + return recovered; + } + catch (Exception backupException) + { + CrashLogService.Write(backupException); + } + } + + throw new InvalidDataException( + $"Хранилище терминала повреждено. Исходный файл сохранён как {Path.GetFileName(corruptPath)}. Работа заблокирована до восстановления резервной копии.", + primaryException); } } + private static async Task ReadStateAsync(string path) + { + var encrypted = await File.ReadAllBytesAsync(path); + var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); + return JsonSerializer.Deserialize(clear, JsonOptions) + ?? throw new InvalidDataException("Файл состояния не содержит данных."); + } + private static bool MigrateFingerprints(TerminalState state) { var changed = false; @@ -65,13 +103,49 @@ public sealed class TerminalStore public async Task SaveAsync(TerminalState state) { - var directory = Path.GetDirectoryName(_filePath)!; - Directory.CreateDirectory(directory); - var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions); - var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser); - var temporaryPath = _filePath + ".tmp"; - await File.WriteAllBytesAsync(temporaryPath, encrypted); - File.Move(temporaryPath, _filePath, true); + await _saveGate.WaitAsync(); + try + { + var directory = Path.GetDirectoryName(_filePath)!; + Directory.CreateDirectory(directory); + var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions); + var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser); + var temporaryPath = _filePath + ".tmp"; + await using (var stream = new FileStream( + temporaryPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await stream.WriteAsync(encrypted); + await stream.FlushAsync(); + stream.Flush(true); + } + + RotateBackups(); + File.Move(temporaryPath, _filePath, true); + } + finally + { + _saveGate.Release(); + } + } + + private IEnumerable BackupPaths() + { + for (var index = 1; index <= 3; index++) yield return _filePath + $".bak{index}"; + } + + private void RotateBackups() + { + var backups = BackupPaths().ToArray(); + for (var index = backups.Length - 1; index > 0; index--) + { + if (File.Exists(backups[index - 1])) File.Copy(backups[index - 1], backups[index], true); + } + if (File.Exists(_filePath)) File.Copy(_filePath, backups[0], true); } private static string? FindLegacyPackagedState()