fix: harden terminal scanner and background operation
Some checks are pending
ci / verify (push) Waiting to run

This commit is contained in:
Ochenstarik 2026-09-18 00:49:34 +07:00
parent 14ed610d14
commit b88ff9195c
9 changed files with 490 additions and 56 deletions

View file

@ -16,7 +16,13 @@ namespace KronaStaffTerminal;
/// </summary> /// </summary>
public partial class App : Application public partial class App : Application
{ {
private const string InstanceMutexName = @"Local\KronaStaffTerminal.Singleton";
private const string ActivationEventName = @"Local\KronaStaffTerminal.Activate";
private TrayService? _trayService; private TrayService? _trayService;
private Microsoft.UI.Dispatching.DispatcherQueueTimer? _autostartTimer;
private Mutex? _instanceMutex;
private EventWaitHandle? _activationEvent;
private CancellationTokenSource? _activationCancellation;
/// <summary> /// <summary>
/// The main application window. Use <c>App.Window</c> from any class that needs /// The main application window. Use <c>App.Window</c> from any class that needs
/// the window reference (for dialogs, pickers, interop, etc.). /// the window reference (for dialogs, pickers, interop, etc.).
@ -53,10 +59,55 @@ public partial class App : Application
/// <param name="args">Details about the launch request and process.</param> /// <param name="args">Details about the launch request and process.</param>
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) 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(); Window = new MainWindow();
DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
_trayService = new TrayService(ShowWindow, RequestExit); _trayService = new TrayService(ShowWindow, RequestExit);
_activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName);
_activationCancellation = new CancellationTokenSource();
_ = Task.Run(() => ListenForActivation(_activationCancellation.Token));
Window.Activate(); 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(); public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon();
@ -80,6 +131,14 @@ public partial class App : Application
public void CompleteExit() public void CompleteExit()
{ {
_activationCancellation?.Cancel();
_activationCancellation?.Dispose();
_activationCancellation = null;
_activationEvent?.Dispose();
_activationEvent = null;
_instanceMutex?.ReleaseMutex();
_instanceMutex?.Dispose();
_instanceMutex = null;
_trayService?.Dispose(); _trayService?.Dispose();
_trayService = null; _trayService = null;
if (Window is MainWindow mainWindow) if (Window is MainWindow mainWindow)

View file

@ -125,7 +125,9 @@
<TextBlock Text="Добавление и регистрация сотрудников" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/> <TextBlock Text="Добавление и регистрация сотрудников" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
<CommandBar Grid.Row="1" Background="{ThemeResource KronaSurfaceBrush}" DefaultLabelPosition="Right"> <CommandBar Grid.Row="1" Background="{ThemeResource KronaSurfaceBrush}" DefaultLabelPosition="Right">
<AppBarButton Icon="Add" Label="Добавить" Click="AddEmployee_Click"/> <AppBarButton Icon="Add" Label="Добавить" Click="AddEmployee_Click"/>
<AppBarButton Icon="Edit" Label="Редактировать" Click="EditEmployee_Click"/>
<AppBarButton Icon="Contact" Label="Добавить отпечаток" Click="RegisterFingerprint_Click"/> <AppBarButton Icon="Contact" Label="Добавить отпечаток" Click="RegisterFingerprint_Click"/>
<AppBarButton Icon="Cancel" Label="Отменить регистрацию" Click="CancelRegistration_Click"/>
<AppBarButton Label="Удалить отпечатки" Click="DeleteFingerprints_Click"> <AppBarButton Label="Удалить отпечатки" Click="DeleteFingerprints_Click">
<AppBarButton.Icon> <AppBarButton.Icon>
<FontIcon Glyph="&#xE928;" /> <FontIcon Glyph="&#xE928;" />
@ -157,7 +159,7 @@
<CheckBox x:Name="AdministratorCheckBox" Content="Администратор терминала"/> <CheckBox x:Name="AdministratorCheckBox" Content="Администратор терминала"/>
<TextBlock Text="Выберите сотрудника справа и нажмите «Добавить отпечаток». Для каждого нового пальца повторите регистрацию из трёх сканов." TextWrapping="Wrap" Opacity="0.75"/> <TextBlock Text="Выберите сотрудника справа и нажмите «Добавить отпечаток». Для каждого нового пальца повторите регистрацию из трёх сканов." TextWrapping="Wrap" Opacity="0.75"/>
</StackPanel> </StackPanel>
<ListView x:Name="EmployeesList" Grid.Column="1" Background="{ThemeResource KronaRaisedSurfaceBrush}" SelectionMode="Single"> <ListView x:Name="EmployeesList" Grid.Column="1" Background="{ThemeResource KronaRaisedSurfaceBrush}" SelectionMode="Single" SelectionChanged="EmployeesList_SelectionChanged">
<ListView.ItemTemplate> <ListView.ItemTemplate>
<DataTemplate x:DataType="models:EmployeeRecord"> <DataTemplate x:DataType="models:EmployeeRecord">
<Grid Padding="4,10" ColumnSpacing="14"> <Grid Padding="4,10" ColumnSpacing="14">

View file

@ -18,6 +18,10 @@ public sealed partial class MainPage : Page
private readonly DispatcherQueueTimer _adminSessionTimer; private readonly DispatcherQueueTimer _adminSessionTimer;
private bool _adminSessionActive; private bool _adminSessionActive;
private bool _adminNavigationInProgress; private bool _adminNavigationInProgress;
private bool _stateLoadFailed;
private bool _scannerReconnectPending;
private int _failedPinAttempts;
private DateTimeOffset _pinBlockedUntilUtc;
private string? _registrationEmployeeId; private string? _registrationEmployeeId;
private DateTimeOffset _adminSessionExpiresAtUtc; private DateTimeOffset _adminSessionExpiresAtUtc;
private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5); private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5);
@ -27,8 +31,9 @@ public sealed partial class MainPage : Page
InitializeComponent(); InitializeComponent();
_scanner.StatusChanged += message => Enqueue(() => SetStatus(message)); _scanner.StatusChanged += message => Enqueue(() => SetStatus(message));
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3")); _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3"));
_scanner.RegistrationCompleted += (id, template) => Enqueue(() => _ = CompleteRegistrationAsync(id, template)); _scanner.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template));
_scanner.FingerRecognized += (id, score) => Enqueue(() => _ = HandleRecognizedFingerAsync(id, score)); _scanner.FingerRecognized += (id, score) => EnqueueTask(() => HandleRecognizedFingerAsync(id, score));
_scanner.ConnectionLost += () => EnqueueTask(ReconnectScannerAsync);
_adminSessionTimer = DispatcherQueue.CreateTimer(); _adminSessionTimer = DispatcherQueue.CreateTimer();
_adminSessionTimer.Interval = TimeSpan.FromSeconds(15); _adminSessionTimer.Interval = TimeSpan.FromSeconds(15);
_adminSessionTimer.IsRepeating = true; _adminSessionTimer.IsRepeating = true;
@ -46,7 +51,19 @@ public sealed partial class MainPage : Page
{ {
if (_loaded) return; if (_loaded) return;
_loaded = true; _loaded = true;
try
{
_state = await _store.LoadAsync(); _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(); LoadSettings();
ApplyStartupSetting(); ApplyStartupSetting();
RefreshLists(); RefreshLists();
@ -56,10 +73,31 @@ public sealed partial class MainPage : Page
private void Page_Unloaded(object sender, RoutedEventArgs e) private void Page_Unloaded(object sender, RoutedEventArgs e)
{ {
_loaded = false;
_adminSessionTimer.Stop(); _adminSessionTimer.Stop();
_scanner.Dispose(); _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) private async void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e)
{ {
if (_adminNavigationInProgress || ReferenceEquals(MainTabs.SelectedItem, TerminalTab)) return; if (_adminNavigationInProgress || ReferenceEquals(MainTabs.SelectedItem, TerminalTab)) return;
@ -88,6 +126,7 @@ public sealed partial class MainPage : Page
private void LockAdminSession(string message) private void LockAdminSession(string message)
{ {
CancelRegistration();
_adminSessionActive = false; _adminSessionActive = false;
_adminSessionExpiresAtUtc = default; _adminSessionExpiresAtUtc = default;
_adminAuthCompletion?.TrySetResult(false); _adminAuthCompletion?.TrySetResult(false);
@ -109,6 +148,11 @@ public sealed partial class MainPage : Page
private async Task<bool> EnsureAdminSessionAsync(string operation) private async Task<bool> EnsureAdminSessionAsync(string operation)
{ {
if (_stateLoadFailed)
{
SetStatus("Административные действия заблокированы: хранилище данных не открыто.", InfoBarSeverity.Error);
return false;
}
if (HasActiveAdminSession) if (HasActiveAdminSession)
{ {
TouchAdminSession(); TouchAdminSession();
@ -157,9 +201,10 @@ public sealed partial class MainPage : Page
return false; return false;
} }
var pin = AdminPinService.Hash(pinBox.Password); var pin = await Task.Run(() => AdminPinService.Hash(pinBox.Password));
_state.Settings.AdminPinSalt = pin.Salt; _state.Settings.AdminPinSalt = pin.Salt;
_state.Settings.AdminPinHash = pin.Hash; _state.Settings.AdminPinHash = pin.Hash;
_state.Settings.AdminPinIterations = pin.Iterations;
await _store.SaveAsync(_state); await _store.SaveAsync(_state);
return true; return true;
} }
@ -181,7 +226,7 @@ public sealed partial class MainPage : Page
if (_state.Settings.ReaderIndex != _scanner.ReaderIndex) if (_state.Settings.ReaderIndex != _scanner.ReaderIndex)
{ {
_state.Settings.ReaderIndex = _scanner.ReaderIndex; _state.Settings.ReaderIndex = _scanner.ReaderIndex;
_ = _store.SaveAsync(_state); SaveStateInBackground();
} }
if (_scanner.SkippedTemplateCount > 0) if (_scanner.SkippedTemplateCount > 0)
{ {
@ -246,6 +291,96 @@ public sealed partial class MainPage : Page
SetStatus($"{name} добавлен. Теперь зарегистрируйте отпечаток.", InfoBarSeverity.Success); 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) private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
{ {
if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return; if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return;
@ -268,6 +403,31 @@ public sealed partial class MainPage : Page
_scanner.BeginRegistration(fingerprintId); _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) private async Task CompleteRegistrationAsync(int fingerprintId, string template)
{ {
var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId); var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId);
@ -390,7 +550,7 @@ public sealed partial class MainPage : Page
} }
else else
{ {
SetStatus($"{employee.DisplayName} не имеет прав администратора.", InfoBarSeverity.Warning); await RecordAttendanceAsync(employee, score);
} }
return; return;
} }
@ -483,6 +643,12 @@ public sealed partial class MainPage : Page
private async Task<bool> AuthorizeAdminAsync(string operation) private async Task<bool> 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 hasAdministratorFingerprint = AdministratorFingerprintConfigured;
var pinBox = new PasswordBox var pinBox = new PasswordBox
{ {
@ -514,9 +680,34 @@ public sealed partial class MainPage : Page
var fingerprintAuthorized = _adminAuthCompletion.Task.IsCompletedSuccessfully && var fingerprintAuthorized = _adminAuthCompletion.Task.IsCompletedSuccessfully &&
_adminAuthCompletion.Task.Result; _adminAuthCompletion.Task.Result;
_adminAuthCompletion.TrySetResult(false); _adminAuthCompletion.TrySetResult(false);
var authorized = fingerprintAuthorized || var pinAuthorized = false;
(dialogResult == ContentDialogResult.Primary && if (dialogResult == ContentDialogResult.Primary)
AdminPinService.Verify(pinBox.Password, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash)); {
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; _adminAuthCompletion = null;
_adminAuthDialog = null; _adminAuthDialog = null;
SetStatus(authorized SetStatus(authorized
@ -536,9 +727,10 @@ public sealed partial class MainPage : Page
return; return;
} }
if (!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && !await AuthorizeAdminAsync("изменение PIN")) 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.AdminPinSalt = pin.Salt;
_state.Settings.AdminPinHash = pin.Hash; _state.Settings.AdminPinHash = pin.Hash;
_state.Settings.AdminPinIterations = pin.Iterations;
NewAdminPinBox.Password = ""; NewAdminPinBox.Password = "";
await _store.SaveAsync(_state); await _store.SaveAsync(_state);
SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success); 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 static void Enqueue(Action action) => App.DispatcherQueue.TryEnqueue(() => action());
private void EnqueueTask(Func<Task> 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);
}
}
} }

View file

@ -57,6 +57,12 @@ public sealed partial class MainWindow : Window
Activate(); Activate();
} }
public void HideToTray()
{
if (RootFrame.Content is MainPage page) page.LockAdminSession();
AppWindow.Hide();
}
public async Task RequestExitAsync() public async Task RequestExitAsync()
{ {
if (RootFrame.Content is MainPage page && await page.AuthorizeExitAsync()) if (RootFrame.Content is MainPage page && await page.AuthorizeExitAsync())

View file

@ -66,6 +66,7 @@ public sealed class TerminalSettings
public bool StartWithWindows { get; set; } = true; public bool StartWithWindows { get; set; } = true;
public string AdminPinSalt { get; set; } = ""; public string AdminPinSalt { get; set; } = "";
public string AdminPinHash { get; set; } = ""; public string AdminPinHash { get; set; } = "";
public int AdminPinIterations { get; set; } = 120_000;
public int DuplicateScanGuardSeconds { get; set; } = 120; public int DuplicateScanGuardSeconds { get; set; } = 120;
public bool TelegramEnabled { get; set; } public bool TelegramEnabled { get; set; }
public string TelegramBotToken { get; set; } = ""; public string TelegramBotToken { get; set; } = "";

View file

@ -5,14 +5,14 @@
## Возможности ## Возможности
- выбор одного из подключённых сканеров с автоматическим переходом на рабочее устройство; - выбор одного из подключённых сканеров с автоматическим переходом на рабочее устройство;
- добавление сотрудников с должностью, полом и правами администратора; - добавление и редактирование сотрудников с должностью, полом и правами администратора без потери отпечатков и журнала;
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам; - несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
- чередование событий «Приход» и «Уход»; - чередование событий «Приход» и «Уход»;
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд; - настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
- локальный журнал последних отметок; - локальный журнал последних отметок;
- защищённая очистка журнала без удаления сотрудников и отпечатков; - защищённая очистка журнала без удаления сотрудников и отпечатков;
- уведомления через Telegram Bot API и MAX Bot API; - уведомления через Telegram Bot API и MAX Bot API;
- автозагрузка вместе с Windows и постоянная работа в системном трее; - автозагрузка сразу в системный трей и постоянное фоновое сканирование, даже когда открыта другая программа;
- закрытый административный раздел с входом по PIN или отпечатку администратора; - закрытый административный раздел с входом по PIN или отпечатку администратора;
- автоматическая блокировка административного раздела через 5 минут и при сворачивании; - автоматическая блокировка административного раздела через 5 минут и при сворачивании;
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI. - шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.

View file

@ -4,16 +4,16 @@ namespace KronaStaffTerminal.Services;
public static class AdminPinService 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 salt = RandomNumberGenerator.GetBytes(16);
var hash = Rfc2898DeriveBytes.Pbkdf2(pin, salt, Iterations, HashAlgorithmName.SHA256, 32); var hash = Rfc2898DeriveBytes.Pbkdf2(pin, salt, CurrentIterations, HashAlgorithmName.SHA256, 32);
return (Convert.ToBase64String(salt), Convert.ToBase64String(hash)); 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)) if (string.IsNullOrWhiteSpace(pin) || string.IsNullOrWhiteSpace(saltBase64) || string.IsNullOrWhiteSpace(hashBase64))
{ {
@ -24,7 +24,12 @@ public static class AdminPinService
{ {
var salt = Convert.FromBase64String(saltBase64); var salt = Convert.FromBase64String(saltBase64);
var expected = Convert.FromBase64String(hashBase64); 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); return CryptographicOperations.FixedTimeEquals(actual, expected);
} }
catch (FormatException) catch (FormatException)

View file

@ -10,8 +10,10 @@ public sealed class FingerprintScanner : IDisposable
private IntPtr _device = IntPtr.Zero; private IntPtr _device = IntPtr.Zero;
private IntPtr _database = IntPtr.Zero; private IntPtr _database = IntPtr.Zero;
private CancellationTokenSource? _captureCancellation; private CancellationTokenSource? _captureCancellation;
private Task? _captureTask;
private int _registrationCount; private int _registrationCount;
private int? _registrationFingerprintId; private int? _registrationFingerprintId;
private DateTimeOffset _registrationExpiresAtUtc;
private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue; private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue;
private bool _fingerOnSensor; private bool _fingerOnSensor;
@ -19,6 +21,7 @@ public sealed class FingerprintScanner : IDisposable
public event Action<int>? RegistrationProgress; public event Action<int>? RegistrationProgress;
public event Action<int, string>? RegistrationCompleted; public event Action<int, string>? RegistrationCompleted;
public event Action<string>? StatusChanged; public event Action<string>? StatusChanged;
public event Action? ConnectionLost;
public int DeviceCount { get; private set; } public int DeviceCount { get; private set; }
public int ReaderIndex { get; private set; } public int ReaderIndex { get; private set; }
@ -35,7 +38,9 @@ public sealed class FingerprintScanner : IDisposable
throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result})."); 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) if (DeviceCount <= 0)
{ {
zkfp2.Terminate(); zkfp2.Terminate();
@ -85,7 +90,9 @@ public sealed class FingerprintScanner : IDisposable
} }
_captureCancellation = new CancellationTokenSource(); _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} подключён. Приложите палец."); StatusChanged?.Invoke($"Сканер №{ReaderIndex + 1} подключён. Приложите палец.");
} }
@ -95,6 +102,7 @@ public sealed class FingerprintScanner : IDisposable
{ {
_registrationFingerprintId = fingerprintId; _registrationFingerprintId = fingerprintId;
_registrationCount = 0; _registrationCount = 0;
_registrationExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1);
} }
StatusChanged?.Invoke("Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла."); StatusChanged?.Invoke("Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла.");
@ -106,34 +114,51 @@ public sealed class FingerprintScanner : IDisposable
{ {
_registrationFingerprintId = null; _registrationFingerprintId = null;
_registrationCount = 0; _registrationCount = 0;
_registrationExpiresAtUtc = default;
} }
} }
private void CaptureLoop(CancellationToken cancellationToken) private void CaptureLoop(IntPtr device, IntPtr database, CancellationToken cancellationToken)
{ {
var parameter = new byte[4]; var parameter = new byte[4];
var size = 4; var size = 4;
var width = 0; var width = 0;
var height = 0; var height = 0;
zkfp2.GetParameters(_device, 1, parameter, ref size); zkfp2.GetParameters(device, 1, parameter, ref size);
zkfp2.ByteArray2Int(parameter, ref width); zkfp2.ByteArray2Int(parameter, ref width);
size = 4; size = 4;
zkfp2.GetParameters(_device, 2, parameter, ref size); zkfp2.GetParameters(device, 2, parameter, ref size);
zkfp2.ByteArray2Int(parameter, ref height); zkfp2.ByteArray2Int(parameter, ref height);
var image = new byte[Math.Max(1, width * height)]; var image = new byte[Math.Max(1, width * height)];
var captured = new byte[TemplateSize]; var captured = new byte[TemplateSize];
var nextHealthCheck = DateTimeOffset.UtcNow.AddSeconds(5);
try
{
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{ {
ExpireRegistrationIfNeeded();
if (DateTimeOffset.UtcNow >= nextHealthCheck)
{
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; var capturedLength = TemplateSize;
if (zkfp2.AcquireFingerprint(_device, image, captured, ref capturedLength) == 0) if (zkfp2.AcquireFingerprint(device, image, captured, ref capturedLength) == 0)
{ {
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
if (!_fingerOnSensor && now - _lastProcessedCapture > TimeSpan.FromMilliseconds(500)) if (!_fingerOnSensor && now - _lastProcessedCapture > TimeSpan.FromMilliseconds(500))
{ {
_fingerOnSensor = true; _fingerOnSensor = true;
_lastProcessedCapture = now; _lastProcessedCapture = now;
ProcessCapture(captured, capturedLength); ProcessCapture(database, captured, capturedLength);
} }
} }
else else
@ -143,14 +168,49 @@ public sealed class FingerprintScanner : IDisposable
Thread.Sleep(150); Thread.Sleep(150);
} }
} }
catch (Exception exception)
{
CrashLogService.Write(exception);
if (!cancellationToken.IsCancellationRequested)
{
StatusChanged?.Invoke("Ошибка сканера. Выполняется переподключение…");
ConnectionLost?.Invoke();
}
}
}
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) lock (_gate)
{ {
if (_registrationFingerprintId is int fingerprintId) 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("Это другой палец. Приложите тот же палец ещё раз."); StatusChanged?.Invoke("Это другой палец. Приложите тот же палец ещё раз.");
return; return;
@ -168,15 +228,16 @@ public sealed class FingerprintScanner : IDisposable
var merged = new byte[TemplateSize]; var merged = new byte[TemplateSize];
var mergedLength = 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) if (result == 0)
{ {
zkfp2.DBDel(_database, fingerprintId); zkfp2.DBDel(database, fingerprintId);
result = zkfp2.DBAdd(_database, fingerprintId, merged); result = zkfp2.DBAdd(database, fingerprintId, merged);
} }
_registrationFingerprintId = null; _registrationFingerprintId = null;
_registrationCount = 0; _registrationCount = 0;
_registrationExpiresAtUtc = default;
if (result == 0) if (result == 0)
{ {
RegistrationCompleted?.Invoke(fingerprintId, zkfp2.BlobToBase64(merged, mergedLength)); RegistrationCompleted?.Invoke(fingerprintId, zkfp2.BlobToBase64(merged, mergedLength));
@ -191,7 +252,7 @@ public sealed class FingerprintScanner : IDisposable
var foundId = 0; var foundId = 0;
var score = 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); FingerRecognized?.Invoke(foundId, score);
} }
@ -205,8 +266,16 @@ public sealed class FingerprintScanner : IDisposable
public void Disconnect() public void Disconnect()
{ {
_captureCancellation?.Cancel(); _captureCancellation?.Cancel();
try
{
_captureTask?.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException exception)
{
CrashLogService.Write(exception.Flatten());
}
_captureCancellation = null; _captureCancellation = null;
Thread.Sleep(200); _captureTask = null;
if (_database != IntPtr.Zero) if (_database != IntPtr.Zero)
{ {
zkfp2.DBFree(_database); zkfp2.DBFree(_database);

View file

@ -8,6 +8,7 @@ public sealed class TerminalStore
{ {
private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray(); private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private readonly SemaphoreSlim _saveGate = new(1, 1);
private readonly string _filePath = Path.Combine( private readonly string _filePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Krona", "Krona",
@ -15,14 +16,13 @@ public sealed class TerminalStore
"terminal-state.dat"); "terminal-state.dat");
public async Task<TerminalState> LoadAsync() public async Task<TerminalState> LoadAsync()
{
try
{ {
var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState(); var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState();
if (sourcePath is null) return new TerminalState(); if (sourcePath is null) return new TerminalState();
var encrypted = await File.ReadAllBytesAsync(sourcePath);
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); try
var state = JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions) ?? new TerminalState(); {
var state = await ReadStateAsync(sourcePath);
var migrated = MigrateFingerprints(state); var migrated = MigrateFingerprints(state);
if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
{ {
@ -30,10 +30,48 @@ public sealed class TerminalStore
} }
return state; 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<TerminalState> ReadStateAsync(string path)
{
var encrypted = await File.ReadAllBytesAsync(path);
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
return JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions)
?? throw new InvalidDataException("Файл состояния не содержит данных.");
} }
private static bool MigrateFingerprints(TerminalState state) private static bool MigrateFingerprints(TerminalState state)
@ -64,15 +102,51 @@ public sealed class TerminalStore
} }
public async Task SaveAsync(TerminalState state) public async Task SaveAsync(TerminalState state)
{
await _saveGate.WaitAsync();
try
{ {
var directory = Path.GetDirectoryName(_filePath)!; var directory = Path.GetDirectoryName(_filePath)!;
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions); var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions);
var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser); var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser);
var temporaryPath = _filePath + ".tmp"; var temporaryPath = _filePath + ".tmp";
await File.WriteAllBytesAsync(temporaryPath, encrypted); 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); File.Move(temporaryPath, _filePath, true);
} }
finally
{
_saveGate.Release();
}
}
private IEnumerable<string> 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() private static string? FindLegacyPackagedState()
{ {