From 71660745a57bce66ca044a5fca403aaed6d8c170 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 16:28:20 +0700 Subject: [PATCH] feat(terminal): wire attendance tracker, tray states, fingerprint enrollment wizard, and re-authorization --- apps/KronaStaffTerminal/App.xaml.cs | 13 +- apps/KronaStaffTerminal/MainPage.xaml.cs | 407 +++++++++++------- .../Services/TrayService.cs | 98 ++++- 3 files changed, 364 insertions(+), 154 deletions(-) diff --git a/apps/KronaStaffTerminal/App.xaml.cs b/apps/KronaStaffTerminal/App.xaml.cs index 7a7d45f..523bbcc 100644 --- a/apps/KronaStaffTerminal/App.xaml.cs +++ b/apps/KronaStaffTerminal/App.xaml.cs @@ -86,7 +86,7 @@ public partial class App : Application Window = new MainWindow(); DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); - _trayService = new TrayService(ShowWindow, RequestExit); + _trayService = new TrayService(ShowWindow, RequestExit, ShowWindow, RequestSync); _activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName); _activationCancellation = new CancellationTokenSource(); _activationTask = Task.Run(() => ListenForActivation(_activationCancellation.Token)); @@ -123,6 +123,9 @@ public partial class App : Application public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon(); + public void UpdateTray(TrayState state, int pendingCount, string? statusText = null) => + _trayService?.UpdateState(state, pendingCount, statusText); + private static void ShowWindow() { if (Window is MainWindow mainWindow) @@ -131,6 +134,14 @@ public partial class App : Application } } + private static void RequestSync() + { + if (Window is MainWindow mainWindow && mainWindow.Content is FrameworkElement fe && fe.FindName("RootFrame") is Microsoft.UI.Xaml.Controls.Frame frame && frame.Content is MainPage page) + { + _ = page.SyncPendingAttendanceAsync(); + } + } + private static void RequestExit() { if (Window is MainWindow mainWindow) diff --git a/apps/KronaStaffTerminal/MainPage.xaml.cs b/apps/KronaStaffTerminal/MainPage.xaml.cs index 835b50a..99c4bde 100644 --- a/apps/KronaStaffTerminal/MainPage.xaml.cs +++ b/apps/KronaStaffTerminal/MainPage.xaml.cs @@ -15,6 +15,8 @@ public sealed partial class MainPage : Page private readonly TerminalStore _store = new(); private readonly NotificationService _notifications = new(); private readonly AttendanceSyncService _attendanceSync = new(); + private readonly AttendanceTracker _attendanceTracker = new(); + private readonly TerminalIpcClient _ipcClient = new(); private readonly FingerprintScanner _scanner = new(); private readonly IUpdateSource _updateSource = new ForgejoUpdateSource(); private TerminalState _state = new(); @@ -37,6 +39,7 @@ public sealed partial class MainPage : Page private int? _attendanceSuppressedFingerprintId; private DateTimeOffset _attendanceSuppressedUntilUtc; private string? _registrationEmployeeId; + private string _registrationFingerLabel = "Отпечаток"; private bool _refreshingLists; private bool _applicationLocked = true; private string _lastAdminActor = "unknown"; @@ -55,7 +58,8 @@ public sealed partial class MainPage : Page throw; } _scanner.StatusChanged += message => Enqueue(() => SetStatus(message)); - _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3")); + _scanner.StateChanged += (state, message) => Enqueue(() => HandleScannerStateChanged(state, message)); + _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Мастер регистрации: принят скан {count}/3. Уберите палец и приложите снова.")); _scanner.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template)); _scanner.FingerRecognized += (id, score) => EnqueueTask(() => HandleRecognizedFingerAsync(id, score)); _scanner.ConnectionLost += () => EnqueueTask(ReconnectScannerAsync); @@ -234,25 +238,33 @@ public sealed partial class MainPage : Page } var enteredPin = UnlockPinBox.Password; - var authorized = await Task.Run(() => - AdminPinService.Verify(enteredPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations) || - (!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) && - AdminPinService.Verify(enteredPin.Trim(), _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations))); + var authorized = AdminPinService.VerifyAndRecord( + enteredPin, + _state.Settings, + DateTimeOffset.UtcNow, + out var errorMessage, + out var rehashNeeded); + + if (!authorized && !string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal)) + { + authorized = AdminPinService.VerifyAndRecord( + enteredPin.Trim(), + _state.Settings, + DateTimeOffset.UtcNow, + out errorMessage, + out rehashNeeded); + } + + await _store.SaveAsync(_state); + UnlockPinBox.Password = ""; + if (authorized) { - _state.Settings.FailedAdminPinAttempts = 0; - _state.Settings.AdminPinBlockedUntilUtc = default; - await _store.SaveAsync(_state); UnlockApplication("local-pin", "Вход выполнен по PIN администратора."); return; } - _state.Settings.FailedAdminPinAttempts++; - var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10)); - _state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); - UnlockPinBox.Password = ""; - LockScreenMessage.Text = $"Неверный PIN. Следующая попытка через {delaySeconds} сек."; - await _store.SaveAsync(_state); + LockScreenMessage.Text = errorMessage ?? "Неверный PIN."; } private void LockAdminSession(string message) @@ -344,6 +356,24 @@ public sealed partial class MainPage : Page return true; } + private void HandleScannerStateChanged(ScannerState state, string message) + { + SetStatus(message, state switch + { + ScannerState.Connected => InfoBarSeverity.Success, + ScannerState.Busy => InfoBarSeverity.Informational, + ScannerState.DriverError => InfoBarSeverity.Error, + _ => InfoBarSeverity.Warning + }); + + var pendingCount = _state?.Attendance?.Count(a => !a.ServerSynced) ?? 0; + var trayState = state == ScannerState.Connected + ? (_state?.Attendance?.Any(a => !a.ServerSynced && a.ServerSyncError != null) == true ? TrayState.NoServer : TrayState.Normal) + : TrayState.NoScanner; + + ((App)Application.Current).UpdateTray(trayState, pendingCount, message); + } + private void ConnectScanner() { try @@ -356,11 +386,12 @@ public sealed partial class MainPage : Page try { ReaderCombo.Items.Clear(); - for (var index = 0; index < _scanner.DeviceCount; index++) + foreach (var device in _scanner.DiscoveredDevices) { - ReaderCombo.Items.Add($"SLK20R №{index + 1}"); + ReaderCombo.Items.Add(device.DisplayName); } - ReaderCombo.SelectedIndex = _scanner.ReaderIndex; + var selectedIdx = _scanner.DiscoveredDevices.ToList().FindIndex(d => d.Index == _scanner.ReaderIndex); + ReaderCombo.SelectedIndex = selectedIdx >= 0 ? selectedIdx : 0; } finally { @@ -371,16 +402,23 @@ public sealed partial class MainPage : Page _state.Settings.ReaderIndex = _scanner.ReaderIndex; SaveStateInBackground(); } + + var activeDevice = _scanner.DiscoveredDevices.FirstOrDefault(d => d.Index == _scanner.ReaderIndex); + var deviceName = activeDevice?.DisplayName ?? $"SLK20R №{_scanner.ReaderIndex + 1}"; + if (_scanner.SkippedTemplateCount > 0) { SetStatus( - $"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.", + $"Сканер {deviceName} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.", InfoBarSeverity.Warning); } else { - SetStatus($"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success); + SetStatus($"Сканер {deviceName} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success); } + + var pendingCount = _state.Attendance.Count(a => !a.ServerSynced); + ((App)Application.Current).UpdateTray(TrayState.Normal, pendingCount, $"Сканер {deviceName} подключён"); } catch (Exception exception) { @@ -395,6 +433,8 @@ public sealed partial class MainPage : Page _refreshingReaders = false; } SetStatus($"{exception.Message} Проверьте USB-подключение и нажмите «Найти сканеры».", InfoBarSeverity.Error); + var pendingCount = _state?.Attendance?.Count(a => !a.ServerSynced) ?? 0; + ((App)Application.Current).UpdateTray(TrayState.NoScanner, pendingCount, "Сканер не подключён"); } } @@ -634,6 +674,7 @@ public sealed partial class MainPage : Page DefaultButton = ContentDialogButton.Close }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + if (!await AuthorizeAdminAsync("деактивация сотрудника")) return; employee.IsBlocked = true; employee.BlockedAtUtc = DateTimeOffset.UtcNow; @@ -670,6 +711,54 @@ public sealed partial class MainPage : Page SetStatus("Сканер не подключён.", InfoBarSeverity.Error); return; } + + var fingerCombo = new ComboBox + { + Header = "Выберите палец для регистрации", + ItemsSource = new[] + { + "Указательный (правая рука)", + "Большой (правая рука)", + "Средний (правая рука)", + "Безымянный (правая рука)", + "Мизинец (правая рука)", + "Указательный (левая рука)", + "Большой (левая рука)", + "Средний (левая рука)", + "Безымянный (левая рука)", + "Мизинец (левая рука)", + "Дополнительный отпечаток" + }, + SelectedIndex = 0, + MinWidth = 280 + }; + + var wizardDialog = new ContentDialog + { + XamlRoot = XamlRoot, + Title = $"Мастер регистрации отпечатка: {employee.DisplayName}", + Content = new StackPanel + { + Spacing = 12, + Children = + { + new TextBlock + { + Text = "Шаг 1 из 3: Выберите палец для сканирования.\nПосле нажатия «Начать» приложите палец к сканеру 3 раза.", + TextWrapping = TextWrapping.Wrap + }, + fingerCombo + } + }, + PrimaryButtonText = "Начать регистрацию", + CloseButtonText = "Отмена", + DefaultButton = ContentDialogButton.Primary + }; + + if (await wizardDialog.ShowAsync() != ContentDialogResult.Primary) return; + + _registrationFingerLabel = (string)fingerCombo.SelectedItem; + var fingerprintId = _state.Employees .SelectMany(item => item.Fingerprints) .Select(item => item.Id) @@ -677,6 +766,7 @@ public sealed partial class MainPage : Page .Max() + 1; _registrationEmployeeId = employee.Id; _scanner.BeginRegistration(fingerprintId); + SetStatus($"Мастер регистрации: приложите {_registrationFingerLabel} к сканеру 3 раза.", InfoBarSeverity.Informational); } private void CancelRegistration_Click(object sender, RoutedEventArgs e) @@ -709,17 +799,31 @@ public sealed partial class MainPage : Page { var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId); if (employee is null) return; - employee.Fingerprints.Add(new FingerprintRecord + + var existingSlot = employee.Fingerprints.FirstOrDefault(f => f.Label == _registrationFingerLabel); + if (existingSlot is not null) { - Id = fingerprintId, - Template = template, - Label = $"Отпечаток {employee.Fingerprints.Count + 1}" - }); + existingSlot.Id = fingerprintId; + existingSlot.Template = template; + existingSlot.EnrolledAtUtc = DateTimeOffset.UtcNow; + } + else + { + employee.Fingerprints.Add(new FingerprintRecord + { + Id = fingerprintId, + Template = template, + Label = _registrationFingerLabel, + EnrolledAtUtc = DateTimeOffset.UtcNow + }); + } + _registrationEmployeeId = null; await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); - TerminalMessage.Text = $"Новый отпечаток сотрудника «{TerminalDisplayName(employee)}» зарегистрирован"; - SetStatus($"Добавлен отпечаток №{employee.Fingerprints.Count} сотрудника «{employee.DisplayName}».", InfoBarSeverity.Success); + ConnectScanner(); + TerminalMessage.Text = $"Отпечаток «{_registrationFingerLabel}» сотрудника «{TerminalDisplayName(employee)}» успешно сохранён"; + SetStatus($"Отпечаток «{_registrationFingerLabel}» сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success); } private async void DeleteFingerprints_Click(object sender, RoutedEventArgs e) @@ -742,23 +846,45 @@ public sealed partial class MainPage : Page return; } + var options = new List { "Все зарегистрированные отпечатки" }; + options.AddRange(employee.Fingerprints.Select(f => $"{f.Label} (ID {f.Id})")); + + var combo = new ComboBox + { + Header = "Выберите отпечаток для удаления", + ItemsSource = options, + SelectedIndex = 0, + MinWidth = 280 + }; + var dialog = new ContentDialog { XamlRoot = XamlRoot, - Title = "Удалить все отпечатки сотрудника?", - Content = $"{employee.DisplayName}: будет удалено отпечатков — {employee.Fingerprints.Count}. Сам сотрудник останется.", - PrimaryButtonText = "Удалить отпечатки", + Title = $"Удаление отпечатков: {employee.DisplayName}", + Content = new StackPanel { Spacing = 10, Children = { combo } }, + PrimaryButtonText = "Удалить", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; - if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; - if (!await AuthorizeAdminAsync("удаление отпечатков сотрудника")) return; - employee.Fingerprints.Clear(); + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + if (!await AuthorizeAdminAsync("удаление отпечатка сотрудника")) return; + + if (combo.SelectedIndex == 0) + { + employee.Fingerprints.Clear(); + SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success); + } + else + { + var chosenFingerprint = employee.Fingerprints[combo.SelectedIndex - 1]; + employee.Fingerprints.RemoveAt(combo.SelectedIndex - 1); + SetStatus($"Отпечаток «{chosenFingerprint.Label}» сотрудника «{employee.DisplayName}» удален.", InfoBarSeverity.Success); + } + await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); ConnectScanner(); - SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success); } private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e) @@ -878,50 +1004,41 @@ public sealed partial class MainPage : Page private async Task RecordAttendanceAsync(EmployeeRecord employee, int score) { var now = DateTimeOffset.UtcNow; - var last = _state.Attendance - .Where(item => item.EmployeeId == employee.Id) - .OrderByDescending(item => item.OccurredAtUtc) - .FirstOrDefault(); - var guard = TimeSpan.FromSeconds(Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600)); - if (last is not null && now - last.OccurredAtUtc < guard) + var result = await _attendanceTracker.TryRecordAttendanceAsync( + _state, + employee, + _scanner.ReaderIndex, + now, + state => _store.SaveAsync(state)); + + if (!result.Success) { - var remaining = Math.Max(1, (int)Math.Ceiling((guard - (now - last.OccurredAtUtc)).TotalSeconds)); - TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {remaining} сек."; - SetStatus($"Повторный скан защищён: событие не создано. Последнее событие — «{last.EventType}».", InfoBarSeverity.Informational); + if (result.IsDuplicateSuppressed) + { + TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {result.RemainingSeconds} сек."; + SetStatus(result.Message, InfoBarSeverity.Informational); + } return; } - var maximumShift = TimeSpan.FromHours(Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48)); - var eventType = last is not null && last.EventType == "Приход" && now - last.OccurredAtUtc <= maximumShift - ? "Уход" - : "Приход"; - var record = new AttendanceRecord - { - EmployeeId = employee.Id, - EmployeeName = employee.DisplayName, - OccurredAtUtc = now, - ReaderIndex = _scanner.ReaderIndex, - EventType = eventType - }; - _state.Attendance.Insert(0, record); - if (_state.Attendance.Count > 100_000) - { - _state.Attendance.RemoveRange(100_000, _state.Attendance.Count - 100_000); - } - await _store.SaveAsync(_state); + var record = result.Record!; RefreshLists(); - TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}"; - SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success); + TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{record.EventType} отмечен в {now.ToLocalTime():HH:mm}"; + SetStatus($"{record.EventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success); - var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee, eventType, now.ToLocalTime()); + var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee, record.EventType, now.ToLocalTime()); record.NotificationDelivered = delivery.Delivered; record.NotificationError = delivery.Error; + + // Try IPC enqueue first for background service + _ = _ipcClient.EnqueueAttendanceAsync(record); + await SyncPendingAttendanceAsync(); await _store.SaveAsync(_state); RefreshLists(); if (delivery.Error is not null) { - SetStatus($"{eventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning); + SetStatus($"{record.EventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning); } } @@ -933,63 +1050,43 @@ public sealed partial class MainPage : Page return; } - var nowUtc = DateTimeOffset.UtcNow; - var hour = Math.Clamp(_state.Settings.AutomaticCheckoutHour, 0, 23); - var minute = Math.Clamp(_state.Settings.AutomaticCheckoutMinute, 0, 59); - var latestScheduleUtc = LatestAutomaticCheckoutScheduleUtc(nowUtc, hour, minute); - if (_state.Settings.LastAutomaticCheckoutUtc >= latestScheduleUtc) return; - _automaticCheckoutInProgress = true; try { - var automaticRecords = new List<(AttendanceRecord Record, EmployeeRecord Employee)>(); - foreach (var employee in _state.Employees) - { - var last = _state.Attendance - .Where(item => item.EmployeeId == employee.Id) - .OrderByDescending(item => item.OccurredAtUtc) - .FirstOrDefault(); - if (last is null || last.EventType != "Приход") continue; + var nowUtc = DateTimeOffset.UtcNow; + var created = await _attendanceTracker.RunScheduledCheckoutAsync( + _state, + nowUtc, + state => _store.SaveAsync(state)); - var checkoutUtc = NextAutomaticCheckoutAfter(last.OccurredAtUtc, hour, minute); - if (checkoutUtc > nowUtc) continue; - automaticRecords.Add((new AttendanceRecord - { - EmployeeId = employee.Id, - EmployeeName = employee.DisplayName, - OccurredAtUtc = checkoutUtc, - ReaderIndex = -1, - EventType = "Уход автоматически" - }, employee)); - } + if (created.Count == 0) return; - _state.Attendance.InsertRange(0, automaticRecords.Select(item => item.Record)); - if (_state.Attendance.Count > 100_000) - { - _state.Attendance.RemoveRange(100_000, _state.Attendance.Count - 100_000); - } - _state.Settings.LastAutomaticCheckoutUtc = latestScheduleUtc; AppendSecurityAudit( "attendance.automatic_checkout", "succeeded", - $"scheduled={latestScheduleUtc:O}; records={automaticRecords.Count}"); - await _store.SaveAsync(_state); + $"scheduled={nowUtc:O}; records={created.Count}"); + RefreshLists(); + TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {created.Count}"; + SetStatus($"Автоматически закрыто смен: {created.Count}.", InfoBarSeverity.Informational); - if (automaticRecords.Count == 0) return; - TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {automaticRecords.Count}"; - SetStatus($"Автоматически закрыто смен: {automaticRecords.Count}.", InfoBarSeverity.Informational); - - await Task.WhenAll(automaticRecords.Select(async item => + foreach (var record in created) { - var delivery = await _notifications.SendAttendanceAsync( - _state.Settings, - item.Employee, - item.Record.EventType, - item.Record.OccurredAtUtc.ToLocalTime()); - item.Record.NotificationDelivered = delivery.Delivered; - item.Record.NotificationError = delivery.Error; - })); + _ = _ipcClient.EnqueueAttendanceAsync(record); + var employee = _state.Employees.FirstOrDefault(e => e.Id == record.EmployeeId); + if (employee is not null) + { + var delivery = await _notifications.SendAttendanceAsync( + _state.Settings, + employee, + record.EventType, + record.OccurredAtUtc.ToLocalTime()); + record.NotificationDelivered = delivery.Delivered; + record.NotificationError = delivery.Error; + } + } + + await SyncPendingAttendanceAsync(); await _store.SaveAsync(_state); RefreshLists(); } @@ -1186,58 +1283,48 @@ public sealed partial class MainPage : Page // PasswordBox is a WinUI object and must only be accessed from the UI // thread. Capture its value before performing PBKDF2 verification. var enteredPin = pinBox.Password; - string verifiedPin = enteredPin; + string? pinErrorMessage = null; + var securityStateChanged = false; + if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor) { - pinAuthorized = await Task.Run(() => - AdminPinService.Verify( - enteredPin, - _state.Settings.AdminPinSalt, - _state.Settings.AdminPinHash, - _state.Settings.AdminPinIterations) || - (!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) && - AdminPinService.Verify( - enteredPin.Trim(), - _state.Settings.AdminPinSalt, - _state.Settings.AdminPinHash, - _state.Settings.AdminPinIterations))); - } - var authorized = fingerprintAuthorized || pinAuthorized; - var securityStateChanged = false; - if (authorized) - { - _state.Settings.FailedAdminPinAttempts = 0; - _state.Settings.AdminPinBlockedUntilUtc = default; + pinAuthorized = AdminPinService.VerifyAndRecord( + enteredPin, + _state.Settings, + DateTimeOffset.UtcNow, + out pinErrorMessage, + out _); + + if (!pinAuthorized && !string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal)) + { + pinAuthorized = AdminPinService.VerifyAndRecord( + enteredPin.Trim(), + _state.Settings, + DateTimeOffset.UtcNow, + out pinErrorMessage, + out _); + } securityStateChanged = true; if (pinAuthorized) { _lastAdminActor = "local-pin"; - if (_state.Settings.AdminPinIterations < AdminPinService.CurrentIterations) - { - if (!AdminPinService.Verify(verifiedPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations)) - { - verifiedPin = verifiedPin.Trim(); - } - var upgraded = await Task.Run(() => AdminPinService.Hash(verifiedPin)); - _state.Settings.AdminPinSalt = upgraded.Salt; - _state.Settings.AdminPinHash = upgraded.Hash; - _state.Settings.AdminPinIterations = upgraded.Iterations; - } } } - else if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor) + else if (fingerprintAuthorized) { - _state.Settings.FailedAdminPinAttempts++; - var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10)); - _state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); + _state.Settings.FailedAdminPinAttempts = 0; + _state.Settings.AdminPinBlockedUntilUtc = default; securityStateChanged = true; } + + var authorized = fingerprintAuthorized || pinAuthorized; if (securityStateChanged) await _store.SaveAsync(_state); _adminAuthCompletion = null; _adminAuthDialog = null; + SetStatus(authorized ? fingerprintAuthorized ? "Вход выполнен по отпечатку администратора." : "Вход выполнен по PIN администратора." - : dialogResult == ContentDialogResult.Primary ? "Неверный PIN администратора." : "Вход отменён.", + : dialogResult == ContentDialogResult.Primary ? (pinErrorMessage ?? "Неверный PIN администратора.") : "Вход отменён.", authorized ? InfoBarSeverity.Success : dialogResult == ContentDialogResult.Primary ? InfoBarSeverity.Error : InfoBarSeverity.Warning); return authorized; } @@ -1464,19 +1551,41 @@ public sealed partial class MainPage : Page CurrentVersionText.Text = $"Текущая версия: {ForgejoUpdateSource.CurrentVersion.ToString(3)}"; } - private async Task SyncPendingAttendanceAsync() + public async Task SyncPendingAttendanceAsync() { if (_attendanceSyncInProgress || _stateLoadFailed) return; _attendanceSyncInProgress = true; try { - await _attendanceSync.SyncPendingAsync(_state); - await _store.SaveAsync(_state); + var serviceAvailable = await _ipcClient.IsServiceAvailableAsync(); + if (serviceAvailable) + { + var ipcResult = await _ipcClient.TriggerSyncAsync(); + _state = await _store.LoadAsync(); + } + else + { + await _attendanceSync.SyncPendingAsync(_state); + await _store.SaveAsync(_state); + } + + var pendingCount = _state.Attendance.Count(a => !a.ServerSynced); + var trayState = !_scanner.IsConnected + ? TrayState.NoScanner + : (_state.Attendance.Any(a => !a.ServerSynced && a.ServerSyncError != null) + ? TrayState.NoServer + : TrayState.Normal); + + ((App)Application.Current).UpdateTray(trayState, pendingCount); } catch (InvalidOperationException exception) { SetStatus(exception.Message, InfoBarSeverity.Warning); } + catch (Exception ex) + { + CrashLogService.Write(ex); + } finally { _attendanceSyncInProgress = false; diff --git a/apps/KronaStaffTerminal/Services/TrayService.cs b/apps/KronaStaffTerminal/Services/TrayService.cs index cf708fc..54a8603 100644 --- a/apps/KronaStaffTerminal/Services/TrayService.cs +++ b/apps/KronaStaffTerminal/Services/TrayService.cs @@ -2,6 +2,14 @@ using System.Runtime.InteropServices; namespace KronaStaffTerminal.Services; +public enum TrayState +{ + Normal, + NoScanner, + NoServer, + Error +} + public sealed class TrayService : IDisposable { private const uint CallbackMessage = 0x8001; @@ -22,20 +30,35 @@ public sealed class TrayService : IDisposable private const uint LrDefaultSize = 0x0040; private const uint OpenCommand = 1001; private const uint ExitCommand = 1002; + private const uint StatusCommand = 1003; + private const uint SyncCommand = 1004; private readonly Action _showWindow; private readonly Action _requestExit; + private readonly Action? _showStatus; + private readonly Action? _requestSync; private readonly WindowProcedure _windowProcedure; private readonly IntPtr _windowHandle; private readonly IntPtr _iconHandle; private NotifyIconData _iconData; private bool _disposed; - public TrayService(Action showWindow, Action requestExit) + private TrayState _currentState = TrayState.Normal; + private int _pendingSyncCount; + private string _currentStatusText = "Работает в штатном режиме"; + + public TrayService( + Action showWindow, + Action requestExit, + Action? showStatus = null, + Action? requestSync = null) { _showWindow = showWindow; _requestExit = requestExit; + _showStatus = showStatus; + _requestSync = requestSync; _windowProcedure = HandleWindowMessage; + var className = $"KronaStaffTerminal.Tray.{Environment.ProcessId}"; var windowClass = new WindowClass { @@ -51,12 +74,43 @@ public sealed class TrayService : IDisposable ShellNotifyIcon(NimAdd, ref _iconData); } + public void UpdateState(TrayState state, int pendingSyncCount, string? statusText = null) + { + _currentState = state; + _pendingSyncCount = pendingSyncCount; + if (!string.IsNullOrWhiteSpace(statusText)) + { + _currentStatusText = statusText; + } + + var stateLabel = state switch + { + TrayState.Normal => "Работает", + TrayState.NoScanner => "Сканер отключён", + TrayState.NoServer => "Сервер недоступен", + TrayState.Error => "Сбой", + _ => "В работе" + }; + + var tip = $"Krona Staff — {stateLabel} ({pendingSyncCount} в очереди)"; + if (tip.Length > 127) tip = tip[..127]; + + _iconData.Flags = NifTip | NifIcon; + _iconData.Tip = tip; + ShellNotifyIcon(NimModify, ref _iconData); + } + public void ShowRunningBalloon() + { + ShowNotificationBalloon("Krona Staff", "Терминал продолжает работать в трее.", 1); + } + + public void ShowNotificationBalloon(string title, string message, uint infoFlags = 1) { _iconData.Flags = NifInfo; - _iconData.InfoTitle = "Krona Staff"; - _iconData.Info = "Терминал продолжает работать в трее."; - _iconData.InfoFlags = 1; + _iconData.InfoTitle = title.Length > 63 ? title[..63] : title; + _iconData.Info = message.Length > 255 ? message[..255] : message; + _iconData.InfoFlags = infoFlags; ShellNotifyIcon(NimModify, ref _iconData); } @@ -94,13 +148,49 @@ public sealed class TrayService : IDisposable { var menu = CreatePopupMenu(); AppendMenu(menu, MfString, OpenCommand, "Открыть Krona Staff"); + + var statusSummary = _currentState switch + { + TrayState.Normal => "Состояние: Работает", + TrayState.NoScanner => "Состояние: Сканер отключён", + TrayState.NoServer => "Состояние: Нет связи с сервером", + TrayState.Error => "Состояние: Ошибка", + _ => $"Состояние: {_currentStatusText}" + }; + AppendMenu(menu, MfString, StatusCommand, statusSummary); + + var syncSummary = _pendingSyncCount > 0 + ? $"Синхронизация ({_pendingSyncCount} в очереди)" + : "Синхронизация (очередь пуста)"; + AppendMenu(menu, MfString, SyncCommand, syncSummary); + AppendMenu(menu, MfSeparator, 0, null); AppendMenu(menu, MfString, ExitCommand, "Выход администратора…"); + GetCursorPos(out var point); SetForegroundWindow(_windowHandle); var command = TrackPopupMenu(menu, TpmReturnCmd, point.X, point.Y, 0, _windowHandle, IntPtr.Zero); DestroyMenu(menu); + if (command == OpenCommand) Dispatch(_showWindow); + if (command == StatusCommand) + { + if (_showStatus is not null) + { + Dispatch(_showStatus); + } + else + { + ShowNotificationBalloon("Состояние терминала", _currentStatusText); + } + } + if (command == SyncCommand) + { + if (_requestSync is not null) + { + Dispatch(_requestSync); + } + } if (command == ExitCommand) Dispatch(_requestExit); }