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();
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)

View file

@ -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;
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 = $"Отпечаток {employee.Fingerprints.Count + 1}"
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<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
{
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;
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)
{
_ = _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,
item.Employee,
item.Record.EventType,
item.Record.OccurredAtUtc.ToLocalTime());
item.Record.NotificationDelivered = delivery.Delivered;
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);
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(
pinAuthorized = AdminPinService.VerifyAndRecord(
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,
DateTimeOffset.UtcNow,
out pinErrorMessage,
out _);
if (!pinAuthorized && !string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal))
{
_state.Settings.FailedAdminPinAttempts = 0;
_state.Settings.AdminPinBlockedUntilUtc = default;
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)
}
}
else if (fingerprintAuthorized)
{
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)
{
_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
{
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;

View file

@ -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);
}