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