feat: secure terminal lifecycle and attendance scans
Some checks are pending
ci / verify (push) Waiting to run

This commit is contained in:
Ochenstarik 2026-09-17 21:13:42 +07:00
parent efd5bd5c60
commit 3fbeb1fe1b
13 changed files with 573 additions and 44 deletions

View file

@ -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;
/// </summary>
public partial class App : Application
{
private TrayService? _trayService;
/// <summary>
/// The main application window. Use <c>App.Window</c> 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();
}
}
}

View file

@ -10,7 +10,9 @@
<PublishProfile Condition="Exists('Properties\PublishProfiles\win-$(Platform).pubxml')">win-$(Platform).pubxml</PublishProfile>
<UseWinUI>true</UseWinUI>
<WinUISDKReferences>false</WinUISDKReferences>
<EnableMsixTooling>true</EnableMsixTooling>
<EnableMsixTooling>false</EnableMsixTooling>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>

View file

@ -47,12 +47,14 @@
<Grid Padding="4,10" ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="170"/>
<ColumnDefinition Width="130"/>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="120"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{x:Bind EmployeeName}" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{x:Bind LocalTime}"/>
<TextBlock Grid.Column="2" Text="{x:Bind DeliveryStatus}"/>
<TextBlock Grid.Column="1" Text="{x:Bind EventType}" FontWeight="SemiBold"/>
<TextBlock Grid.Column="2" Text="{x:Bind LocalTime}"/>
<TextBlock Grid.Column="3" Text="{x:Bind DeliveryStatus}"/>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
@ -72,6 +74,7 @@
<CommandBar Grid.Row="1" DefaultLabelPosition="Right">
<AppBarButton Icon="Add" Label="Добавить" Click="AddEmployee_Click"/>
<AppBarButton Icon="Contact" Label="Зарегистрировать отпечаток" Click="RegisterFingerprint_Click"/>
<AppBarButton Icon="Permissions" Label="Права администратора" Click="ToggleAdministrator_Click"/>
<AppBarButton Icon="Delete" Label="Удалить" Click="DeleteEmployee_Click"/>
</CommandBar>
<Grid Grid.Row="2" ColumnSpacing="20">
@ -82,6 +85,8 @@
<StackPanel Spacing="10">
<TextBox x:Name="PersonnelNumberBox" Header="Табельный номер" PlaceholderText="Например, 001" AutomationProperties.Name="Табельный номер"/>
<TextBox x:Name="EmployeeNameBox" Header="Имя сотрудника" PlaceholderText="Иван Петров" AutomationProperties.Name="Имя сотрудника"/>
<TextBox x:Name="PositionBox" Header="Должность" PlaceholderText="Например, менеджер" AutomationProperties.Name="Должность"/>
<CheckBox x:Name="AdministratorCheckBox" Content="Администратор терминала"/>
<TextBlock Text="Выберите сотрудника справа и нажмите «Зарегистрировать отпечаток». Сканер попросит приложить один палец три раза." TextWrapping="Wrap" Opacity="0.75"/>
</StackPanel>
<ListView x:Name="EmployeesList" Grid.Column="1" SelectionMode="Single">
@ -91,11 +96,15 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="210"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{x:Bind PersonnelNumber}"/>
<TextBlock Grid.Column="1" Text="{x:Bind DisplayName}" FontWeight="SemiBold"/>
<TextBlock Grid.Column="2" Text="{x:Bind FingerprintStatus}"/>
<TextBlock Grid.Column="2" Text="{x:Bind Position}"/>
<TextBlock Grid.Column="3" Text="{x:Bind AccessStatus}"/>
<TextBlock Grid.Column="4" Text="{x:Bind FingerprintStatus}"/>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
@ -109,6 +118,10 @@
<StackPanel Padding="4,16,20,24" Spacing="14" MaxWidth="720" HorizontalAlignment="Left">
<TextBlock Text="Сканер" Style="{StaticResource SubtitleTextBlockStyle}"/>
<ComboBox x:Name="ReaderCombo" Header="Используемое устройство" MinWidth="320"/>
<ToggleSwitch x:Name="StartWithWindowsSwitch" Header="Автозагрузка" OffContent="Выключена" OnContent="Запускать вместе с Windows"/>
<NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" SpinButtonPlacementMode="Compact"/>
<PasswordBox x:Name="NewAdminPinBox" Header="Новый PIN администратора" PasswordRevealMode="Peek"/>
<Button Content="Установить новый PIN" HorizontalAlignment="Left" Click="SetAdminPin_Click"/>
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<ToggleSwitch x:Name="TelegramEnabledSwitch" Header="Telegram" OffContent="Выключен" OnContent="Включён"/>
<PasswordBox x:Name="TelegramTokenBox" Header="Токен Telegram-бота" PasswordRevealMode="Peek"/>

View file

@ -12,14 +12,16 @@ public sealed partial class MainPage : Page
private readonly FingerprintScanner _scanner = new();
private TerminalState _state = new();
private bool _loaded;
private TaskCompletionSource<bool>? _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<bool> 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<bool> 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<bool>(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<bool> 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;

View file

@ -12,6 +12,8 @@ namespace KronaStaffTerminal;
/// </summary>
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();
}
}

View file

@ -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; } = "";

View file

@ -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, затем отпечаток администратора. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включена по умолчанию.
## Уведомления

View file

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

View file

@ -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<int, int>? FingerRecognized;
public event Action<int>? 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);
}
}

View file

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

View file

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

View file

@ -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<TerminalState>(clear, JsonOptions) ?? new TerminalState();
var state = JsonSerializer.Deserialize<TerminalState>(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);
}
}

View file

@ -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<WindowClass>(),
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<NotifyIconData>(),
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);
}