using KronaStaffTerminal.Models; using KronaStaffTerminal.Services; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; using System.Text; using Windows.Storage; using Windows.Storage.Pickers; namespace KronaStaffTerminal; 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 IFingerprintScanner _scanner; private readonly IUpdateSource _updateSource = new ForgejoUpdateSource(); /// Инъекция фабрики для тестирования. По умолчанию создаёт реальный FingerprintScanner. public static Func? ScannerFactory { get; set; } private TerminalState _state = new(); private bool _loaded; private TaskCompletionSource? _adminAuthCompletion; private ContentDialog? _adminAuthDialog; private readonly DispatcherQueueTimer _adminSessionTimer; private readonly DispatcherQueueTimer _updateTimer; private readonly DispatcherQueueTimer _automaticCheckoutTimer; private readonly CancellationTokenSource _updateCancellation = new(); private AvailableUpdate? _availableUpdate; private bool _updateCheckInProgress; private bool _automaticCheckoutInProgress; private bool _attendanceSyncInProgress; private bool _adminSessionActive; private bool _adminNavigationInProgress; private bool _stateLoadFailed; private bool _scannerReconnectPending; private bool _refreshingReaders; private int? _attendanceSuppressedFingerprintId; private DateTimeOffset _attendanceSuppressedUntilUtc; private string? _registrationEmployeeId; private string _registrationFingerLabel = "Отпечаток"; private bool _refreshingLists; private bool _applicationLocked = true; private string _lastAdminActor = "unknown"; private DateTimeOffset _adminSessionExpiresAtUtc; private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5); public MainPage() { try { InitializeComponent(); } catch (Exception exception) { CrashLogService.Write(exception); throw; } _scanner = ScannerFactory?.Invoke() ?? new FingerprintScanner(); _scanner.StatusChanged += message => Enqueue(() => SetStatus(message)); _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); _scanner.RegistrationCancelled += () => Enqueue(() => _registrationEmployeeId = null); _adminSessionTimer = DispatcherQueue.CreateTimer(); _adminSessionTimer.Interval = TimeSpan.FromSeconds(15); _adminSessionTimer.IsRepeating = true; _adminSessionTimer.Tick += (_, _) => { if (_adminSessionActive && DateTimeOffset.UtcNow >= _adminSessionExpiresAtUtc) { LockAdminSession("Сеанс администратора завершён после 5 минут бездействия."); } }; _adminSessionTimer.Start(); _updateTimer = DispatcherQueue.CreateTimer(); _updateTimer.Interval = TimeSpan.FromHours(6); _updateTimer.IsRepeating = true; _updateTimer.Tick += (_, _) => EnqueueTask(() => CheckForUpdatesAsync(false)); _automaticCheckoutTimer = DispatcherQueue.CreateTimer(); _automaticCheckoutTimer.Interval = TimeSpan.FromMinutes(1); _automaticCheckoutTimer.IsRepeating = true; _automaticCheckoutTimer.Tick += (_, _) => EnqueueTask(async () => { await RunAutomaticCheckoutIfDueAsync(); await SyncPendingAttendanceAsync(); }); } private async void Page_Loaded(object sender, RoutedEventArgs e) { if (_loaded) return; _loaded = true; try { _state = await _store.LoadAsync(); } catch (Exception exception) { _stateLoadFailed = true; CrashLogService.Write(exception); MainTabs.SelectedItem = TerminalTab; TerminalMessage.Text = "Хранилище данных не открыто\nОбратитесь к администратору"; SetStatus(exception.Message, InfoBarSeverity.Error); return; } LoadSettings(); ApplyStartupSetting(); RefreshLists(); ConnectScanner(); _updateTimer.Start(); _automaticCheckoutTimer.Start(); MainTabs.SelectedItem = TerminalTab; LockScreenMessage.Text = SecurityConfigured ? "Введите PIN администратора или приложите зарегистрированный палец." : "Первый запуск: нажмите «Войти», чтобы создать PIN администратора."; if (_store.RecoveryNotice is not null) { SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning); } await RunAutomaticCheckoutIfDueAsync(); await SyncPendingAttendanceAsync(); await CheckForUpdatesAsync(false); } private void Page_Unloaded(object sender, RoutedEventArgs e) { _loaded = false; _adminSessionTimer.Stop(); _updateTimer.Stop(); _automaticCheckoutTimer.Stop(); _updateCancellation.Cancel(); _scanner.Dispose(); } private async Task ReconnectScannerAsync() { if (_scannerReconnectPending || !_loaded) return; _scannerReconnectPending = true; try { _scanner.Disconnect(); while (_loaded && !_scanner.IsConnected) { await Task.Delay(TimeSpan.FromSeconds(3)); if (!_loaded) return; ConnectScanner(); } } finally { _scannerReconnectPending = false; } } private async void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (_adminNavigationInProgress || ReferenceEquals(MainTabs.SelectedItem, TerminalTab)) return; if (HasActiveAdminSession) { TouchAdminSession(); return; } var requestedTab = MainTabs.SelectedItem; _adminNavigationInProgress = true; MainTabs.SelectedItem = TerminalTab; var authorized = await EnsureAdminSessionAsync("вход в административный раздел"); if (authorized) { MainTabs.SelectedItem = requestedTab; } _adminNavigationInProgress = false; } private void LogoutAdmin_Click(object sender, RoutedEventArgs e) => LockApplication("Программа заблокирована."); public void LockAdminSession() => LockApplication("Программа заблокирована при сворачивании."); private void LockApplication(string message) { LockAdminSession(message); _applicationLocked = true; if (ProtectedContent is not null) ProtectedContent.Visibility = Visibility.Collapsed; if (ApplicationLockScreen is not null) ApplicationLockScreen.Visibility = Visibility.Visible; if (UnlockPinBox is not null) UnlockPinBox.Password = ""; if (LockScreenMessage is not null) LockScreenMessage.Text = message + " Введите PIN или приложите палец администратора."; } private void UnlockApplication(string actor, string message) { _applicationLocked = false; _lastAdminActor = actor; _adminSessionActive = true; TouchAdminSession(); UnlockPinBox.Password = ""; ApplicationLockScreen.Visibility = Visibility.Collapsed; ProtectedContent.Visibility = Visibility.Visible; MainTabs.SelectedItem = TerminalTab; SetStatus(message, InfoBarSeverity.Success); } private async void UnlockApplication_Click(object sender, RoutedEventArgs e) => await UnlockApplicationByPinAsync(); private async void UnlockPinBox_KeyDown(object sender, KeyRoutedEventArgs e) { if (e.Key != Windows.System.VirtualKey.Enter) return; e.Handled = true; await UnlockApplicationByPinAsync(); } private async Task UnlockApplicationByPinAsync() { if (_stateLoadFailed) { LockScreenMessage.Text = "Хранилище не открыто. Доступ заблокирован."; return; } if (!SecurityConfigured) { if (await EnsureAdminSessionAsync("первичная настройка")) { UnlockApplication("initial-pin", "Первичная настройка выполнена."); } return; } var remainingLockout = _state.Settings.AdminPinBlockedUntilUtc - DateTimeOffset.UtcNow; if (remainingLockout > TimeSpan.Zero) { LockScreenMessage.Text = $"PIN временно заблокирован. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек. Можно приложить палец администратора."; return; } var enteredPin = UnlockPinBox.Password; 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) { UnlockApplication("local-pin", "Вход выполнен по PIN администратора."); return; } LockScreenMessage.Text = errorMessage ?? "Неверный PIN."; } private void LockAdminSession(string message) { CancelRegistration(); _adminSessionActive = false; _adminSessionExpiresAtUtc = default; _adminAuthCompletion?.TrySetResult(false); _adminAuthDialog?.Hide(); if (MainTabs is not null) { _adminNavigationInProgress = true; MainTabs.SelectedItem = TerminalTab; _adminNavigationInProgress = false; } if (_loaded) SetStatus(message, InfoBarSeverity.Informational); } private bool HasActiveAdminSession => _adminSessionActive && DateTimeOffset.UtcNow < _adminSessionExpiresAtUtc; private void TouchAdminSession() => _adminSessionExpiresAtUtc = DateTimeOffset.UtcNow.Add(AdminSessionDuration); private async Task EnsureAdminSessionAsync(string operation) { if (_stateLoadFailed) { SetStatus("Административные действия заблокированы: хранилище данных не открыто.", InfoBarSeverity.Error); return false; } if (HasActiveAdminSession) { TouchAdminSession(); return true; } var authorized = !PinConfigured && !AdministratorFingerprintConfigured ? await CreateInitialAdminPinAsync() : await AuthorizeAdminAsync(operation); if (!authorized) return false; _adminSessionActive = true; TouchAdminSession(); SetStatus(AdministratorEmployeeConfigured ? "Вход администратора выполнен. Сеанс закроется через 5 минут бездействия." : "Первичная настройка открыта. Добавьте первого сотрудника-администратора. Сканер можно подключить позже.", InfoBarSeverity.Success); return true; } private async Task CreateInitialAdminPinAsync() { var pinBox = new PasswordBox { Header = "Придумайте PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek }; var repeatBox = new PasswordBox { Header = "Повторите PIN", PasswordRevealMode = PasswordRevealMode.Peek }; var content = new StackPanel { Spacing = 12, MinWidth = 320 }; content.Children.Add(new TextBlock { Text = "Первичная настройка. PIN работает без сканера. После входа добавьте первого сотрудника-администратора; отпечаток можно зарегистрировать позже.", TextWrapping = TextWrapping.Wrap }); content.Children.Add(pinBox); content.Children.Add(repeatBox); var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Создание администратора", Content = content, PrimaryButtonText = "Создать PIN", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return false; if (pinBox.Password.Length < 6 || pinBox.Password != repeatBox.Password) { SetStatus("PIN должен содержать не менее 6 символов, оба значения должны совпадать.", InfoBarSeverity.Error); return false; } // WinUI controls are apartment-bound. Read the value on the UI thread // before moving the expensive password hashing to the thread pool. var enteredPin = pinBox.Password; var pin = await Task.Run(() => AdminPinService.Hash(enteredPin)); _state.Settings.AdminPinSalt = pin.Salt; _state.Settings.AdminPinHash = pin.Hash; _state.Settings.AdminPinIterations = pin.Iterations; await _store.SaveAsync(_state, createRecoveryPoint: true); _lastAdminActor = "initial-pin"; 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 { _scanner.Connect(_state.Settings.ReaderIndex, _state.Employees .Where(employee => employee.HasFingerprint && !employee.IsBlocked) .SelectMany(employee => employee.Fingerprints) .Select(fingerprint => (fingerprint.Id, fingerprint.Template))); _refreshingReaders = true; try { ReaderCombo.Items.Clear(); foreach (var device in _scanner.DiscoveredDevices) { ReaderCombo.Items.Add(device.DisplayName); } var selectedIdx = _scanner.DiscoveredDevices.ToList().FindIndex(d => d.Index == _scanner.ReaderIndex); ReaderCombo.SelectedIndex = selectedIdx >= 0 ? selectedIdx : 0; } finally { _refreshingReaders = false; } if (_state.Settings.ReaderIndex != _scanner.ReaderIndex) { _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( $"Сканер {deviceName} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.", InfoBarSeverity.Warning); } else { 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) { _refreshingReaders = true; try { ReaderCombo.Items.Clear(); ReaderCombo.SelectedIndex = -1; } finally { _refreshingReaders = false; } SetStatus($"{exception.Message} Проверьте USB-подключение и нажмите «Найти сканеры».", InfoBarSeverity.Error); var pendingCount = _state?.Attendance?.Count(a => !a.ServerSynced) ?? 0; ((App)Application.Current).UpdateTray(TrayState.NoScanner, pendingCount, "Сканер не подключён"); } } private void ScanReaders_Click(object sender, RoutedEventArgs e) => ConnectScanner(); private void ReaderCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (_refreshingReaders || !_loaded || ReaderCombo.SelectedIndex < 0) return; if (_scanner.IsConnected && ReaderCombo.SelectedIndex == _scanner.ReaderIndex) return; _state.Settings.ReaderIndex = ReaderCombo.SelectedIndex; ConnectScanner(); } private async void AddEmployee_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("добавление сотрудника")) return; var name = EmployeeNameBox.Text.Trim(); var number = PersonnelNumberBox.Text.Trim(); if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number)) { SetStatus("Заполните имя и табельный номер сотрудника.", InfoBarSeverity.Warning); return; } if (_state.Employees.Any(employee => employee.PersonnelNumber.Equals(number, StringComparison.OrdinalIgnoreCase))) { SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning); return; } if (AdministratorCheckBox.IsChecked == true && AdministratorEmployeeConfigured && !await AuthorizeAdminAsync("назначение нового администратора")) { return; } var employee = new EmployeeRecord { DisplayName = name, PersonnelNumber = number, Position = PositionBox.Text.Trim(), Gender = GenderComboBox.SelectedIndex switch { 1 => "Мужской", 2 => "Женский", _ => "" }, IsAdministrator = AdministratorCheckBox.IsChecked == true }; _state.Employees.Add(employee); await _store.SaveAsync(_state, createRecoveryPoint: true); EmployeeNameBox.Text = ""; PersonnelNumberBox.Text = ""; PositionBox.Text = ""; GenderComboBox.SelectedIndex = 0; AdministratorCheckBox.IsChecked = false; RefreshLists(); EmployeesList.SelectedItem = employee; SetStatus(employee.IsAdministrator ? $"{name} добавлен как администратор. Отпечаток можно зарегистрировать после подключения сканера." : $"{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 => "Женский", _ => "" }; await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); EmployeesList.SelectedItem = employee; SetStatus($"Данные сотрудника «{name}» обновлены. Отпечатки и журнал сохранены.", InfoBarSeverity.Success); } private async void EditEmployeeNotifications_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("настройка личных уведомлений")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } if (employee.IsBlocked) { SetStatus("Сначала разблокируйте сотрудника.", InfoBarSeverity.Warning); return; } var telegramBox = new TextBox { Header = "Telegram — личный chat_id", Text = employee.TelegramChatId, PlaceholderText = "Например, 123456789", MinWidth = 420 }; var vkBox = new TextBox { Header = "ВКонтакте — личный peer_id", Text = employee.VkPeerId, PlaceholderText = "ID пользователя в диалоге с сообществом" }; var maxBox = new TextBox { Header = "MAX — личный user_id", Text = employee.MaxUserId, PlaceholderText = "ID пользователя в диалоге с ботом" }; var content = new StackPanel { Spacing = 12 }; content.Children.Add(new TextBlock { Text = "Сообщения отправляются только в личный диалог этого сотрудника. Перед привязкой сотрудник должен открыть соответствующего бота и начать диалог.", TextWrapping = TextWrapping.Wrap }); content.Children.Add(telegramBox); content.Children.Add(vkBox); content.Children.Add(maxBox); var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = $"Личные уведомления: {employee.DisplayName}", Content = content, PrimaryButtonText = "Сохранить", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; employee.TelegramChatId = telegramBox.Text.Trim(); employee.VkPeerId = vkBox.Text.Trim(); employee.MaxUserId = maxBox.Text.Trim(); await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); EmployeesList.SelectedItem = employee; SetStatus($"Личные уведомления сотрудника «{employee.DisplayName}» сохранены: {employee.NotificationStatus}.", InfoBarSeverity.Success); } private async void ToggleEmployeeBlocked_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("блокировка сотрудника")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } if (!employee.IsBlocked && employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked) <= 1) { SetStatus("Нельзя заблокировать единственного активного администратора.", InfoBarSeverity.Warning); return; } if (!employee.IsBlocked) { var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = $"Заблокировать {employee.DisplayName}?", Content = "Отпечатки перестанут создавать отметки. Привязки Telegram, ВКонтакте и MAX будут удалены, новые сообщения отправляться не будут. История диалога в самом мессенджере останется у пользователя.", PrimaryButtonText = "Заблокировать", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; if (!await AuthorizeAdminAsync("деактивация сотрудника")) return; employee.IsBlocked = true; employee.BlockedAtUtc = DateTimeOffset.UtcNow; employee.TelegramChatId = ""; employee.VkPeerId = ""; employee.MaxUserId = ""; } else { employee.IsBlocked = false; employee.BlockedAtUtc = null; } await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); EmployeesList.SelectedItem = employee; ConnectScanner(); SetStatus(employee.IsBlocked ? $"Сотрудник «{employee.DisplayName}» заблокирован, личные каналы отвязаны." : $"Сотрудник «{employee.DisplayName}» разблокирован. Личные уведомления нужно подключить заново.", InfoBarSeverity.Success); } private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } if (!_scanner.IsConnected) { 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) .DefaultIfEmpty(0) .Max() + 1; _registrationEmployeeId = employee.Id; _scanner.BeginRegistration(fingerprintId); SetStatus($"Мастер регистрации: приложите {_registrationFingerLabel} к сканеру 3 раза.", InfoBarSeverity.Informational); } 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 (_refreshingLists) return; if (_registrationEmployeeId is null) return; if (EmployeesList.SelectedItem is EmployeeRecord employee && employee.Id == _registrationEmployeeId) return; CancelRegistration(); SetStatus("Регистрация отпечатка отменена из-за смены сотрудника.", InfoBarSeverity.Warning); } private void CancelRegistration() { _scanner.CancelRegistration(); _registrationEmployeeId = null; } private async Task CompleteRegistrationAsync(int fingerprintId, string template) { var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId); if (employee is null) return; var existingSlot = employee.Fingerprints.FirstOrDefault(f => f.Label == _registrationFingerLabel); if (existingSlot is not null) { 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(); ConnectScanner(); TerminalMessage.Text = $"Отпечаток «{_registrationFingerLabel}» сотрудника «{TerminalDisplayName(employee)}» успешно сохранён"; SetStatus($"Отпечаток «{_registrationFingerLabel}» сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success); } private async void DeleteFingerprints_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("удаление отпечатков")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } if (!employee.HasFingerprint) { SetStatus("У выбранного сотрудника нет зарегистрированных отпечатков.", InfoBarSeverity.Informational); return; } if (employee.IsAdministrator && !employee.IsBlocked && !PinConfigured && _state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked && item.HasFingerprint) <= 1) { SetStatus("Нельзя удалить последний способ входа администратора.", InfoBarSeverity.Warning); 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 = $"Удаление отпечатков: {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; 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(); } private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("изменение прав администратора")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } if (AdministratorEmployeeConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return; if (employee.IsAdministrator && !employee.IsBlocked && _state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked) <= 1) { SetStatus("Нельзя снять права с единственного администратора с зарегистрированным отпечатком.", InfoBarSeverity.Warning); return; } employee.IsAdministrator = !employee.IsAdministrator; await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); SetStatus(employee.IsAdministrator ? $"{employee.DisplayName} назначен администратором." : $"Права администратора у {employee.DisplayName} сняты.", InfoBarSeverity.Success); } private async void DeleteEmployee_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("удаление сотрудника")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Удалить сотрудника и отпечаток?", Content = $"{employee.DisplayName}, его отпечатки, право доступа и привязки личных уведомлений будут удалены из терминала. История диалога в самом мессенджере останется у пользователя.", PrimaryButtonText = "Продолжить", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; if (!SecurityConfigured) { SetStatus("Сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning); return; } if (!await AuthorizeAdminAsync("удаление сотрудника и отпечатка")) return; if (employee.IsAdministrator && !employee.IsBlocked && _state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked) <= 1) { SetStatus("Нельзя удалить единственного администратора.", InfoBarSeverity.Warning); return; } _state.Employees.Remove(employee); await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); ConnectScanner(); SetStatus("Сотрудник и его отпечаток удалены.", InfoBarSeverity.Success); } private async Task HandleRecognizedFingerAsync(int fingerprintId, int score) { var employee = _state.Employees.FirstOrDefault(item => item.Fingerprints.Any(fingerprint => fingerprint.Id == fingerprintId)); if (employee is null) return; if (employee.IsBlocked) { if (_applicationLocked) LockScreenMessage.Text = "Этот сотрудник заблокирован."; else SetStatus("Сотрудник заблокирован. Отметка не создана.", InfoBarSeverity.Warning); return; } if (_applicationLocked) { if (!employee.IsAdministrator) { await RecordAttendanceAsync(employee, score); LockScreenMessage.Text = "Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным."; return; } _attendanceSuppressedFingerprintId = fingerprintId; _attendanceSuppressedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3); UnlockApplication($"employee:{employee.Id}", $"Вход выполнен по отпечатку администратора {employee.DisplayName}."); return; } if (_adminAuthCompletion is not null) { if (employee.IsAdministrator) { // Some SDK/reader combinations can report the same touch more // than once while the authorization dialog is closing. Keep the // follow-up report out of attendance processing. _attendanceSuppressedFingerprintId = fingerprintId; _attendanceSuppressedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3); _lastAdminActor = $"employee:{employee.Id}"; TerminalMessage.Text = $"Администратор {TerminalDisplayName(employee)} подтверждён"; _adminAuthCompletion.TrySetResult(true); _adminAuthDialog?.Hide(); } else { SetStatus("Этот отпечаток не принадлежит администратору. Отметка прихода или ухода не создана.", InfoBarSeverity.Warning); } return; } if (_attendanceSuppressedFingerprintId == fingerprintId && DateTimeOffset.UtcNow < _attendanceSuppressedUntilUtc) { return; } _attendanceSuppressedFingerprintId = null; _attendanceSuppressedUntilUtc = default; await RecordAttendanceAsync(employee, score); } private async Task RecordAttendanceAsync(EmployeeRecord employee, int score) { var now = DateTimeOffset.UtcNow; var result = await _attendanceTracker.TryRecordAttendanceAsync( _state, employee, _scanner.ReaderIndex, now, state => _store.SaveAsync(state)); if (!result.Success) { if (result.IsDuplicateSuppressed) { TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {result.RemainingSeconds} сек."; SetStatus(result.Message, InfoBarSeverity.Informational); } return; } var record = result.Record!; RefreshLists(); 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, 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($"{record.EventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning); } } private async Task RunAutomaticCheckoutIfDueAsync() { if (!_loaded || _stateLoadFailed || _automaticCheckoutInProgress || !_state.Settings.AutomaticCheckoutEnabled) { return; } _automaticCheckoutInProgress = true; try { var nowUtc = DateTimeOffset.UtcNow; var created = await _attendanceTracker.RunScheduledCheckoutAsync( _state, nowUtc, state => _store.SaveAsync(state)); if (created.Count == 0) return; AppendSecurityAudit( "attendance.automatic_checkout", "succeeded", $"scheduled={nowUtc:O}; records={created.Count}"); RefreshLists(); TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {created.Count}"; SetStatus($"Автоматически закрыто смен: {created.Count}.", InfoBarSeverity.Informational); foreach (var record in created) { _ = _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(); } catch (Exception exception) { CrashLogService.Write(exception); SetStatus($"Автоматический уход не выполнен: {exception.Message}", InfoBarSeverity.Warning); } finally { _automaticCheckoutInProgress = false; } } private static DateTimeOffset LatestAutomaticCheckoutScheduleUtc(DateTimeOffset nowUtc, int hour, int minute) { var localNow = nowUtc.ToLocalTime(); var localSchedule = localNow.Date.AddHours(hour).AddMinutes(minute); if (localSchedule > localNow.DateTime) localSchedule = localSchedule.AddDays(-1); return LocalScheduleToUtc(localSchedule); } private static DateTimeOffset NextAutomaticCheckoutAfter(DateTimeOffset arrivalUtc, int hour, int minute) { var localArrival = arrivalUtc.ToLocalTime(); var localSchedule = localArrival.Date.AddHours(hour).AddMinutes(minute); if (localSchedule <= localArrival.DateTime) localSchedule = localSchedule.AddDays(1); return LocalScheduleToUtc(localSchedule); } private static DateTimeOffset LocalScheduleToUtc(DateTime localSchedule) { localSchedule = DateTime.SpecifyKind(localSchedule, DateTimeKind.Unspecified); if (TimeZoneInfo.Local.IsInvalidTime(localSchedule)) localSchedule = localSchedule.AddHours(1); return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(localSchedule, TimeZoneInfo.Local)); } private async void ClearAttendance_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("очистка журнала")) return; var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Очистить журнал отметок?", Content = $"Будут удалены все записи прихода и ухода: {_state.Attendance.Count}. Сотрудники и отпечатки останутся.", PrimaryButtonText = "Очистить журнал", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; if (!await AuthorizeAdminAsync("очистка журнала прихода и ухода")) return; _state.Attendance.Clear(); await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); TerminalMessage.Text = "Журнал отметок очищен"; SetStatus("Журнал прихода и ухода очищен.", InfoBarSeverity.Success); } private async void ExportAttendance_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("экспорт журнала")) return; var warning = new ContentDialog { XamlRoot = XamlRoot, Title = "Экспорт персональных данных", Content = "CSV-файл не шифруется и содержит ФИО, табельные номера, должности и время отметок. Сохраняйте его только в защищённом месте.", PrimaryButtonText = "Продолжить экспорт", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await warning.ShowAsync() != ContentDialogResult.Primary) return; _lastAdminActor = "unknown"; if (!await AuthorizeAdminAsync("экспорт персональных данных", requireIdentifiedActor: true)) return; var picker = new FileSavePicker { SuggestedFileName = $"krona-staff-attendance-{DateTime.Now:yyyy-MM-dd}" }; picker.FileTypeChoices.Add("CSV", new[] { ".csv" }); WinRT.Interop.InitializeWithWindow.Initialize(picker, App.WindowHandle); var file = await picker.PickSaveFileAsync(); if (file is null) return; AppendSecurityAudit("attendance.export", "started", $"records={_state.Attendance.Count}; file={file.Name}"); await _store.SaveAsync(_state); try { var csv = new StringBuilder("Сотрудник;Табельный номер;Должность;Событие;Время;Доставка\r\n"); foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc)) { var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId); csv.Append(Csv(item.EmployeeName)).Append(';') .Append(Csv(employee?.PersonnelNumber ?? "")).Append(';') .Append(Csv(employee?.Position ?? "")).Append(';') .Append(Csv(item.EventType)).Append(';') .Append(Csv(item.LocalTime)).Append(';') .Append(Csv(item.DeliveryStatus)).Append("\r\n"); } await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8); AppendSecurityAudit("attendance.export", "succeeded", $"records={_state.Attendance.Count}; file={file.Name}"); await _store.SaveAsync(_state); SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success); } catch (Exception exception) { AppendSecurityAudit("attendance.export", "failed", $"records={_state.Attendance.Count}; error={exception.GetType().Name}"); await _store.SaveAsync(_state); CrashLogService.Write(exception); SetStatus($"Экспорт не выполнен: {exception.Message}", InfoBarSeverity.Error); } } private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\""; public async Task AuthorizeExitAsync() { if (_stateLoadFailed) { SetStatus("Хранилище не открыто. Программа завершает работу без изменения данных.", InfoBarSeverity.Warning); return true; } if (!SecurityConfigured) { SetStatus("Выход заблокирован: сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning); return false; } return await AuthorizeAdminAsync("полное завершение терминала"); } private bool PinConfigured => !string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash); private bool AdministratorFingerprintConfigured => _state.Employees.Any(employee => employee.IsAdministrator && !employee.IsBlocked && employee.HasFingerprint); private bool AdministratorEmployeeConfigured => _state.Employees.Any(employee => employee.IsAdministrator && !employee.IsBlocked); private bool SecurityConfigured => PinConfigured || AdministratorFingerprintConfigured; private async Task AuthorizeAdminAsync(string operation, bool requireIdentifiedActor = false) { var remainingLockout = _state.Settings.AdminPinBlockedUntilUtc - DateTimeOffset.UtcNow; var pinBlocked = remainingLockout > TimeSpan.Zero; var hasAdministratorFingerprint = AdministratorFingerprintConfigured; if (requireIdentifiedActor && !hasAdministratorFingerprint) { SetStatus("Для экспорта персональных данных требуется отпечаток сотрудника-администратора.", InfoBarSeverity.Warning); return false; } if (pinBlocked && !hasAdministratorFingerprint) { SetStatus($"Слишком много неверных попыток PIN. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек.", InfoBarSeverity.Warning); return false; } var pinBox = new PasswordBox { Header = "PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek, MinWidth = 280, IsEnabled = !pinBlocked && !requireIdentifiedActor }; var content = new StackPanel { Spacing = 12, MinWidth = 320 }; content.Children.Add(new TextBlock { Text = requireIdentifiedActor ? "Для записи автора экспорта приложите зарегистрированный палец сотрудника-администратора." : hasAdministratorFingerprint ? pinBlocked ? $"PIN временно заблокирован ещё на {Math.Ceiling(remainingLockout.TotalSeconds)} сек. Приложите зарегистрированный палец администратора." : "Введите PIN или приложите зарегистрированный палец администратора." : "Введите PIN. После входа зарегистрируйте отпечаток администратора.", TextWrapping = TextWrapping.Wrap }); content.Children.Add(pinBox); _adminAuthCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _adminAuthDialog = new ContentDialog { XamlRoot = XamlRoot, Title = $"Вход администратора: {operation}", Content = content, PrimaryButtonText = "Войти по PIN", IsPrimaryButtonEnabled = !pinBlocked && !requireIdentifiedActor, CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; var dialogResult = await _adminAuthDialog.ShowAsync(); var fingerprintAuthorized = _adminAuthCompletion.Task.IsCompletedSuccessfully && _adminAuthCompletion.Task.Result; _adminAuthCompletion.TrySetResult(false); var pinAuthorized = false; // 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? pinErrorMessage = null; var securityStateChanged = false; if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor) { 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"; } } else if (fingerprintAuthorized) { _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 ? (pinErrorMessage ?? "Неверный PIN администратора.") : "Вход отменён.", authorized ? InfoBarSeverity.Success : dialogResult == ContentDialogResult.Primary ? InfoBarSeverity.Error : InfoBarSeverity.Warning); return authorized; } private async void SetAdminPin_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("изменение PIN")) return; var newPin = NewAdminPinBox.Password.Trim(); if (newPin.Length < 6) { SetStatus("PIN должен содержать не менее 6 символов.", InfoBarSeverity.Warning); return; } if (AdministratorEmployeeConfigured && PinConfigured && !await AuthorizeAdminAsync("изменение PIN")) return; var pin = await Task.Run(() => AdminPinService.Hash(newPin)); _state.Settings.AdminPinSalt = pin.Salt; _state.Settings.AdminPinHash = pin.Hash; _state.Settings.AdminPinIterations = pin.Iterations; NewAdminPinBox.Password = ""; await _store.SaveAsync(_state, createRecoveryPoint: true); SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success); } private async void SaveSettings_Click(object sender, RoutedEventArgs e) { // The settings tab itself is protected by the active admin session. // Saving must not start a second fingerprint authorization flow. if (!HasActiveAdminSession) { LockAdminSession("Сеанс администратора завершён. Войдите снова, чтобы сохранить настройки."); return; } TouchAdminSession(); CopySettingsFromControls(); await _store.SaveAsync(_state, createRecoveryPoint: true); ApplyStartupSetting(); ConnectScanner(); await RunAutomaticCheckoutIfDueAsync(); SetStatus("Настройки сохранены.", InfoBarSeverity.Success); } private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("проверка обновлений")) return; CopyUpdateSettingsFromControls(); await _store.SaveAsync(_state); await CheckForUpdatesAsync(true); } private async Task CheckForUpdatesAsync(bool reportUpToDate) { if (_updateCheckInProgress || (!_state.Settings.UpdateChecksEnabled && !reportUpToDate) || _updateCancellation.IsCancellationRequested) { return; } _updateCheckInProgress = true; try { var update = await _updateSource.FindUpdateAsync( _state.Settings, _updateCancellation.Token); _state.Settings.LastUpdateCheckUtc = DateTimeOffset.UtcNow; if (update is null) { _availableUpdate = null; UpdateBar.IsOpen = false; if (reportUpToDate) { SetStatus( $"Установлена последняя версия {ForgejoUpdateSource.CurrentVersion.ToString(3)}.", InfoBarSeverity.Success); } return; } _availableUpdate = update; UpdateBar.Message = $"{update.ReleaseName}. Установка доступна администратору."; UpdateBar.IsOpen = true; if (reportUpToDate) { SetStatus($"Доступна версия {update.DisplayVersion}.", InfoBarSeverity.Informational); } } catch (OperationCanceledException) when (_updateCancellation.IsCancellationRequested) { } catch (Exception exception) { CrashLogService.Write(exception); if (reportUpToDate) { SetStatus($"Не удалось проверить обновления: {exception.Message}", InfoBarSeverity.Warning); } } finally { _updateCheckInProgress = false; } } private async void InstallUpdate_Click(object sender, RoutedEventArgs e) { if (_availableUpdate is null) return; if (!await EnsureAdminSessionAsync("установка обновления")) return; var update = _availableUpdate; var notes = string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "После загрузки программа закроется и откроется установщик." : update.ReleaseNotes; var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = $"Установить Krona Staff {update.DisplayVersion}?", Content = new TextBlock { Text = notes, TextWrapping = TextWrapping.Wrap, MaxWidth = 520 }, PrimaryButtonText = "Скачать и установить", CloseButtonText = "Позже", DefaultButton = ContentDialogButton.Primary }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; try { UpdateBar.IsClosable = false; UpdateBar.Message = $"Загрузка версии {update.DisplayVersion}: 0%"; var progress = new Progress(value => UpdateBar.Message = $"Загрузка версии {update.DisplayVersion}: {value:P0}"); var installerPath = await _updateSource.DownloadAndVerifyAsync( update, _state.Settings, progress, _updateCancellation.Token); AppendSecurityAudit("application.update", "started", $"version={update.DisplayVersion}"); await _store.SaveAsync(_state, createRecoveryPoint: true); ForgejoUpdateSource.LaunchInstaller(installerPath); ((App)Application.Current).CompleteExit(); } catch (Exception exception) { CrashLogService.Write(exception); UpdateBar.IsClosable = true; SetStatus($"Обновление не установлено: {exception.Message}", InfoBarSeverity.Error); } } private async void TestNotification_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("проверка уведомлений")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Выберите сотрудника в разделе «Сотрудники», затем повторите тест.", InfoBarSeverity.Warning); return; } CopySettingsFromControls(); var result = await _notifications.SendTestAsync(_state.Settings, employee); SetStatus(result.Delivered ? $"Тестовое сообщение отправлено лично сотруднику «{employee.DisplayName}»." : result.Error ?? "У сотрудника не указан личный ID или канал выключен.", result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning); } private void CopySettingsFromControls() { _state.Settings.ReaderIndex = Math.Max(0, ReaderCombo.SelectedIndex); _state.Settings.StartWithWindows = StartWithWindowsSwitch.IsOn; _state.Settings.DuplicateScanGuardSeconds = double.IsNaN(DuplicateGuardBox.Value) ? 120 : Math.Clamp((int)DuplicateGuardBox.Value, 10, 3600); _state.Settings.MaximumShiftHours = double.IsNaN(MaximumShiftHoursBox.Value) ? 20 : Math.Clamp((int)MaximumShiftHoursBox.Value, 1, 48); _state.Settings.AutomaticCheckoutEnabled = AutomaticCheckoutSwitch.IsOn; _state.Settings.AutomaticCheckoutHour = Math.Clamp(AutomaticCheckoutTimePicker.Time.Hours, 0, 23); _state.Settings.AutomaticCheckoutMinute = Math.Clamp(AutomaticCheckoutTimePicker.Time.Minutes, 0, 59); _state.Settings.ShowEmployeeNamesOnTerminal = ShowEmployeeNamesSwitch.IsOn; _state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn; _state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim(); _state.Settings.VkEnabled = VkEnabledSwitch.IsOn; _state.Settings.VkGroupToken = VkTokenBox.Password.Trim(); _state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn; _state.Settings.MaxBotToken = MaxTokenBox.Password.Trim(); _state.Settings.StaffServerUrl = StaffServerUrlBox.Text.Trim(); _state.Settings.StaffServerToken = StaffServerTokenBox.Password.Trim(); _state.Settings.StaffServerCertificateSha256 = StaffServerCertificateBox.Text.Trim(); CopyUpdateSettingsFromControls(); } private void CopyUpdateSettingsFromControls() { _state.Settings.UpdateChecksEnabled = UpdateChecksSwitch.IsOn; _state.Settings.UpdateFeedUrl = UpdateFeedBox.Text.Trim(); _state.Settings.UpdateAccessToken = UpdateTokenBox.Password.Trim(); _state.Settings.UpdateTrustedSignerThumbprint = UpdateSignerThumbprintBox.Text.Trim(); } private void LoadSettings() { StartWithWindowsSwitch.IsOn = _state.Settings.StartWithWindows; DuplicateGuardBox.Value = Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600); MaximumShiftHoursBox.Value = Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48); AutomaticCheckoutSwitch.IsOn = _state.Settings.AutomaticCheckoutEnabled; AutomaticCheckoutTimePicker.Time = new TimeSpan( Math.Clamp(_state.Settings.AutomaticCheckoutHour, 0, 23), Math.Clamp(_state.Settings.AutomaticCheckoutMinute, 0, 59), 0); ShowEmployeeNamesSwitch.IsOn = _state.Settings.ShowEmployeeNamesOnTerminal; TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled; TelegramTokenBox.Password = _state.Settings.TelegramBotToken; VkEnabledSwitch.IsOn = _state.Settings.VkEnabled; VkTokenBox.Password = _state.Settings.VkGroupToken; MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled; MaxTokenBox.Password = _state.Settings.MaxBotToken; StaffServerUrlBox.Text = _state.Settings.StaffServerUrl; StaffServerTokenBox.Password = _state.Settings.StaffServerToken; StaffServerCertificateBox.Text = _state.Settings.StaffServerCertificateSha256; TerminalDeviceIdText.Text = $"Случайный ID терминала: {_state.Settings.TerminalDeviceId}"; UpdateChecksSwitch.IsOn = _state.Settings.UpdateChecksEnabled; UpdateFeedBox.Text = _state.Settings.UpdateFeedUrl; UpdateTokenBox.Password = _state.Settings.UpdateAccessToken; UpdateSignerThumbprintBox.Text = _state.Settings.UpdateTrustedSignerThumbprint; CurrentVersionText.Text = $"Текущая версия: {ForgejoUpdateSource.CurrentVersion.ToString(3)}"; } public async Task SyncPendingAttendanceAsync() { if (_attendanceSyncInProgress || _stateLoadFailed) return; _attendanceSyncInProgress = true; try { 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; } } private void ApplyStartupSetting() { try { StartupService.SetEnabled(_state.Settings.StartWithWindows); StartWithWindowsSwitch.IsOn = StartupService.IsEnabled(); } catch (Exception exception) { SetStatus($"Не удалось изменить автозагрузку: {exception.Message}", InfoBarSeverity.Warning); } } private void RefreshLists() { var selectedEmployeeId = (EmployeesList.SelectedItem as EmployeeRecord)?.Id; _refreshingLists = true; try { var employees = _state.Employees.OrderBy(employee => employee.DisplayName).ToList(); EmployeesList.ItemsSource = employees; EmployeesList.SelectedItem = employees.FirstOrDefault(employee => employee.Id == selectedEmployeeId); AttendanceList.ItemsSource = _state.Attendance .OrderByDescending(item => item.OccurredAtUtc) .Take(100) .Select(item => _state.Settings.ShowEmployeeNamesOnTerminal ? item : new AttendanceRecord { Id = item.Id, EmployeeId = item.EmployeeId, EmployeeName = MaskName(item.EmployeeName), OccurredAtUtc = item.OccurredAtUtc, ReaderIndex = item.ReaderIndex, EventType = item.EventType, NotificationDelivered = item.NotificationDelivered, NotificationError = item.NotificationError }) .ToList(); } finally { _refreshingLists = false; } } private string TerminalDisplayName(EmployeeRecord employee) => _state.Settings.ShowEmployeeNamesOnTerminal ? employee.DisplayName : MaskName(employee.DisplayName); private void AppendSecurityAudit(string action, string outcome, string detail) => _state.SecurityAudit.Add(new TerminalAuditRecord { Action = action, Actor = _lastAdminActor, Outcome = outcome, Detail = detail }); private static string MaskName(string name) => string.Join(' ', name .Split(' ', StringSplitOptions.RemoveEmptyEntries) .Select(part => part.Length == 1 ? $"{part[0]}…" : $"{part[0]}***")); private void SetStatus(string message, InfoBarSeverity severity = InfoBarSeverity.Informational) { StatusBar.Message = message; StatusBar.Severity = severity; StatusBar.IsOpen = true; } private static void Enqueue(Action action) => App.DispatcherQueue.TryEnqueue(() => action()); private void EnqueueTask(Func operation) => App.DispatcherQueue.TryEnqueue(async () => { try { await operation(); } catch (Exception exception) { CrashLogService.Write(exception); SetStatus($"Операция не выполнена: {exception.Message}", InfoBarSeverity.Error); } }); private async void SaveStateInBackground() { try { await _store.SaveAsync(_state); } catch (Exception exception) { CrashLogService.Write(exception); SetStatus($"Не удалось сохранить данные: {exception.Message}", InfoBarSeverity.Error); } } }