feat: secure terminal lifecycle and attendance scans
Some checks are pending
ci / verify (push) Waiting to run
Some checks are pending
ci / verify (push) Waiting to run
This commit is contained in:
parent
efd5bd5c60
commit
3fbeb1fe1b
13 changed files with 573 additions and 44 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; } = "";
|
||||
|
|
|
|||
|
|
@ -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, затем отпечаток администратора. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включена по умолчанию.
|
||||
|
||||
## Уведомления
|
||||
|
||||
|
|
|
|||
35
apps/KronaStaffTerminal/Services/AdminPinService.cs
Normal file
35
apps/KronaStaffTerminal/Services/AdminPinService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
31
apps/KronaStaffTerminal/Services/StartupService.cs
Normal file
31
apps/KronaStaffTerminal/Services/StartupService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
176
apps/KronaStaffTerminal/Services/TrayService.cs
Normal file
176
apps/KronaStaffTerminal/Services/TrayService.cs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue