feat(terminal): wire attendance tracker, tray states, fingerprint enrollment wizard, and re-authorization

This commit is contained in:
Ochenstarik 2026-09-24 16:28:20 +07:00
parent fecf7e5911
commit 71660745a5
3 changed files with 364 additions and 154 deletions

View file

@ -86,7 +86,7 @@ public partial class App : Application
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, ShowWindow, RequestSync);
_activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName); _activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName);
_activationCancellation = new CancellationTokenSource(); _activationCancellation = new CancellationTokenSource();
_activationTask = Task.Run(() => ListenForActivation(_activationCancellation.Token)); _activationTask = Task.Run(() => ListenForActivation(_activationCancellation.Token));
@ -123,6 +123,9 @@ public partial class App : Application
public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon(); public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon();
public void UpdateTray(TrayState state, int pendingCount, string? statusText = null) =>
_trayService?.UpdateState(state, pendingCount, statusText);
private static void ShowWindow() private static void ShowWindow()
{ {
if (Window is MainWindow mainWindow) 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() private static void RequestExit()
{ {
if (Window is MainWindow mainWindow) if (Window is MainWindow mainWindow)

View file

@ -15,6 +15,8 @@ public sealed partial class MainPage : Page
private readonly TerminalStore _store = new(); private readonly TerminalStore _store = new();
private readonly NotificationService _notifications = new(); private readonly NotificationService _notifications = new();
private readonly AttendanceSyncService _attendanceSync = new(); private readonly AttendanceSyncService _attendanceSync = new();
private readonly AttendanceTracker _attendanceTracker = new();
private readonly TerminalIpcClient _ipcClient = new();
private readonly FingerprintScanner _scanner = new(); private readonly FingerprintScanner _scanner = new();
private readonly IUpdateSource _updateSource = new ForgejoUpdateSource(); private readonly IUpdateSource _updateSource = new ForgejoUpdateSource();
private TerminalState _state = new(); private TerminalState _state = new();
@ -37,6 +39,7 @@ public sealed partial class MainPage : Page
private int? _attendanceSuppressedFingerprintId; private int? _attendanceSuppressedFingerprintId;
private DateTimeOffset _attendanceSuppressedUntilUtc; private DateTimeOffset _attendanceSuppressedUntilUtc;
private string? _registrationEmployeeId; private string? _registrationEmployeeId;
private string _registrationFingerLabel = "Отпечаток";
private bool _refreshingLists; private bool _refreshingLists;
private bool _applicationLocked = true; private bool _applicationLocked = true;
private string _lastAdminActor = "unknown"; private string _lastAdminActor = "unknown";
@ -55,7 +58,8 @@ public sealed partial class MainPage : Page
throw; throw;
} }
_scanner.StatusChanged += message => Enqueue(() => SetStatus(message)); _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.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template));
_scanner.FingerRecognized += (id, score) => EnqueueTask(() => HandleRecognizedFingerAsync(id, score)); _scanner.FingerRecognized += (id, score) => EnqueueTask(() => HandleRecognizedFingerAsync(id, score));
_scanner.ConnectionLost += () => EnqueueTask(ReconnectScannerAsync); _scanner.ConnectionLost += () => EnqueueTask(ReconnectScannerAsync);
@ -234,25 +238,33 @@ public sealed partial class MainPage : Page
} }
var enteredPin = UnlockPinBox.Password; var enteredPin = UnlockPinBox.Password;
var authorized = await Task.Run(() => var authorized = AdminPinService.VerifyAndRecord(
AdminPinService.Verify(enteredPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations) || enteredPin,
(!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) && _state.Settings,
AdminPinService.Verify(enteredPin.Trim(), _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations))); 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) if (authorized)
{ {
_state.Settings.FailedAdminPinAttempts = 0;
_state.Settings.AdminPinBlockedUntilUtc = default;
await _store.SaveAsync(_state);
UnlockApplication("local-pin", "Вход выполнен по PIN администратора."); UnlockApplication("local-pin", "Вход выполнен по PIN администратора.");
return; return;
} }
_state.Settings.FailedAdminPinAttempts++; LockScreenMessage.Text = errorMessage ?? "Неверный PIN.";
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);
} }
private void LockAdminSession(string message) private void LockAdminSession(string message)
@ -344,6 +356,24 @@ public sealed partial class MainPage : Page
return true; 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() private void ConnectScanner()
{ {
try try
@ -356,11 +386,12 @@ public sealed partial class MainPage : Page
try try
{ {
ReaderCombo.Items.Clear(); 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 finally
{ {
@ -371,16 +402,23 @@ public sealed partial class MainPage : Page
_state.Settings.ReaderIndex = _scanner.ReaderIndex; _state.Settings.ReaderIndex = _scanner.ReaderIndex;
SaveStateInBackground(); SaveStateInBackground();
} }
var activeDevice = _scanner.DiscoveredDevices.FirstOrDefault(d => d.Index == _scanner.ReaderIndex);
var deviceName = activeDevice?.DisplayName ?? $"SLK20R №{_scanner.ReaderIndex + 1}";
if (_scanner.SkippedTemplateCount > 0) if (_scanner.SkippedTemplateCount > 0)
{ {
SetStatus( SetStatus(
$"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.", $"Сканер {deviceName} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.",
InfoBarSeverity.Warning); InfoBarSeverity.Warning);
} }
else 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) catch (Exception exception)
{ {
@ -395,6 +433,8 @@ public sealed partial class MainPage : Page
_refreshingReaders = false; _refreshingReaders = false;
} }
SetStatus($"{exception.Message} Проверьте USB-подключение и нажмите «Найти сканеры».", InfoBarSeverity.Error); 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 DefaultButton = ContentDialogButton.Close
}; };
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
if (!await AuthorizeAdminAsync("деактивация сотрудника")) return;
employee.IsBlocked = true; employee.IsBlocked = true;
employee.BlockedAtUtc = DateTimeOffset.UtcNow; employee.BlockedAtUtc = DateTimeOffset.UtcNow;
@ -670,6 +711,54 @@ public sealed partial class MainPage : Page
SetStatus("Сканер не подключён.", InfoBarSeverity.Error); SetStatus("Сканер не подключён.", InfoBarSeverity.Error);
return; 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 var fingerprintId = _state.Employees
.SelectMany(item => item.Fingerprints) .SelectMany(item => item.Fingerprints)
.Select(item => item.Id) .Select(item => item.Id)
@ -677,6 +766,7 @@ public sealed partial class MainPage : Page
.Max() + 1; .Max() + 1;
_registrationEmployeeId = employee.Id; _registrationEmployeeId = employee.Id;
_scanner.BeginRegistration(fingerprintId); _scanner.BeginRegistration(fingerprintId);
SetStatus($"Мастер регистрации: приложите {_registrationFingerLabel} к сканеру 3 раза.", InfoBarSeverity.Informational);
} }
private void CancelRegistration_Click(object sender, RoutedEventArgs e) 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); var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId);
if (employee is null) return; 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, existingSlot.Id = fingerprintId;
Template = template, existingSlot.Template = template;
Label = $"Отпечаток {employee.Fingerprints.Count + 1}" existingSlot.EnrolledAtUtc = DateTimeOffset.UtcNow;
}); }
else
{
employee.Fingerprints.Add(new FingerprintRecord
{
Id = fingerprintId,
Template = template,
Label = _registrationFingerLabel,
EnrolledAtUtc = DateTimeOffset.UtcNow
});
}
_registrationEmployeeId = null; _registrationEmployeeId = null;
await _store.SaveAsync(_state, createRecoveryPoint: true); await _store.SaveAsync(_state, createRecoveryPoint: true);
RefreshLists(); RefreshLists();
TerminalMessage.Text = $"Новый отпечаток сотрудника «{TerminalDisplayName(employee)}» зарегистрирован"; ConnectScanner();
SetStatus($"Добавлен отпечаток №{employee.Fingerprints.Count} сотрудника «{employee.DisplayName}».", InfoBarSeverity.Success); TerminalMessage.Text = $"Отпечаток «{_registrationFingerLabel}» сотрудника «{TerminalDisplayName(employee)}» успешно сохранён";
SetStatus($"Отпечаток «{_registrationFingerLabel}» сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success);
} }
private async void DeleteFingerprints_Click(object sender, RoutedEventArgs e) private async void DeleteFingerprints_Click(object sender, RoutedEventArgs e)
@ -742,23 +846,45 @@ public sealed partial class MainPage : Page
return; return;
} }
var options = new List<string> { "Все зарегистрированные отпечатки" };
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 var dialog = new ContentDialog
{ {
XamlRoot = XamlRoot, XamlRoot = XamlRoot,
Title = "Удалить все отпечатки сотрудника?", Title = $"Удаление отпечатков: {employee.DisplayName}",
Content = $"{employee.DisplayName}: будет удалено отпечатков — {employee.Fingerprints.Count}. Сам сотрудник останется.", Content = new StackPanel { Spacing = 10, Children = { combo } },
PrimaryButtonText = "Удалить отпечатки", PrimaryButtonText = "Удалить",
CloseButtonText = "Отмена", CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close 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); await _store.SaveAsync(_state, createRecoveryPoint: true);
RefreshLists(); RefreshLists();
ConnectScanner(); ConnectScanner();
SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success);
} }
private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e) 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) private async Task RecordAttendanceAsync(EmployeeRecord employee, int score)
{ {
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var last = _state.Attendance var result = await _attendanceTracker.TryRecordAttendanceAsync(
.Where(item => item.EmployeeId == employee.Id) _state,
.OrderByDescending(item => item.OccurredAtUtc) employee,
.FirstOrDefault(); _scanner.ReaderIndex,
var guard = TimeSpan.FromSeconds(Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600)); now,
if (last is not null && now - last.OccurredAtUtc < guard) state => _store.SaveAsync(state));
if (!result.Success)
{ {
var remaining = Math.Max(1, (int)Math.Ceiling((guard - (now - last.OccurredAtUtc)).TotalSeconds)); if (result.IsDuplicateSuppressed)
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {remaining} сек."; {
SetStatus($"Повторный скан защищён: событие не создано. Последнее событие — «{last.EventType}».", InfoBarSeverity.Informational); TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {result.RemainingSeconds} сек.";
SetStatus(result.Message, InfoBarSeverity.Informational);
}
return; return;
} }
var maximumShift = TimeSpan.FromHours(Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48)); var record = result.Record!;
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);
RefreshLists(); RefreshLists();
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}"; TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{record.EventType} отмечен в {now.ToLocalTime():HH:mm}";
SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success); 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.NotificationDelivered = delivery.Delivered;
record.NotificationError = delivery.Error; record.NotificationError = delivery.Error;
// Try IPC enqueue first for background service
_ = _ipcClient.EnqueueAttendanceAsync(record);
await SyncPendingAttendanceAsync(); await SyncPendingAttendanceAsync();
await _store.SaveAsync(_state); await _store.SaveAsync(_state);
RefreshLists(); RefreshLists();
if (delivery.Error is not null) 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; 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; _automaticCheckoutInProgress = true;
try try
{ {
var automaticRecords = new List<(AttendanceRecord Record, EmployeeRecord Employee)>(); var nowUtc = DateTimeOffset.UtcNow;
foreach (var employee in _state.Employees) var created = await _attendanceTracker.RunScheduledCheckoutAsync(
{ _state,
var last = _state.Attendance nowUtc,
.Where(item => item.EmployeeId == employee.Id) state => _store.SaveAsync(state));
.OrderByDescending(item => item.OccurredAtUtc)
.FirstOrDefault();
if (last is null || last.EventType != "Приход") continue;
var checkoutUtc = NextAutomaticCheckoutAfter(last.OccurredAtUtc, hour, minute); if (created.Count == 0) return;
if (checkoutUtc > nowUtc) continue;
automaticRecords.Add((new AttendanceRecord
{
EmployeeId = employee.Id,
EmployeeName = employee.DisplayName,
OccurredAtUtc = checkoutUtc,
ReaderIndex = -1,
EventType = "Уход автоматически"
}, employee));
}
_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( AppendSecurityAudit(
"attendance.automatic_checkout", "attendance.automatic_checkout",
"succeeded", "succeeded",
$"scheduled={latestScheduleUtc:O}; records={automaticRecords.Count}"); $"scheduled={nowUtc:O}; records={created.Count}");
await _store.SaveAsync(_state);
RefreshLists(); RefreshLists();
TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {created.Count}";
SetStatus($"Автоматически закрыто смен: {created.Count}.", InfoBarSeverity.Informational);
if (automaticRecords.Count == 0) return; foreach (var record in created)
TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {automaticRecords.Count}";
SetStatus($"Автоматически закрыто смен: {automaticRecords.Count}.", InfoBarSeverity.Informational);
await Task.WhenAll(automaticRecords.Select(async item =>
{ {
var delivery = await _notifications.SendAttendanceAsync( _ = _ipcClient.EnqueueAttendanceAsync(record);
_state.Settings, var employee = _state.Employees.FirstOrDefault(e => e.Id == record.EmployeeId);
item.Employee, if (employee is not null)
item.Record.EventType, {
item.Record.OccurredAtUtc.ToLocalTime()); var delivery = await _notifications.SendAttendanceAsync(
item.Record.NotificationDelivered = delivery.Delivered; _state.Settings,
item.Record.NotificationError = delivery.Error; employee,
})); record.EventType,
record.OccurredAtUtc.ToLocalTime());
record.NotificationDelivered = delivery.Delivered;
record.NotificationError = delivery.Error;
}
}
await SyncPendingAttendanceAsync();
await _store.SaveAsync(_state); await _store.SaveAsync(_state);
RefreshLists(); RefreshLists();
} }
@ -1186,58 +1283,48 @@ public sealed partial class MainPage : Page
// PasswordBox is a WinUI object and must only be accessed from the UI // PasswordBox is a WinUI object and must only be accessed from the UI
// thread. Capture its value before performing PBKDF2 verification. // thread. Capture its value before performing PBKDF2 verification.
var enteredPin = pinBox.Password; var enteredPin = pinBox.Password;
string verifiedPin = enteredPin; string? pinErrorMessage = null;
var securityStateChanged = false;
if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor) if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor)
{ {
pinAuthorized = await Task.Run(() => pinAuthorized = AdminPinService.VerifyAndRecord(
AdminPinService.Verify( enteredPin,
enteredPin, _state.Settings,
_state.Settings.AdminPinSalt, DateTimeOffset.UtcNow,
_state.Settings.AdminPinHash, out pinErrorMessage,
_state.Settings.AdminPinIterations) || out _);
(!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) &&
AdminPinService.Verify( if (!pinAuthorized && !string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal))
enteredPin.Trim(), {
_state.Settings.AdminPinSalt, pinAuthorized = AdminPinService.VerifyAndRecord(
_state.Settings.AdminPinHash, enteredPin.Trim(),
_state.Settings.AdminPinIterations))); _state.Settings,
} DateTimeOffset.UtcNow,
var authorized = fingerprintAuthorized || pinAuthorized; out pinErrorMessage,
var securityStateChanged = false; out _);
if (authorized) }
{
_state.Settings.FailedAdminPinAttempts = 0;
_state.Settings.AdminPinBlockedUntilUtc = default;
securityStateChanged = true; securityStateChanged = true;
if (pinAuthorized) if (pinAuthorized)
{ {
_lastAdminActor = "local-pin"; _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++; _state.Settings.FailedAdminPinAttempts = 0;
var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10)); _state.Settings.AdminPinBlockedUntilUtc = default;
_state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
securityStateChanged = true; securityStateChanged = true;
} }
var authorized = fingerprintAuthorized || pinAuthorized;
if (securityStateChanged) await _store.SaveAsync(_state); if (securityStateChanged) await _store.SaveAsync(_state);
_adminAuthCompletion = null; _adminAuthCompletion = null;
_adminAuthDialog = null; _adminAuthDialog = null;
SetStatus(authorized SetStatus(authorized
? fingerprintAuthorized ? "Вход выполнен по отпечатку администратора." : "Вход выполнен по PIN администратора." ? fingerprintAuthorized ? "Вход выполнен по отпечатку администратора." : "Вход выполнен по PIN администратора."
: dialogResult == ContentDialogResult.Primary ? "Неверный PIN администратора." : "Вход отменён.", : dialogResult == ContentDialogResult.Primary ? (pinErrorMessage ?? "Неверный PIN администратора.") : "Вход отменён.",
authorized ? InfoBarSeverity.Success : dialogResult == ContentDialogResult.Primary ? InfoBarSeverity.Error : InfoBarSeverity.Warning); authorized ? InfoBarSeverity.Success : dialogResult == ContentDialogResult.Primary ? InfoBarSeverity.Error : InfoBarSeverity.Warning);
return authorized; return authorized;
} }
@ -1464,19 +1551,41 @@ public sealed partial class MainPage : Page
CurrentVersionText.Text = $"Текущая версия: {ForgejoUpdateSource.CurrentVersion.ToString(3)}"; CurrentVersionText.Text = $"Текущая версия: {ForgejoUpdateSource.CurrentVersion.ToString(3)}";
} }
private async Task SyncPendingAttendanceAsync() public async Task SyncPendingAttendanceAsync()
{ {
if (_attendanceSyncInProgress || _stateLoadFailed) return; if (_attendanceSyncInProgress || _stateLoadFailed) return;
_attendanceSyncInProgress = true; _attendanceSyncInProgress = true;
try try
{ {
await _attendanceSync.SyncPendingAsync(_state); var serviceAvailable = await _ipcClient.IsServiceAvailableAsync();
await _store.SaveAsync(_state); 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) catch (InvalidOperationException exception)
{ {
SetStatus(exception.Message, InfoBarSeverity.Warning); SetStatus(exception.Message, InfoBarSeverity.Warning);
} }
catch (Exception ex)
{
CrashLogService.Write(ex);
}
finally finally
{ {
_attendanceSyncInProgress = false; _attendanceSyncInProgress = false;

View file

@ -2,6 +2,14 @@ using System.Runtime.InteropServices;
namespace KronaStaffTerminal.Services; namespace KronaStaffTerminal.Services;
public enum TrayState
{
Normal,
NoScanner,
NoServer,
Error
}
public sealed class TrayService : IDisposable public sealed class TrayService : IDisposable
{ {
private const uint CallbackMessage = 0x8001; private const uint CallbackMessage = 0x8001;
@ -22,20 +30,35 @@ public sealed class TrayService : IDisposable
private const uint LrDefaultSize = 0x0040; private const uint LrDefaultSize = 0x0040;
private const uint OpenCommand = 1001; private const uint OpenCommand = 1001;
private const uint ExitCommand = 1002; private const uint ExitCommand = 1002;
private const uint StatusCommand = 1003;
private const uint SyncCommand = 1004;
private readonly Action _showWindow; private readonly Action _showWindow;
private readonly Action _requestExit; private readonly Action _requestExit;
private readonly Action? _showStatus;
private readonly Action? _requestSync;
private readonly WindowProcedure _windowProcedure; private readonly WindowProcedure _windowProcedure;
private readonly IntPtr _windowHandle; private readonly IntPtr _windowHandle;
private readonly IntPtr _iconHandle; private readonly IntPtr _iconHandle;
private NotifyIconData _iconData; private NotifyIconData _iconData;
private bool _disposed; 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; _showWindow = showWindow;
_requestExit = requestExit; _requestExit = requestExit;
_showStatus = showStatus;
_requestSync = requestSync;
_windowProcedure = HandleWindowMessage; _windowProcedure = HandleWindowMessage;
var className = $"KronaStaffTerminal.Tray.{Environment.ProcessId}"; var className = $"KronaStaffTerminal.Tray.{Environment.ProcessId}";
var windowClass = new WindowClass var windowClass = new WindowClass
{ {
@ -51,12 +74,43 @@ public sealed class TrayService : IDisposable
ShellNotifyIcon(NimAdd, ref _iconData); 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() public void ShowRunningBalloon()
{
ShowNotificationBalloon("Krona Staff", "Терминал продолжает работать в трее.", 1);
}
public void ShowNotificationBalloon(string title, string message, uint infoFlags = 1)
{ {
_iconData.Flags = NifInfo; _iconData.Flags = NifInfo;
_iconData.InfoTitle = "Krona Staff"; _iconData.InfoTitle = title.Length > 63 ? title[..63] : title;
_iconData.Info = "Терминал продолжает работать в трее."; _iconData.Info = message.Length > 255 ? message[..255] : message;
_iconData.InfoFlags = 1; _iconData.InfoFlags = infoFlags;
ShellNotifyIcon(NimModify, ref _iconData); ShellNotifyIcon(NimModify, ref _iconData);
} }
@ -94,13 +148,49 @@ public sealed class TrayService : IDisposable
{ {
var menu = CreatePopupMenu(); var menu = CreatePopupMenu();
AppendMenu(menu, MfString, OpenCommand, "Открыть Krona Staff"); 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, MfSeparator, 0, null);
AppendMenu(menu, MfString, ExitCommand, "Выход администратора…"); AppendMenu(menu, MfString, ExitCommand, "Выход администратора…");
GetCursorPos(out var point); GetCursorPos(out var point);
SetForegroundWindow(_windowHandle); SetForegroundWindow(_windowHandle);
var command = TrackPopupMenu(menu, TpmReturnCmd, point.X, point.Y, 0, _windowHandle, IntPtr.Zero); var command = TrackPopupMenu(menu, TpmReturnCmd, point.X, point.Y, 0, _windowHandle, IntPtr.Zero);
DestroyMenu(menu); DestroyMenu(menu);
if (command == OpenCommand) Dispatch(_showWindow); 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); if (command == ExitCommand) Dispatch(_requestExit);
} }