fix: harden terminal scanner and background operation
Some checks are pending
ci / verify (push) Waiting to run
Some checks are pending
ci / verify (push) Waiting to run
This commit is contained in:
parent
14ed610d14
commit
b88ff9195c
9 changed files with 490 additions and 56 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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="" />
|
<FontIcon Glyph="" />
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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; } = "";
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue