diff --git a/apps/KronaStaffTerminal/App.xaml.cs b/apps/KronaStaffTerminal/App.xaml.cs
index a6ceb25..bb0de45 100644
--- a/apps/KronaStaffTerminal/App.xaml.cs
+++ b/apps/KronaStaffTerminal/App.xaml.cs
@@ -4,6 +4,7 @@ using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
+using KronaStaffTerminal.Services;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
@@ -15,6 +16,7 @@ namespace KronaStaffTerminal;
///
public partial class App : Application
{
+ private TrayService? _trayService;
///
/// The main application window. Use App.Window from any class that needs
/// the window reference (for dialogs, pickers, interop, etc.).
@@ -52,6 +54,36 @@ public partial class App : Application
{
Window = new MainWindow();
DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
+ _trayService = new TrayService(ShowWindow, RequestExit);
Window.Activate();
}
+
+ public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon();
+
+ private static void ShowWindow()
+ {
+ if (Window is MainWindow mainWindow)
+ {
+ mainWindow.ShowWindow();
+ }
+ }
+
+ private static void RequestExit()
+ {
+ if (Window is MainWindow mainWindow)
+ {
+ mainWindow.ShowWindow();
+ _ = mainWindow.RequestExitAsync();
+ }
+ }
+
+ public void CompleteExit()
+ {
+ _trayService?.Dispose();
+ _trayService = null;
+ if (Window is MainWindow mainWindow)
+ {
+ mainWindow.ExitApplication();
+ }
+ }
}
diff --git a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
index ee682ea..4e87419 100644
--- a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
+++ b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
@@ -10,7 +10,9 @@
win-$(Platform).pubxml
true
false
- true
+ false
+ None
+ true
enable
enable
x64
diff --git a/apps/KronaStaffTerminal/MainPage.xaml b/apps/KronaStaffTerminal/MainPage.xaml
index 7d1f7c5..2d494fd 100644
--- a/apps/KronaStaffTerminal/MainPage.xaml
+++ b/apps/KronaStaffTerminal/MainPage.xaml
@@ -47,12 +47,14 @@
-
-
+
+
+
-
-
+
+
+
@@ -72,6 +74,7 @@
+
@@ -82,6 +85,8 @@
+
+
@@ -91,11 +96,15 @@
-
+
+
+
-
+
+
+
@@ -109,6 +118,10 @@
+
+
+
+
diff --git a/apps/KronaStaffTerminal/MainPage.xaml.cs b/apps/KronaStaffTerminal/MainPage.xaml.cs
index d8258ad..555a3a8 100644
--- a/apps/KronaStaffTerminal/MainPage.xaml.cs
+++ b/apps/KronaStaffTerminal/MainPage.xaml.cs
@@ -12,14 +12,16 @@ public sealed partial class MainPage : Page
private readonly FingerprintScanner _scanner = new();
private TerminalState _state = new();
private bool _loaded;
+ private TaskCompletionSource? _adminAuthCompletion;
+ private ContentDialog? _adminAuthDialog;
public MainPage()
{
InitializeComponent();
_scanner.StatusChanged += message => Enqueue(() => SetStatus(message));
- _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3", InfoBarSeverity.Informational));
+ _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3"));
_scanner.RegistrationCompleted += (id, template) => Enqueue(() => _ = CompleteRegistrationAsync(id, template));
- _scanner.FingerRecognized += (id, score) => Enqueue(() => _ = RecordArrivalAsync(id, score));
+ _scanner.FingerRecognized += (id, score) => Enqueue(() => _ = HandleRecognizedFingerAsync(id, score));
}
private async void Page_Loaded(object sender, RoutedEventArgs e)
@@ -28,14 +30,12 @@ public sealed partial class MainPage : Page
_loaded = true;
_state = await _store.LoadAsync();
LoadSettings();
+ ApplyStartupSetting();
RefreshLists();
ConnectScanner();
}
- private void Page_Unloaded(object sender, RoutedEventArgs e)
- {
- _scanner.Dispose();
- }
+ private void Page_Unloaded(object sender, RoutedEventArgs e) => _scanner.Dispose();
private void ConnectScanner()
{
@@ -72,18 +72,31 @@ public sealed partial class MainPage : Page
SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning);
return;
}
+ if (AdministratorCheckBox.IsChecked == true && SecurityConfigured &&
+ !await AuthorizeAdminAsync("назначение нового администратора"))
+ {
+ return;
+ }
- var employee = new EmployeeRecord { DisplayName = name, PersonnelNumber = number };
+ var employee = new EmployeeRecord
+ {
+ DisplayName = name,
+ PersonnelNumber = number,
+ Position = PositionBox.Text.Trim(),
+ IsAdministrator = AdministratorCheckBox.IsChecked == true
+ };
_state.Employees.Add(employee);
await _store.SaveAsync(_state);
EmployeeNameBox.Text = "";
PersonnelNumberBox.Text = "";
+ PositionBox.Text = "";
+ AdministratorCheckBox.IsChecked = false;
RefreshLists();
EmployeesList.SelectedItem = employee;
SetStatus($"{name} добавлен. Теперь зарегистрируйте отпечаток.", InfoBarSeverity.Success);
}
- private void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
+ private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
{
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
@@ -95,7 +108,11 @@ public sealed partial class MainPage : Page
SetStatus("Сканер не подключён.", InfoBarSeverity.Error);
return;
}
-
+ if (employee.HasFingerprint && SecurityConfigured &&
+ !await AuthorizeAdminAsync("замена зарегистрированного отпечатка"))
+ {
+ return;
+ }
if (employee.FingerprintId <= 0)
{
employee.FingerprintId = _state.Employees.Select(item => item.FingerprintId).DefaultIfEmpty(0).Max() + 1;
@@ -114,6 +131,25 @@ public sealed partial class MainPage : Page
SetStatus($"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success);
}
+ private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e)
+ {
+ if (EmployeesList.SelectedItem is not EmployeeRecord employee)
+ {
+ SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
+ return;
+ }
+ if (SecurityConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return;
+ if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1)
+ {
+ SetStatus("Нельзя снять права с единственного администратора с зарегистрированным отпечатком.", InfoBarSeverity.Warning);
+ return;
+ }
+ employee.IsAdministrator = !employee.IsAdministrator;
+ await _store.SaveAsync(_state);
+ RefreshLists();
+ SetStatus(employee.IsAdministrator ? $"{employee.DisplayName} назначен администратором." : $"Права администратора у {employee.DisplayName} сняты.", InfoBarSeverity.Success);
+ }
+
private async void DeleteEmployee_Click(object sender, RoutedEventArgs e)
{
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
@@ -121,67 +157,177 @@ public sealed partial class MainPage : Page
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
return;
}
-
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
- Title = "Удалить сотрудника?",
- Content = $"{employee.DisplayName} и зарегистрированный отпечаток будут удалены из терминала.",
- PrimaryButtonText = "Удалить",
+ 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 && _state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1)
+ {
+ SetStatus("Нельзя удалить единственного администратора.", InfoBarSeverity.Warning);
+ return;
+ }
_state.Employees.Remove(employee);
await _store.SaveAsync(_state);
RefreshLists();
ConnectScanner();
- SetStatus("Сотрудник удалён.", InfoBarSeverity.Success);
+ SetStatus("Сотрудник и его отпечаток удалены.", InfoBarSeverity.Success);
}
- private async Task RecordArrivalAsync(int fingerprintId, int score)
+ private async Task HandleRecognizedFingerAsync(int fingerprintId, int score)
{
var employee = _state.Employees.FirstOrDefault(item => item.FingerprintId == fingerprintId);
if (employee is null) return;
- var now = DateTimeOffset.UtcNow;
- var alreadyRecorded = _state.Attendance.Any(item =>
- item.EmployeeId == employee.Id && item.OccurredAtUtc.ToLocalTime().Date == now.ToLocalTime().Date);
- if (alreadyRecorded)
+ if (_adminAuthCompletion is not null)
{
- TerminalMessage.Text = $"{employee.DisplayName}: приход сегодня уже отмечен";
- SetStatus($"Повторное сканирование распознано, новая отметка не создана. Точность: {score}.", InfoBarSeverity.Informational);
+ if (employee.IsAdministrator)
+ {
+ TerminalMessage.Text = $"Администратор {employee.DisplayName} подтверждён";
+ _adminAuthCompletion.TrySetResult(true);
+ _adminAuthDialog?.Hide();
+ }
+ else
+ {
+ SetStatus($"{employee.DisplayName} не имеет прав администратора.", InfoBarSeverity.Warning);
+ }
+ return;
+ }
+ await RecordAttendanceAsync(employee, score);
+ }
+
+ 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 remaining = Math.Max(1, (int)Math.Ceiling((guard - (now - last.OccurredAtUtc)).TotalSeconds));
+ TerminalMessage.Text = $"{employee.DisplayName}\nПовторный скан отклонён\nПовторите через {remaining} сек.";
+ SetStatus($"Повторный скан защищён: событие не создано. Последнее событие — «{last.EventType}».", InfoBarSeverity.Informational);
return;
}
+ var isNewDay = last is null || last.OccurredAtUtc.ToLocalTime().Date != now.ToLocalTime().Date;
+ var eventType = isNewDay || last!.EventType == "Уход" ? "Приход" : "Уход";
var record = new AttendanceRecord
{
EmployeeId = employee.Id,
EmployeeName = employee.DisplayName,
OccurredAtUtc = now,
- ReaderIndex = _scanner.ReaderIndex
+ ReaderIndex = _scanner.ReaderIndex,
+ EventType = eventType
};
_state.Attendance.Insert(0, record);
await _store.SaveAsync(_state);
RefreshLists();
- TerminalMessage.Text = $"Здравствуйте, {employee.DisplayName}!\nПриход отмечен в {now.ToLocalTime():HH:mm}";
- SetStatus($"Приход отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
+ TerminalMessage.Text = $"{employee.DisplayName}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}";
+ SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
- var delivery = await _notifications.SendArrivalAsync(_state.Settings, employee.DisplayName, now.ToLocalTime());
+ var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee.DisplayName, eventType, now.ToLocalTime());
record.NotificationDelivered = delivery.Delivered;
record.NotificationError = delivery.Error;
await _store.SaveAsync(_state);
RefreshLists();
if (delivery.Error is not null)
{
- SetStatus($"Приход сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning);
+ SetStatus($"{eventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning);
}
}
+ public async Task AuthorizeExitAsync()
+ {
+ if (!SecurityConfigured)
+ {
+ SetStatus("Выход заблокирован: сначала настройте PIN и отпечаток администратора.", InfoBarSeverity.Warning);
+ return false;
+ }
+ return await AuthorizeAdminAsync("полное завершение терминала");
+ }
+
+ private bool SecurityConfigured =>
+ !string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) &&
+ _state.Employees.Any(employee => employee.IsAdministrator && employee.HasFingerprint);
+
+ private async Task AuthorizeAdminAsync(string operation)
+ {
+ var pinBox = new PasswordBox { Header = "PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek, MinWidth = 280 };
+ var pinDialog = new ContentDialog
+ {
+ XamlRoot = XamlRoot,
+ Title = $"Подтверждение: {operation}",
+ Content = pinBox,
+ PrimaryButtonText = "Продолжить",
+ CloseButtonText = "Отмена",
+ DefaultButton = ContentDialogButton.Primary
+ };
+ if (await pinDialog.ShowAsync() != ContentDialogResult.Primary) return false;
+ if (!AdminPinService.Verify(pinBox.Password, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash))
+ {
+ SetStatus("Неверный PIN администратора.", InfoBarSeverity.Error);
+ return false;
+ }
+
+ _adminAuthCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _adminAuthDialog = new ContentDialog
+ {
+ XamlRoot = XamlRoot,
+ Title = "Приложите палец администратора",
+ Content = "PIN принят. Теперь приложите зарегистрированный палец сотрудника с правами администратора.",
+ CloseButtonText = "Отмена",
+ DefaultButton = ContentDialogButton.Close
+ };
+ _ = ObserveAdminDialogAsync(_adminAuthDialog, _adminAuthCompletion);
+ var authorized = await _adminAuthCompletion.Task;
+ _adminAuthCompletion = null;
+ _adminAuthDialog = null;
+ SetStatus(authorized ? "Действие подтверждено администратором." : "Подтверждение отменено.", authorized ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
+ return authorized;
+ }
+
+ private static async Task ObserveAdminDialogAsync(ContentDialog dialog, TaskCompletionSource completion)
+ {
+ await dialog.ShowAsync();
+ completion.TrySetResult(false);
+ }
+
+ private async void SetAdminPin_Click(object sender, RoutedEventArgs e)
+ {
+ var newPin = NewAdminPinBox.Password.Trim();
+ if (newPin.Length < 4)
+ {
+ SetStatus("PIN должен содержать не менее 4 символов.", InfoBarSeverity.Warning);
+ return;
+ }
+ if (!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && !await AuthorizeAdminAsync("изменение PIN")) return;
+ var pin = AdminPinService.Hash(newPin);
+ _state.Settings.AdminPinSalt = pin.Salt;
+ _state.Settings.AdminPinHash = pin.Hash;
+ NewAdminPinBox.Password = "";
+ await _store.SaveAsync(_state);
+ SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success);
+ }
+
private async void SaveSettings_Click(object sender, RoutedEventArgs e)
{
CopySettingsFromControls();
await _store.SaveAsync(_state);
+ ApplyStartupSetting();
ConnectScanner();
SetStatus("Настройки сохранены.", InfoBarSeverity.Success);
}
@@ -196,6 +342,8 @@ public sealed partial class MainPage : Page
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.TelegramEnabled = TelegramEnabledSwitch.IsOn;
_state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim();
_state.Settings.TelegramChatId = TelegramChatBox.Text.Trim();
@@ -206,6 +354,8 @@ public sealed partial class MainPage : Page
private void LoadSettings()
{
+ StartWithWindowsSwitch.IsOn = _state.Settings.StartWithWindows;
+ DuplicateGuardBox.Value = Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600);
TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled;
TelegramTokenBox.Password = _state.Settings.TelegramBotToken;
TelegramChatBox.Text = _state.Settings.TelegramChatId;
@@ -214,6 +364,19 @@ public sealed partial class MainPage : Page
MaxChatBox.Text = _state.Settings.MaxChatId;
}
+ private void ApplyStartupSetting()
+ {
+ try
+ {
+ StartupService.SetEnabled(_state.Settings.StartWithWindows);
+ StartWithWindowsSwitch.IsOn = StartupService.IsEnabled();
+ }
+ catch (Exception exception)
+ {
+ SetStatus($"Не удалось изменить автозагрузку: {exception.Message}", InfoBarSeverity.Warning);
+ }
+ }
+
private void RefreshLists()
{
EmployeesList.ItemsSource = null;
diff --git a/apps/KronaStaffTerminal/MainWindow.xaml.cs b/apps/KronaStaffTerminal/MainWindow.xaml.cs
index 163d19d..1704193 100644
--- a/apps/KronaStaffTerminal/MainWindow.xaml.cs
+++ b/apps/KronaStaffTerminal/MainWindow.xaml.cs
@@ -12,6 +12,8 @@ namespace KronaStaffTerminal;
///
public sealed partial class MainWindow : Window
{
+ private bool _allowClose;
+
public MainWindow()
{
InitializeComponent();
@@ -21,8 +23,37 @@ public sealed partial class MainWindow : Window
AppWindow.SetIcon("Assets/AppIcon.ico");
AppWindow.Resize(new Windows.Graphics.SizeInt32(1120, 760));
+ AppWindow.Closing += AppWindow_Closing;
// Navigate the root frame to the main page on startup.
RootFrame.Navigate(typeof(MainPage));
}
+
+ private void AppWindow_Closing(Microsoft.UI.Windowing.AppWindow sender, Microsoft.UI.Windowing.AppWindowClosingEventArgs args)
+ {
+ if (_allowClose) return;
+ args.Cancel = true;
+ AppWindow.Hide();
+ ((App)Application.Current).NotifyMinimizedToTray();
+ }
+
+ public void ShowWindow()
+ {
+ AppWindow.Show();
+ Activate();
+ }
+
+ public async Task RequestExitAsync()
+ {
+ if (RootFrame.Content is MainPage page && await page.AuthorizeExitAsync())
+ {
+ ((App)Application.Current).CompleteExit();
+ }
+ }
+
+ public void ExitApplication()
+ {
+ _allowClose = true;
+ Close();
+ }
}
diff --git a/apps/KronaStaffTerminal/Models/TerminalModels.cs b/apps/KronaStaffTerminal/Models/TerminalModels.cs
index a3d529d..9acacc9 100644
--- a/apps/KronaStaffTerminal/Models/TerminalModels.cs
+++ b/apps/KronaStaffTerminal/Models/TerminalModels.cs
@@ -7,6 +7,8 @@ public sealed class EmployeeRecord
public string Id { get; set; } = UlidGenerator.New();
public string PersonnelNumber { get; set; } = "";
public string DisplayName { get; set; } = "";
+ public string Position { get; set; } = "";
+ public bool IsAdministrator { get; set; }
public int FingerprintId { get; set; }
public string? FingerprintTemplate { get; set; }
@@ -15,6 +17,9 @@ public sealed class EmployeeRecord
[JsonIgnore]
public string FingerprintStatus => HasFingerprint ? "Отпечаток зарегистрирован" : "Отпечатка нет";
+
+ [JsonIgnore]
+ public string AccessStatus => IsAdministrator ? "Администратор" : "Сотрудник";
}
public sealed class AttendanceRecord
@@ -24,6 +29,7 @@ public sealed class AttendanceRecord
public string EmployeeName { get; set; } = "";
public DateTimeOffset OccurredAtUtc { get; set; }
public int ReaderIndex { get; set; }
+ public string EventType { get; set; } = "Приход";
public bool NotificationDelivered { get; set; }
public string? NotificationError { get; set; }
@@ -37,6 +43,10 @@ public sealed class AttendanceRecord
public sealed class TerminalSettings
{
public int ReaderIndex { get; set; }
+ public bool StartWithWindows { get; set; } = true;
+ public string AdminPinSalt { get; set; } = "";
+ public string AdminPinHash { get; set; } = "";
+ public int DuplicateScanGuardSeconds { get; set; } = 120;
public bool TelegramEnabled { get; set; }
public string TelegramBotToken { get; set; } = "";
public string TelegramChatId { get; set; } = "";
diff --git a/apps/KronaStaffTerminal/README.md b/apps/KronaStaffTerminal/README.md
index 5512cc3..9b927d9 100644
--- a/apps/KronaStaffTerminal/README.md
+++ b/apps/KronaStaffTerminal/README.md
@@ -5,11 +5,14 @@
## Возможности
- выбор одного из подключённых сканеров;
-- добавление и удаление сотрудников;
+- добавление сотрудников с должностью и правами администратора;
- регистрация отпечатка по трём последовательным сканам;
-- распознавание сотрудника и одна отметка прихода в день;
+- чередование событий «Приход» и «Уход»;
+- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
- локальный журнал последних отметок;
- уведомления через Telegram Bot API и MAX Bot API;
+- автозагрузка вместе с Windows и постоянная работа в системном трее;
+- удаление сотрудника и завершение терминала только по PIN и отпечатку администратора;
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
Исходное изображение пальца не сохраняется. Шаблон отпечатка хранится только локально и может быть расшифрован текущей учётной записью Windows.
@@ -29,7 +32,15 @@ dotnet run --project apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Debug
3. Выберите сотрудника в списке и нажмите «Зарегистрировать отпечаток».
4. Трижды приложите один и тот же палец, убирая его со стекла после каждого принятого скана.
-После регистрации вкладка «Терминал» постоянно ожидает отпечаток. Повторный скан в тот же день распознаётся, но вторую отметку прихода не создаёт.
+После регистрации вкладка «Терминал» постоянно ожидает отпечаток. Первое сканирование создаёт «Приход», следующее после защитного интервала — «Уход». Удержание пальца и повторное прикладывание в течение защитного интервала не создают новое событие.
+
+## Администратор и работа в фоне
+
+1. Выберите сотрудника и нажмите «Права администратора».
+2. Убедитесь, что у него зарегистрирован отпечаток.
+3. На вкладке настроек задайте PIN длиной не менее четырёх символов.
+
+После первичной настройки удаление сотрудника, изменение прав, изменение PIN и полный выход требуют сначала PIN, затем отпечаток администратора. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включена по умолчанию.
## Уведомления
diff --git a/apps/KronaStaffTerminal/Services/AdminPinService.cs b/apps/KronaStaffTerminal/Services/AdminPinService.cs
new file mode 100644
index 0000000..265fee3
--- /dev/null
+++ b/apps/KronaStaffTerminal/Services/AdminPinService.cs
@@ -0,0 +1,35 @@
+using System.Security.Cryptography;
+
+namespace KronaStaffTerminal.Services;
+
+public static class AdminPinService
+{
+ private const int Iterations = 120_000;
+
+ public static (string Salt, string Hash) Hash(string pin)
+ {
+ var salt = RandomNumberGenerator.GetBytes(16);
+ var hash = Rfc2898DeriveBytes.Pbkdf2(pin, salt, Iterations, HashAlgorithmName.SHA256, 32);
+ return (Convert.ToBase64String(salt), Convert.ToBase64String(hash));
+ }
+
+ public static bool Verify(string pin, string saltBase64, string hashBase64)
+ {
+ if (string.IsNullOrWhiteSpace(pin) || string.IsNullOrWhiteSpace(saltBase64) || string.IsNullOrWhiteSpace(hashBase64))
+ {
+ return false;
+ }
+
+ try
+ {
+ var salt = Convert.FromBase64String(saltBase64);
+ var expected = Convert.FromBase64String(hashBase64);
+ var actual = Rfc2898DeriveBytes.Pbkdf2(pin, salt, Iterations, HashAlgorithmName.SHA256, expected.Length);
+ return CryptographicOperations.FixedTimeEquals(actual, expected);
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs
index ad62538..6e3e387 100644
--- a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs
+++ b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs
@@ -12,7 +12,8 @@ public sealed class FingerprintScanner : IDisposable
private CancellationTokenSource? _captureCancellation;
private int _registrationCount;
private int? _registrationFingerprintId;
- private DateTimeOffset _lastCapture = DateTimeOffset.MinValue;
+ private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue;
+ private bool _fingerOnSensor;
public event Action? FingerRecognized;
public event Action? RegistrationProgress;
@@ -106,12 +107,18 @@ public sealed class FingerprintScanner : IDisposable
var capturedLength = TemplateSize;
if (zkfp2.AcquireFingerprint(_device, image, captured, ref capturedLength) == 0)
{
- if (DateTimeOffset.UtcNow - _lastCapture > TimeSpan.FromMilliseconds(900))
+ var now = DateTimeOffset.UtcNow;
+ if (!_fingerOnSensor && now - _lastProcessedCapture > TimeSpan.FromMilliseconds(500))
{
- _lastCapture = DateTimeOffset.UtcNow;
+ _fingerOnSensor = true;
+ _lastProcessedCapture = now;
ProcessCapture(captured, capturedLength);
}
}
+ else
+ {
+ _fingerOnSensor = false;
+ }
Thread.Sleep(150);
}
}
diff --git a/apps/KronaStaffTerminal/Services/NotificationService.cs b/apps/KronaStaffTerminal/Services/NotificationService.cs
index b2dbd25..903e0ea 100644
--- a/apps/KronaStaffTerminal/Services/NotificationService.cs
+++ b/apps/KronaStaffTerminal/Services/NotificationService.cs
@@ -9,9 +9,10 @@ public sealed class NotificationService
{
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(15) };
- public async Task<(bool Delivered, string? Error)> SendArrivalAsync(TerminalSettings settings, string employeeName, DateTimeOffset localTime)
+ public async Task<(bool Delivered, string? Error)> SendAttendanceAsync(TerminalSettings settings, string employeeName, string eventType, DateTimeOffset localTime)
{
- return await SendAsync(settings, $"{employeeName} пришёл(а) в {localTime:HH:mm}");
+ var action = eventType == "Уход" ? "ушёл(ушла)" : "пришёл(пришла)";
+ return await SendAsync(settings, $"{employeeName} {action} в {localTime:HH:mm}");
}
public async Task<(bool Delivered, string? Error)> SendTestAsync(TerminalSettings settings)
diff --git a/apps/KronaStaffTerminal/Services/StartupService.cs b/apps/KronaStaffTerminal/Services/StartupService.cs
new file mode 100644
index 0000000..48cd26c
--- /dev/null
+++ b/apps/KronaStaffTerminal/Services/StartupService.cs
@@ -0,0 +1,31 @@
+using Microsoft.Win32;
+
+namespace KronaStaffTerminal.Services;
+
+public static class StartupService
+{
+ private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
+ private const string ValueName = "KronaStaffTerminal";
+
+ public static void SetEnabled(bool enabled)
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true)
+ ?? Registry.CurrentUser.CreateSubKey(RunKey, writable: true);
+ if (enabled)
+ {
+ var executablePath = Environment.ProcessPath
+ ?? throw new InvalidOperationException("Не удалось определить путь программы.");
+ key.SetValue(ValueName, $"\"{executablePath}\" --autostart", RegistryValueKind.String);
+ }
+ else
+ {
+ key.DeleteValue(ValueName, throwOnMissingValue: false);
+ }
+ }
+
+ public static bool IsEnabled()
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RunKey);
+ return key?.GetValue(ValueName) is string;
+ }
+}
diff --git a/apps/KronaStaffTerminal/Services/TerminalStore.cs b/apps/KronaStaffTerminal/Services/TerminalStore.cs
index bed25ec..d288b5a 100644
--- a/apps/KronaStaffTerminal/Services/TerminalStore.cs
+++ b/apps/KronaStaffTerminal/Services/TerminalStore.cs
@@ -18,10 +18,16 @@ public sealed class TerminalStore
{
try
{
- if (!File.Exists(_filePath)) return new TerminalState();
- var encrypted = await File.ReadAllBytesAsync(_filePath);
+ var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState();
+ if (sourcePath is null) return new TerminalState();
+ var encrypted = await File.ReadAllBytesAsync(sourcePath);
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
- return JsonSerializer.Deserialize(clear, JsonOptions) ?? new TerminalState();
+ var state = JsonSerializer.Deserialize(clear, JsonOptions) ?? new TerminalState();
+ if (!string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
+ {
+ await SaveAsync(state);
+ }
+ return state;
}
catch
{
@@ -39,4 +45,15 @@ public sealed class TerminalStore
await File.WriteAllBytesAsync(temporaryPath, encrypted);
File.Move(temporaryPath, _filePath, true);
}
+
+ private static string? FindLegacyPackagedState()
+ {
+ var packagesPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Packages");
+ if (!Directory.Exists(packagesPath)) return null;
+ return Directory.EnumerateDirectories(packagesPath, "223A9635-262B-4298-9F19-1D2FE0A6B5F7_*")
+ .Select(directory => Path.Combine(directory, "LocalCache", "Local", "Krona", "StaffTerminal", "terminal-state.dat"))
+ .FirstOrDefault(File.Exists);
+ }
}
diff --git a/apps/KronaStaffTerminal/Services/TrayService.cs b/apps/KronaStaffTerminal/Services/TrayService.cs
new file mode 100644
index 0000000..cf708fc
--- /dev/null
+++ b/apps/KronaStaffTerminal/Services/TrayService.cs
@@ -0,0 +1,176 @@
+using System.Runtime.InteropServices;
+
+namespace KronaStaffTerminal.Services;
+
+public sealed class TrayService : IDisposable
+{
+ private const uint CallbackMessage = 0x8001;
+ private const uint NimAdd = 0;
+ private const uint NimModify = 1;
+ private const uint NimDelete = 2;
+ private const uint NifMessage = 1;
+ private const uint NifIcon = 2;
+ private const uint NifTip = 4;
+ private const uint NifInfo = 16;
+ private const uint WmLButtonDblClk = 0x0203;
+ private const uint WmRButtonUp = 0x0205;
+ private const uint MfString = 0;
+ private const uint MfSeparator = 0x0800;
+ private const uint TpmReturnCmd = 0x0100;
+ private const uint ImageIcon = 1;
+ private const uint LrLoadFromFile = 0x0010;
+ private const uint LrDefaultSize = 0x0040;
+ private const uint OpenCommand = 1001;
+ private const uint ExitCommand = 1002;
+
+ private readonly Action _showWindow;
+ private readonly Action _requestExit;
+ private readonly WindowProcedure _windowProcedure;
+ private readonly IntPtr _windowHandle;
+ private readonly IntPtr _iconHandle;
+ private NotifyIconData _iconData;
+ private bool _disposed;
+
+ public TrayService(Action showWindow, Action requestExit)
+ {
+ _showWindow = showWindow;
+ _requestExit = requestExit;
+ _windowProcedure = HandleWindowMessage;
+ var className = $"KronaStaffTerminal.Tray.{Environment.ProcessId}";
+ var windowClass = new WindowClass
+ {
+ Size = (uint)Marshal.SizeOf(),
+ WindowProcedure = Marshal.GetFunctionPointerForDelegate(_windowProcedure),
+ Instance = GetModuleHandle(null),
+ ClassName = className
+ };
+ RegisterClassEx(ref windowClass);
+ _windowHandle = CreateWindowEx(0, className, className, 0, 0, 0, 0, 0, IntPtr.Zero, IntPtr.Zero, windowClass.Instance, IntPtr.Zero);
+ _iconHandle = LoadImage(IntPtr.Zero, Path.Combine(AppContext.BaseDirectory, "Assets", "AppIcon.ico"), ImageIcon, 0, 0, LrLoadFromFile | LrDefaultSize);
+ _iconData = CreateIconData(NifMessage | NifIcon | NifTip);
+ ShellNotifyIcon(NimAdd, ref _iconData);
+ }
+
+ public void ShowRunningBalloon()
+ {
+ _iconData.Flags = NifInfo;
+ _iconData.InfoTitle = "Krona Staff";
+ _iconData.Info = "Терминал продолжает работать в трее.";
+ _iconData.InfoFlags = 1;
+ ShellNotifyIcon(NimModify, ref _iconData);
+ }
+
+ private NotifyIconData CreateIconData(uint flags) => new()
+ {
+ Size = (uint)Marshal.SizeOf(),
+ WindowHandle = _windowHandle,
+ Id = 1,
+ Flags = flags,
+ CallbackMessage = CallbackMessage,
+ IconHandle = _iconHandle,
+ Tip = "Krona Staff — терминал"
+ };
+
+ private IntPtr HandleWindowMessage(IntPtr window, uint message, IntPtr wParam, IntPtr lParam)
+ {
+ if (message == CallbackMessage)
+ {
+ var mouseMessage = unchecked((uint)lParam.ToInt64());
+ if (mouseMessage == WmLButtonDblClk)
+ {
+ Dispatch(_showWindow);
+ return IntPtr.Zero;
+ }
+ if (mouseMessage == WmRButtonUp)
+ {
+ ShowContextMenu();
+ return IntPtr.Zero;
+ }
+ }
+ return DefWindowProc(window, message, wParam, lParam);
+ }
+
+ private void ShowContextMenu()
+ {
+ var menu = CreatePopupMenu();
+ AppendMenu(menu, MfString, OpenCommand, "Открыть Krona Staff");
+ 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 == ExitCommand) Dispatch(_requestExit);
+ }
+
+ private static void Dispatch(Action action) => App.DispatcherQueue.TryEnqueue(() => action());
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ ShellNotifyIcon(NimDelete, ref _iconData);
+ if (_iconHandle != IntPtr.Zero) DestroyIcon(_iconHandle);
+ if (_windowHandle != IntPtr.Zero) DestroyWindow(_windowHandle);
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct WindowClass
+ {
+ public uint Size;
+ public uint Style;
+ public IntPtr WindowProcedure;
+ public int ClassExtra;
+ public int WindowExtra;
+ public IntPtr Instance;
+ public IntPtr Icon;
+ public IntPtr Cursor;
+ public IntPtr Background;
+ public string? MenuName;
+ public string ClassName;
+ public IntPtr SmallIcon;
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct NotifyIconData
+ {
+ public uint Size;
+ public IntPtr WindowHandle;
+ public uint Id;
+ public uint Flags;
+ public uint CallbackMessage;
+ public IntPtr IconHandle;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string Tip;
+ public uint State;
+ public uint StateMask;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string Info;
+ public uint TimeoutOrVersion;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string InfoTitle;
+ public uint InfoFlags;
+ public Guid GuidItem;
+ public IntPtr BalloonIcon;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct Point { public int X; public int Y; }
+
+ private delegate IntPtr WindowProcedure(IntPtr window, uint message, IntPtr wParam, IntPtr lParam);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern ushort RegisterClassEx(ref WindowClass windowClass);
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr CreateWindowEx(uint extendedStyle, string className, string windowName, uint style, int x, int y, int width, int height, IntPtr parent, IntPtr menu, IntPtr instance, IntPtr parameter);
+ [DllImport("user32.dll")] private static extern IntPtr DefWindowProc(IntPtr window, uint message, IntPtr wParam, IntPtr lParam);
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr LoadImage(IntPtr instance, string name, uint type, int width, int height, uint load);
+ [DllImport("user32.dll")] private static extern bool DestroyIcon(IntPtr icon);
+ [DllImport("user32.dll")] private static extern bool DestroyWindow(IntPtr window);
+ [DllImport("user32.dll")] private static extern IntPtr CreatePopupMenu();
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern bool AppendMenu(IntPtr menu, uint flags, uint id, string? text);
+ [DllImport("user32.dll")] private static extern uint TrackPopupMenu(IntPtr menu, uint flags, int x, int y, int reserved, IntPtr window, IntPtr rectangle);
+ [DllImport("user32.dll")] private static extern bool DestroyMenu(IntPtr menu);
+ [DllImport("user32.dll")] private static extern bool GetCursorPos(out Point point);
+ [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr window);
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode)] private static extern bool Shell_NotifyIcon(uint message, ref NotifyIconData data);
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr GetModuleHandle(string? moduleName);
+
+ private static bool ShellNotifyIcon(uint message, ref NotifyIconData data) => Shell_NotifyIcon(message, ref data);
+}