feat: secure and brand staff terminal
Some checks are pending
ci / verify (push) Waiting to run

This commit is contained in:
Ochenstarik 2026-09-18 00:11:29 +07:00
parent 3fbeb1fe1b
commit 14ed610d14
24 changed files with 558 additions and 94 deletions

View file

@ -8,9 +8,8 @@
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<!-- Other merged dictionaries here -->
<ResourceDictionary Source="ms-appx:///Styles/KronaTheme.xaml" />
</ResourceDictionary.MergedDictionaries>
<!-- Other app resources here -->
</ResourceDictionary>
</Application.Resources>
</Application>

View file

@ -44,6 +44,7 @@ public partial class App : Application
public App()
{
InitializeComponent();
UnhandledException += (_, eventArgs) => CrashLogService.Write(eventArgs.Exception);
}
/// <summary>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View file

@ -5,6 +5,7 @@
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>KronaStaffTerminal</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
<Platforms>x86;x64;ARM64</Platforms>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())</RuntimeIdentifier>
<PublishProfile Condition="Exists('Properties\PublishProfiles\win-$(Platform).pubxml')">win-$(Platform).pubxml</PublishProfile>
@ -27,6 +28,9 @@
<Content Include="Assets\Square44x44Logo.targetsize-48_altform-lightunplated.png" />
<Content Include="Assets\StoreLogo.png" />
<Content Include="Assets\AppIcon.ico" />
<Content Include="Assets\KronaAppMark.png" />
<Content Include="Assets\KronaAppIcon.png" />
<Content Include="Assets\TrushenkoLogo.png" />
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
</ItemGroup>

View file

@ -10,7 +10,9 @@
Unloaded="Page_Unloaded"
mc:Ignorable="d">
<Grid Padding="24,16,24,24" RowSpacing="12">
<Grid Padding="24,16,24,24"
RowSpacing="12"
Background="{ThemeResource KronaAppBackgroundBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
@ -23,25 +25,75 @@
Title="Подключение"
Message="Запуск терминала…"/>
<TabView Grid.Row="1" IsAddTabButtonVisible="False">
<TabViewItem Header="Терминал" IsClosable="False">
<TabView x:Name="MainTabs"
Grid.Row="1"
Background="{ThemeResource KronaWorkspaceBrush}"
IsAddTabButtonVisible="False"
SelectionChanged="MainTabs_SelectionChanged">
<TabViewItem x:Name="TerminalTab" Header="Терминал" IsClosable="False">
<Grid Padding="4,16,4,4" ColumnSpacing="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="16">
<FontIcon Glyph="&#xE928;" FontSize="88"/>
<TextBlock Text="Приложите палец" Style="{StaticResource TitleTextBlockStyle}" HorizontalAlignment="Center"/>
<TextBlock x:Name="TerminalMessage" Text="Ожидание сканирования" TextWrapping="Wrap" TextAlignment="Center" MaxWidth="360"/>
</StackPanel>
<Border Margin="8"
Padding="28"
Background="{ThemeResource KronaSurfaceBrush}"
BorderBrush="{ThemeResource KronaBorderBrush}"
BorderThickness="1"
CornerRadius="{StaticResource KronaRadiusLarge}">
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14">
<Grid Width="220" Height="220">
<Ellipse Fill="{ThemeResource KronaGoldSoftBrush}" />
<Image Margin="18"
Source="Assets/TrushenkoLogo.png"
Stretch="Uniform"
AutomationProperties.Name="Логотип Трушенко" />
<Border Width="54"
Height="54"
Margin="0,0,4,4"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Background="{ThemeResource KronaPrimaryBrush}"
CornerRadius="27">
<FontIcon Glyph="&#xE928;"
FontSize="27"
Foreground="{ThemeResource KronaTextInverseBrush}" />
</Border>
</Grid>
<TextBlock Text="Приложите палец"
Foreground="{ThemeResource KronaTextPrimaryBrush}"
Style="{StaticResource TitleTextBlockStyle}"
HorizontalAlignment="Center"/>
<TextBlock x:Name="TerminalMessage"
MaxWidth="360"
Foreground="{ThemeResource KronaTextSecondaryBrush}"
Text="Ожидание сканирования"
TextWrapping="Wrap"
TextAlignment="Center"/>
</StackPanel>
</Border>
<Grid Grid.Column="1" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Text="Последние отметки" Style="{StaticResource SubtitleTextBlockStyle}"/>
<ListView x:Name="AttendanceList" Grid.Row="1" SelectionMode="None">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="Последние отметки"
Foreground="{ThemeResource KronaTextPrimaryBrush}"
Style="{StaticResource SubtitleTextBlockStyle}"/>
<Button Grid.Column="1"
Content="Очистить журнал"
Click="ClearAttendance_Click" />
</Grid>
<ListView x:Name="AttendanceList"
Grid.Row="1"
Background="{ThemeResource KronaRaisedSurfaceBrush}"
SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:AttendanceRecord">
<Grid Padding="4,10" ColumnSpacing="16">
@ -63,19 +115,30 @@
</Grid>
</TabViewItem>
<TabViewItem Header="Сотрудники" IsClosable="False">
<TabViewItem x:Name="EmployeesTab" Header="Сотрудники" IsClosable="False">
<Grid Padding="4,16,4,4" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Text="Добавление и регистрация сотрудников" Style="{StaticResource SubtitleTextBlockStyle}"/>
<CommandBar Grid.Row="1" DefaultLabelPosition="Right">
<TextBlock Text="Добавление и регистрация сотрудников" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
<CommandBar Grid.Row="1" Background="{ThemeResource KronaSurfaceBrush}" DefaultLabelPosition="Right">
<AppBarButton Icon="Add" Label="Добавить" Click="AddEmployee_Click"/>
<AppBarButton Icon="Contact" Label="Зарегистрировать отпечаток" Click="RegisterFingerprint_Click"/>
<AppBarButton Icon="Contact" Label="Добавить отпечаток" Click="RegisterFingerprint_Click"/>
<AppBarButton Label="Удалить отпечатки" Click="DeleteFingerprints_Click">
<AppBarButton.Icon>
<FontIcon Glyph="&#xE928;" />
</AppBarButton.Icon>
</AppBarButton>
<AppBarButton Icon="Permissions" Label="Права администратора" Click="ToggleAdministrator_Click"/>
<AppBarButton Icon="Delete" Label="Удалить" Click="DeleteEmployee_Click"/>
<AppBarSeparator />
<AppBarButton Label="Заблокировать" Click="LogoutAdmin_Click">
<AppBarButton.Icon>
<FontIcon Glyph="&#xE72E;" />
</AppBarButton.Icon>
</AppBarButton>
</CommandBar>
<Grid Grid.Row="2" ColumnSpacing="20">
<Grid.ColumnDefinitions>
@ -86,25 +149,32 @@
<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="Должность"/>
<ComboBox x:Name="GenderComboBox" Header="Пол" SelectedIndex="0">
<ComboBoxItem Content="Не указан" />
<ComboBoxItem Content="Мужской" />
<ComboBoxItem Content="Женский" />
</ComboBox>
<CheckBox x:Name="AdministratorCheckBox" Content="Администратор терминала"/>
<TextBlock Text="Выберите сотрудника справа и нажмите «Зарегистрировать отпечаток». Сканер попросит приложить один палец три раза." TextWrapping="Wrap" Opacity="0.75"/>
<TextBlock Text="Выберите сотрудника справа и нажмите «Добавить отпечаток». Для каждого нового пальца повторите регистрацию из трёх сканов." TextWrapping="Wrap" Opacity="0.75"/>
</StackPanel>
<ListView x:Name="EmployeesList" Grid.Column="1" SelectionMode="Single">
<ListView x:Name="EmployeesList" Grid.Column="1" Background="{ThemeResource KronaRaisedSurfaceBrush}" SelectionMode="Single">
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:EmployeeRecord">
<Grid Padding="4,10" ColumnSpacing="14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="210"/>
<ColumnDefinition Width="130"/>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="130"/>
<ColumnDefinition Width="170"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{x:Bind PersonnelNumber}"/>
<TextBlock Grid.Column="1" Text="{x:Bind DisplayName}" FontWeight="SemiBold"/>
<TextBlock Grid.Column="2" Text="{x:Bind Position}"/>
<TextBlock Grid.Column="3" Text="{x:Bind AccessStatus}"/>
<TextBlock Grid.Column="4" Text="{x:Bind FingerprintStatus}"/>
<TextBlock Grid.Column="3" Text="{x:Bind GenderDisplay}"/>
<TextBlock Grid.Column="4" Text="{x:Bind AccessStatus}"/>
<TextBlock Grid.Column="5" Text="{x:Bind FingerprintStatus}"/>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
@ -113,10 +183,10 @@
</Grid>
</TabViewItem>
<TabViewItem Header="Уведомления и сканер" IsClosable="False">
<TabViewItem x:Name="SettingsTab" Header="Уведомления и сканер" IsClosable="False">
<ScrollViewer>
<StackPanel Padding="4,16,20,24" Spacing="14" MaxWidth="720" HorizontalAlignment="Left">
<TextBlock Text="Сканер" Style="{StaticResource SubtitleTextBlockStyle}"/>
<TextBlock Text="Сканер" Foreground="{ThemeResource KronaTextPrimaryBrush}" 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"/>
@ -131,8 +201,12 @@
<PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/>
<TextBox x:Name="MaxChatBox" Header="Chat ID MAX"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Сохранить настройки" Click="SaveSettings_Click"/>
<Button Content="Сохранить настройки"
Background="{ThemeResource KronaPrimaryBrush}"
Foreground="{ThemeResource KronaTextInverseBrush}"
Click="SaveSettings_Click"/>
<Button Content="Отправить тест" Click="TestNotification_Click"/>
<Button Content="Заблокировать" Click="LogoutAdmin_Click"/>
</StackPanel>
<TextBlock Text="Токены, сотрудники и шаблоны отпечатков хранятся в зашифрованном хранилище текущей учётной записи Windows." TextWrapping="Wrap" Opacity="0.7"/>
</StackPanel>

View file

@ -1,5 +1,6 @@
using KronaStaffTerminal.Models;
using KronaStaffTerminal.Services;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@ -14,6 +15,12 @@ public sealed partial class MainPage : Page
private bool _loaded;
private TaskCompletionSource<bool>? _adminAuthCompletion;
private ContentDialog? _adminAuthDialog;
private readonly DispatcherQueueTimer _adminSessionTimer;
private bool _adminSessionActive;
private bool _adminNavigationInProgress;
private string? _registrationEmployeeId;
private DateTimeOffset _adminSessionExpiresAtUtc;
private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5);
public MainPage()
{
@ -22,6 +29,17 @@ public sealed partial class MainPage : Page
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3"));
_scanner.RegistrationCompleted += (id, template) => Enqueue(() => _ = CompleteRegistrationAsync(id, template));
_scanner.FingerRecognized += (id, score) => Enqueue(() => _ = HandleRecognizedFingerAsync(id, score));
_adminSessionTimer = DispatcherQueue.CreateTimer();
_adminSessionTimer.Interval = TimeSpan.FromSeconds(15);
_adminSessionTimer.IsRepeating = true;
_adminSessionTimer.Tick += (_, _) =>
{
if (_adminSessionActive && DateTimeOffset.UtcNow >= _adminSessionExpiresAtUtc)
{
LockAdminSession("Сеанс администратора завершён после 5 минут бездействия.");
}
};
_adminSessionTimer.Start();
}
private async void Page_Loaded(object sender, RoutedEventArgs e)
@ -33,9 +51,118 @@ public sealed partial class MainPage : Page
ApplyStartupSetting();
RefreshLists();
ConnectScanner();
MainTabs.SelectedItem = TerminalTab;
}
private void Page_Unloaded(object sender, RoutedEventArgs e) => _scanner.Dispose();
private void Page_Unloaded(object sender, RoutedEventArgs e)
{
_adminSessionTimer.Stop();
_scanner.Dispose();
}
private async void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_adminNavigationInProgress || ReferenceEquals(MainTabs.SelectedItem, TerminalTab)) return;
if (HasActiveAdminSession)
{
TouchAdminSession();
return;
}
var requestedTab = MainTabs.SelectedItem;
_adminNavigationInProgress = true;
MainTabs.SelectedItem = TerminalTab;
var authorized = await EnsureAdminSessionAsync("вход в административный раздел");
if (authorized)
{
MainTabs.SelectedItem = requestedTab;
}
_adminNavigationInProgress = false;
}
private void LogoutAdmin_Click(object sender, RoutedEventArgs e) =>
LockAdminSession("Административный раздел заблокирован.");
public void LockAdminSession() =>
LockAdminSession("Административный раздел заблокирован при сворачивании.");
private void LockAdminSession(string message)
{
_adminSessionActive = false;
_adminSessionExpiresAtUtc = default;
_adminAuthCompletion?.TrySetResult(false);
_adminAuthDialog?.Hide();
if (MainTabs is not null)
{
_adminNavigationInProgress = true;
MainTabs.SelectedItem = TerminalTab;
_adminNavigationInProgress = false;
}
if (_loaded) SetStatus(message, InfoBarSeverity.Informational);
}
private bool HasActiveAdminSession =>
_adminSessionActive && DateTimeOffset.UtcNow < _adminSessionExpiresAtUtc;
private void TouchAdminSession() =>
_adminSessionExpiresAtUtc = DateTimeOffset.UtcNow.Add(AdminSessionDuration);
private async Task<bool> EnsureAdminSessionAsync(string operation)
{
if (HasActiveAdminSession)
{
TouchAdminSession();
return true;
}
var authorized = !PinConfigured && !AdministratorFingerprintConfigured
? await CreateInitialAdminPinAsync()
: await AuthorizeAdminAsync(operation);
if (!authorized) return false;
_adminSessionActive = true;
TouchAdminSession();
SetStatus(AdministratorFingerprintConfigured
? "Вход администратора выполнен. Сеанс закроется через 5 минут бездействия."
: "Первичная настройка открыта. Добавьте администратора и зарегистрируйте его отпечаток.",
InfoBarSeverity.Success);
return true;
}
private async Task<bool> CreateInitialAdminPinAsync()
{
var pinBox = new PasswordBox { Header = "Придумайте PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek };
var repeatBox = new PasswordBox { Header = "Повторите PIN", PasswordRevealMode = PasswordRevealMode.Peek };
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
content.Children.Add(new TextBlock
{
Text = "Первичная настройка. После входа добавьте сотрудника-администратора и зарегистрируйте его отпечаток.",
TextWrapping = TextWrapping.Wrap
});
content.Children.Add(pinBox);
content.Children.Add(repeatBox);
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = "Создание администратора",
Content = content,
PrimaryButtonText = "Создать PIN",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Primary
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return false;
if (pinBox.Password.Length < 6 || pinBox.Password != repeatBox.Password)
{
SetStatus("PIN должен содержать не менее 6 символов, оба значения должны совпадать.", InfoBarSeverity.Error);
return false;
}
var pin = AdminPinService.Hash(pinBox.Password);
_state.Settings.AdminPinSalt = pin.Salt;
_state.Settings.AdminPinHash = pin.Hash;
await _store.SaveAsync(_state);
return true;
}
private void ConnectScanner()
{
@ -43,14 +170,29 @@ public sealed partial class MainPage : Page
{
_scanner.Connect(_state.Settings.ReaderIndex, _state.Employees
.Where(employee => employee.HasFingerprint)
.Select(employee => (employee.FingerprintId, employee.FingerprintTemplate!)));
.SelectMany(employee => employee.Fingerprints)
.Select(fingerprint => (fingerprint.Id, fingerprint.Template)));
ReaderCombo.Items.Clear();
for (var index = 0; index < _scanner.DeviceCount; index++)
{
ReaderCombo.Items.Add($"SLK20R №{index + 1}");
}
ReaderCombo.SelectedIndex = _scanner.ReaderIndex;
SetStatus($"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success);
if (_state.Settings.ReaderIndex != _scanner.ReaderIndex)
{
_state.Settings.ReaderIndex = _scanner.ReaderIndex;
_ = _store.SaveAsync(_state);
}
if (_scanner.SkippedTemplateCount > 0)
{
SetStatus(
$"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.",
InfoBarSeverity.Warning);
}
else
{
SetStatus($"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success);
}
}
catch (Exception exception)
{
@ -60,6 +202,7 @@ public sealed partial class MainPage : Page
private async void AddEmployee_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("добавление сотрудника")) return;
var name = EmployeeNameBox.Text.Trim();
var number = PersonnelNumberBox.Text.Trim();
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number))
@ -83,6 +226,12 @@ public sealed partial class MainPage : Page
DisplayName = name,
PersonnelNumber = number,
Position = PositionBox.Text.Trim(),
Gender = GenderComboBox.SelectedIndex switch
{
1 => "Мужской",
2 => "Женский",
_ => ""
},
IsAdministrator = AdministratorCheckBox.IsChecked == true
};
_state.Employees.Add(employee);
@ -90,6 +239,7 @@ public sealed partial class MainPage : Page
EmployeeNameBox.Text = "";
PersonnelNumberBox.Text = "";
PositionBox.Text = "";
GenderComboBox.SelectedIndex = 0;
AdministratorCheckBox.IsChecked = false;
RefreshLists();
EmployeesList.SelectedItem = employee;
@ -98,6 +248,7 @@ public sealed partial class MainPage : Page
private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return;
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
@ -108,31 +259,68 @@ 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;
}
_scanner.BeginRegistration(employee.FingerprintId);
var fingerprintId = _state.Employees
.SelectMany(item => item.Fingerprints)
.Select(item => item.Id)
.DefaultIfEmpty(0)
.Max() + 1;
_registrationEmployeeId = employee.Id;
_scanner.BeginRegistration(fingerprintId);
}
private async Task CompleteRegistrationAsync(int fingerprintId, string template)
{
var employee = _state.Employees.FirstOrDefault(item => item.FingerprintId == fingerprintId);
var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId);
if (employee is null) return;
employee.FingerprintTemplate = template;
employee.Fingerprints.Add(new FingerprintRecord
{
Id = fingerprintId,
Template = template,
Label = $"Отпечаток {employee.Fingerprints.Count + 1}"
});
_registrationEmployeeId = null;
await _store.SaveAsync(_state);
RefreshLists();
TerminalMessage.Text = $"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован";
SetStatus($"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success);
TerminalMessage.Text = $"Новый отпечаток сотрудника «{employee.DisplayName}» зарегистрирован";
SetStatus($"Добавлен отпечаток №{employee.Fingerprints.Count} сотрудника «{employee.DisplayName}».", InfoBarSeverity.Success);
}
private async void DeleteFingerprints_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("удаление отпечатков")) return;
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
return;
}
if (!employee.HasFingerprint)
{
SetStatus("У выбранного сотрудника нет зарегистрированных отпечатков.", InfoBarSeverity.Informational);
return;
}
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = "Удалить все отпечатки сотрудника?",
Content = $"{employee.DisplayName}: будет удалено отпечатков — {employee.Fingerprints.Count}. Сам сотрудник останется.",
PrimaryButtonText = "Удалить отпечатки",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
if (!await AuthorizeAdminAsync("удаление отпечатков сотрудника")) return;
employee.Fingerprints.Clear();
await _store.SaveAsync(_state);
RefreshLists();
ConnectScanner();
SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success);
}
private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("изменение прав администратора")) return;
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
@ -152,6 +340,7 @@ public sealed partial class MainPage : Page
private async void DeleteEmployee_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("удаление сотрудника")) return;
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
@ -169,7 +358,7 @@ public sealed partial class MainPage : Page
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
if (!SecurityConfigured)
{
SetStatus("Сначала назначьте администратора с отпечатком и установите PIN.", InfoBarSeverity.Warning);
SetStatus("Сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning);
return;
}
if (!await AuthorizeAdminAsync("удаление сотрудника и отпечатка")) return;
@ -188,7 +377,8 @@ public sealed partial class MainPage : Page
private async Task HandleRecognizedFingerAsync(int fingerprintId, int score)
{
var employee = _state.Employees.FirstOrDefault(item => item.FingerprintId == fingerprintId);
var employee = _state.Employees.FirstOrDefault(item =>
item.Fingerprints.Any(fingerprint => fingerprint.Id == fingerprintId));
if (employee is null) return;
if (_adminAuthCompletion is not null)
{
@ -250,68 +440,99 @@ public sealed partial class MainPage : Page
}
}
private async void ClearAttendance_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("очистка журнала")) return;
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = "Очистить журнал отметок?",
Content = $"Будут удалены все записи прихода и ухода: {_state.Attendance.Count}. Сотрудники и отпечатки останутся.",
PrimaryButtonText = "Очистить журнал",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
if (!await AuthorizeAdminAsync("очистка журнала прихода и ухода")) return;
_state.Attendance.Clear();
await _store.SaveAsync(_state);
RefreshLists();
TerminalMessage.Text = "Журнал отметок очищен";
SetStatus("Журнал прихода и ухода очищен.", InfoBarSeverity.Success);
}
public async Task<bool> AuthorizeExitAsync()
{
if (!SecurityConfigured)
{
SetStatus("Выход заблокирован: сначала настройте PIN и отпечаток администратора.", InfoBarSeverity.Warning);
SetStatus("Выход заблокирован: сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning);
return false;
}
return await AuthorizeAdminAsync("полное завершение терминала");
}
private bool SecurityConfigured =>
!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) &&
private bool PinConfigured =>
!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash);
private bool AdministratorFingerprintConfigured =>
_state.Employees.Any(employee => employee.IsAdministrator && employee.HasFingerprint);
private bool SecurityConfigured =>
PinConfigured || AdministratorFingerprintConfigured;
private async Task<bool> AuthorizeAdminAsync(string operation)
{
var pinBox = new PasswordBox { Header = "PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek, MinWidth = 280 };
var pinDialog = new ContentDialog
var hasAdministratorFingerprint = AdministratorFingerprintConfigured;
var pinBox = new PasswordBox
{
XamlRoot = XamlRoot,
Title = $"Подтверждение: {operation}",
Content = pinBox,
PrimaryButtonText = "Продолжить",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Primary
Header = "PIN администратора",
PasswordRevealMode = PasswordRevealMode.Peek,
MinWidth = 280
};
if (await pinDialog.ShowAsync() != ContentDialogResult.Primary) return false;
if (!AdminPinService.Verify(pinBox.Password, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash))
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
content.Children.Add(new TextBlock
{
SetStatus("Неверный PIN администратора.", InfoBarSeverity.Error);
return false;
}
Text = hasAdministratorFingerprint
? "Введите PIN или приложите зарегистрированный палец администратора."
: "Введите PIN. После входа зарегистрируйте отпечаток администратора.",
TextWrapping = TextWrapping.Wrap
});
content.Children.Add(pinBox);
_adminAuthCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_adminAuthDialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = "Приложите палец администратора",
Content = "PIN принят. Теперь приложите зарегистрированный палец сотрудника с правами администратора.",
Title = $"Вход администратора: {operation}",
Content = content,
PrimaryButtonText = "Войти по PIN",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close
DefaultButton = ContentDialogButton.Primary
};
_ = ObserveAdminDialogAsync(_adminAuthDialog, _adminAuthCompletion);
var authorized = await _adminAuthCompletion.Task;
var dialogResult = await _adminAuthDialog.ShowAsync();
var fingerprintAuthorized = _adminAuthCompletion.Task.IsCompletedSuccessfully &&
_adminAuthCompletion.Task.Result;
_adminAuthCompletion.TrySetResult(false);
var authorized = fingerprintAuthorized ||
(dialogResult == ContentDialogResult.Primary &&
AdminPinService.Verify(pinBox.Password, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash));
_adminAuthCompletion = null;
_adminAuthDialog = null;
SetStatus(authorized ? "Действие подтверждено администратором." : "Подтверждение отменено.", authorized ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
SetStatus(authorized
? fingerprintAuthorized ? "Вход выполнен по отпечатку администратора." : "Вход выполнен по PIN администратора."
: dialogResult == ContentDialogResult.Primary ? "Неверный PIN администратора." : "Вход отменён.",
authorized ? InfoBarSeverity.Success : dialogResult == ContentDialogResult.Primary ? InfoBarSeverity.Error : 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)
{
if (!await EnsureAdminSessionAsync("изменение PIN")) return;
var newPin = NewAdminPinBox.Password.Trim();
if (newPin.Length < 4)
if (newPin.Length < 6)
{
SetStatus("PIN должен содержать не менее 4 символов.", InfoBarSeverity.Warning);
SetStatus("PIN должен содержать не менее 6 символов.", InfoBarSeverity.Warning);
return;
}
if (!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && !await AuthorizeAdminAsync("изменение PIN")) return;
@ -325,6 +546,7 @@ public sealed partial class MainPage : Page
private async void SaveSettings_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("изменение настроек")) return;
CopySettingsFromControls();
await _store.SaveAsync(_state);
ApplyStartupSetting();
@ -334,6 +556,7 @@ public sealed partial class MainPage : Page
private async void TestNotification_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("проверка уведомлений")) return;
CopySettingsFromControls();
var result = await _notifications.SendTestAsync(_state.Settings);
SetStatus(result.Delivered ? "Тестовое сообщение отправлено." : result.Error ?? "Включите Telegram или MAX и заполните реквизиты.", result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning);

View file

@ -24,6 +24,7 @@ public sealed partial class MainWindow : Window
AppWindow.SetIcon("Assets/AppIcon.ico");
AppWindow.Resize(new Windows.Graphics.SizeInt32(1120, 760));
AppWindow.Closing += AppWindow_Closing;
AppWindow.Changed += AppWindow_Changed;
// Navigate the root frame to the main page on startup.
RootFrame.Navigate(typeof(MainPage));
@ -33,10 +34,23 @@ public sealed partial class MainWindow : Window
{
if (_allowClose) return;
args.Cancel = true;
if (RootFrame.Content is MainPage page) page.LockAdminSession();
AppWindow.Hide();
((App)Application.Current).NotifyMinimizedToTray();
}
private void AppWindow_Changed(Microsoft.UI.Windowing.AppWindow sender, Microsoft.UI.Windowing.AppWindowChangedEventArgs args)
{
if (!args.DidPresenterChange ||
sender.Presenter is not Microsoft.UI.Windowing.OverlappedPresenter presenter ||
presenter.State != Microsoft.UI.Windowing.OverlappedPresenterState.Minimized)
{
return;
}
if (RootFrame.Content is MainPage page) page.LockAdminSession();
}
public void ShowWindow()
{
AppWindow.Show();

View file

@ -8,18 +8,38 @@ public sealed class EmployeeRecord
public string PersonnelNumber { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Position { get; set; } = "";
public string Gender { get; set; } = "";
public bool IsAdministrator { get; set; }
public List<FingerprintRecord> Fingerprints { get; set; } = [];
// Legacy single-fingerprint fields are kept for one-way migration of existing encrypted state.
public int FingerprintId { get; set; }
public string? FingerprintTemplate { get; set; }
[JsonIgnore]
public bool HasFingerprint => FingerprintId > 0 && !string.IsNullOrWhiteSpace(FingerprintTemplate);
public bool HasFingerprint => Fingerprints.Count > 0;
[JsonIgnore]
public string FingerprintStatus => HasFingerprint ? "Отпечаток зарегистрирован" : "Отпечатка нет";
public string FingerprintStatus => Fingerprints.Count switch
{
0 => "Отпечатков нет",
1 => "1 отпечаток",
2 or 3 or 4 => $"{Fingerprints.Count} отпечатка",
_ => $"{Fingerprints.Count} отпечатков"
};
[JsonIgnore]
public string AccessStatus => IsAdministrator ? "Администратор" : "Сотрудник";
[JsonIgnore]
public string GenderDisplay => string.IsNullOrWhiteSpace(Gender) ? "—" : Gender;
}
public sealed class FingerprintRecord
{
public int Id { get; set; }
public string Template { get; set; } = "";
public string Label { get; set; } = "";
}
public sealed class AttendanceRecord

View file

@ -4,15 +4,17 @@
## Возможности
- выбор одного из подключённых сканеров;
- добавление сотрудников с должностью и правами администратора;
- регистрация отпечатка по трём последовательным сканам;
- выбор одного из подключённых сканеров с автоматическим переходом на рабочее устройство;
- добавление сотрудников с должностью, полом и правами администратора;
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
- чередование событий «Приход» и «Уход»;
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
- локальный журнал последних отметок;
- защищённая очистка журнала без удаления сотрудников и отпечатков;
- уведомления через Telegram Bot API и MAX Bot API;
- автозагрузка вместе с Windows и постоянная работа в системном трее;
- удаление сотрудника и завершение терминала только по PIN и отпечатку администратора;
- закрытый административный раздел с входом по PIN или отпечатку администратора;
- автоматическая блокировка административного раздела через 5 минут и при сворачивании;
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
Исходное изображение пальца не сохраняется. Шаблон отпечатка хранится только локально и может быть расшифрован текущей учётной записью Windows.
@ -27,20 +29,21 @@ dotnet run --project apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Debug
## Первый сотрудник
1. Откройте вкладку «Сотрудники».
2. Введите табельный номер и имя, затем нажмите «Добавить».
3. Выберите сотрудника в списке и нажмите «Зарегистрировать отпечаток».
1. Откройте вкладку «Сотрудники» и при первом входе создайте PIN длиной не менее шести символов.
2. Введите табельный номер и имя, отметьте «Администратор терминала», затем нажмите «Добавить».
3. Выберите сотрудника в списке и нажмите «Добавить отпечаток».
4. Трижды приложите один и тот же палец, убирая его со стекла после каждого принятого скана.
5. Для регистрации другого пальца повторно нажмите «Добавить отпечаток».
После регистрации вкладка «Терминал» постоянно ожидает отпечаток. Первое сканирование создаёт «Приход», следующее после защитного интервала — «Уход». Удержание пальца и повторное прикладывание в течение защитного интервала не создают новое событие.
## Администратор и работа в фоне
1. Выберите сотрудника и нажмите «Права администратора».
2. Убедитесь, что у него зарегистрирован отпечаток.
3. На вкладке настроек задайте PIN длиной не менее четырёх символов.
1. Вкладки «Сотрудники» и «Уведомления и сканер» открываются только после входа администратора.
2. Для входа достаточно PIN либо зарегистрированного отпечатка администратора.
3. Кнопка «Заблокировать», сворачивание окна или пять минут бездействия завершают административный сеанс.
После первичной настройки удаление сотрудника, изменение прав, изменение PIN и полный выход требуют сначала PIN, затем отпечаток администратора. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включена по умолчанию.
После первичной настройки добавление сотрудников, регистрация и удаление отпечатков, изменение прав, уведомлений и параметров сканера доступны только в административном сеансе. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включена по умолчанию.
## Уведомления

View file

@ -0,0 +1,24 @@
namespace KronaStaffTerminal.Services;
public static class CrashLogService
{
private static readonly string LogDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Krona",
"StaffTerminal",
"logs");
public static void Write(Exception exception)
{
try
{
Directory.CreateDirectory(LogDirectory);
var entry = $"[{DateTimeOffset.Now:O}]{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}";
File.AppendAllText(Path.Combine(LogDirectory, "crash.log"), entry);
}
catch
{
// Logging must never replace the original application failure.
}
}
}

View file

@ -22,11 +22,13 @@ public sealed class FingerprintScanner : IDisposable
public int DeviceCount { get; private set; }
public int ReaderIndex { get; private set; }
public int SkippedTemplateCount { get; private set; }
public bool IsConnected => _device != IntPtr.Zero;
public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates)
{
Disconnect();
SkippedTemplateCount = 0;
var result = zkfp2.Init();
if (result != 0)
{
@ -40,12 +42,21 @@ public sealed class FingerprintScanner : IDisposable
throw new InvalidOperationException("Подключённые сканеры не найдены.");
}
ReaderIndex = Math.Clamp(readerIndex, 0, DeviceCount - 1);
_device = zkfp2.OpenDevice(ReaderIndex);
var preferredIndex = Math.Clamp(readerIndex, 0, DeviceCount - 1);
var candidateIndexes = new[] { preferredIndex }
.Concat(Enumerable.Range(0, DeviceCount).Where(index => index != preferredIndex));
foreach (var candidateIndex in candidateIndexes)
{
var candidateDevice = zkfp2.OpenDevice(candidateIndex);
if (candidateDevice == IntPtr.Zero) continue;
ReaderIndex = candidateIndex;
_device = candidateDevice;
break;
}
if (_device == IntPtr.Zero)
{
zkfp2.Terminate();
throw new InvalidOperationException($"Не удалось открыть сканер №{ReaderIndex + 1}.");
throw new InvalidOperationException("Ни один обнаруженный сканер не удалось открыть. Переподключите USB-кабель.");
}
_database = zkfp2.DBInit();
@ -59,8 +70,18 @@ public sealed class FingerprintScanner : IDisposable
foreach (var item in templates)
{
var template = zkfp2.Base64ToBlob(item.Template);
zkfp2.DBAdd(_database, item.FingerprintId, template);
try
{
var template = Convert.FromBase64String(item.Template);
if (template.Length == 0 || zkfp2.DBAdd(_database, item.FingerprintId, template) != 0)
{
SkippedTemplateCount++;
}
}
catch (FormatException)
{
SkippedTemplateCount++;
}
}
_captureCancellation = new CancellationTokenSource();

View file

@ -23,7 +23,8 @@ public sealed class TerminalStore
var encrypted = await File.ReadAllBytesAsync(sourcePath);
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
var state = JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions) ?? new TerminalState();
if (!string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
var migrated = MigrateFingerprints(state);
if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
{
await SaveAsync(state);
}
@ -35,6 +36,33 @@ public sealed class TerminalStore
}
}
private static bool MigrateFingerprints(TerminalState state)
{
var changed = false;
foreach (var employee in state.Employees)
{
employee.Fingerprints ??= [];
if (employee.FingerprintId <= 0 || string.IsNullOrWhiteSpace(employee.FingerprintTemplate))
{
continue;
}
if (employee.Fingerprints.All(item => item.Id != employee.FingerprintId))
{
employee.Fingerprints.Add(new FingerprintRecord
{
Id = employee.FingerprintId,
Template = employee.FingerprintTemplate,
Label = "Отпечаток 1"
});
}
employee.FingerprintId = 0;
employee.FingerprintTemplate = null;
changed = true;
}
return changed;
}
public async Task SaveAsync(TerminalState state)
{
var directory = Path.GetDirectoryName(_filePath)!;

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<SolidColorBrush x:Key="KronaAppBackgroundBrush" Color="#EFE3CC" />
<SolidColorBrush x:Key="KronaWorkspaceBrush" Color="#F3EAD7" />
<SolidColorBrush x:Key="KronaSurfaceBrush" Color="#F8F2E6" />
<SolidColorBrush x:Key="KronaRaisedSurfaceBrush" Color="#FCF8EF" />
<SolidColorBrush x:Key="KronaSurfaceMutedBrush" Color="#E9DDC5" />
<SolidColorBrush x:Key="KronaTextPrimaryBrush" Color="#302A23" />
<SolidColorBrush x:Key="KronaTextSecondaryBrush" Color="#756A5B" />
<SolidColorBrush x:Key="KronaTextInverseBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="KronaBorderBrush" Color="#D9C9AC" />
<SolidColorBrush x:Key="KronaPrimaryBrush" Color="#9A743D" />
<SolidColorBrush x:Key="KronaPrimarySoftBrush" Color="#EADBC1" />
<SolidColorBrush x:Key="KronaGoldBrush" Color="#B78335" />
<SolidColorBrush x:Key="KronaGoldSoftBrush" Color="#EFE0C2" />
</ResourceDictionary>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="KronaAppBackgroundBrush" Color="#EFE3CC" />
<SolidColorBrush x:Key="KronaWorkspaceBrush" Color="#F3EAD7" />
<SolidColorBrush x:Key="KronaSurfaceBrush" Color="#F8F2E6" />
<SolidColorBrush x:Key="KronaRaisedSurfaceBrush" Color="#FCF8EF" />
<SolidColorBrush x:Key="KronaSurfaceMutedBrush" Color="#E9DDC5" />
<SolidColorBrush x:Key="KronaTextPrimaryBrush" Color="#302A23" />
<SolidColorBrush x:Key="KronaTextSecondaryBrush" Color="#756A5B" />
<SolidColorBrush x:Key="KronaTextInverseBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="KronaBorderBrush" Color="#D9C9AC" />
<SolidColorBrush x:Key="KronaPrimaryBrush" Color="#9A743D" />
<SolidColorBrush x:Key="KronaPrimarySoftBrush" Color="#EADBC1" />
<SolidColorBrush x:Key="KronaGoldBrush" Color="#B78335" />
<SolidColorBrush x:Key="KronaGoldSoftBrush" Color="#EFE0C2" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="KronaAppBackgroundBrush" Color="#171A18" />
<SolidColorBrush x:Key="KronaWorkspaceBrush" Color="#1D211E" />
<SolidColorBrush x:Key="KronaSurfaceBrush" Color="#252A26" />
<SolidColorBrush x:Key="KronaRaisedSurfaceBrush" Color="#2B312C" />
<SolidColorBrush x:Key="KronaSurfaceMutedBrush" Color="#212621" />
<SolidColorBrush x:Key="KronaTextPrimaryBrush" Color="#EEE7DA" />
<SolidColorBrush x:Key="KronaTextSecondaryBrush" Color="#B7B3A8" />
<SolidColorBrush x:Key="KronaTextInverseBrush" Color="#171A18" />
<SolidColorBrush x:Key="KronaBorderBrush" Color="#39423A" />
<SolidColorBrush x:Key="KronaPrimaryBrush" Color="#527D60" />
<SolidColorBrush x:Key="KronaPrimarySoftBrush" Color="#29382E" />
<SolidColorBrush x:Key="KronaGoldBrush" Color="#C99A4D" />
<SolidColorBrush x:Key="KronaGoldSoftBrush" Color="#403522" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<CornerRadius x:Key="KronaRadiusLarge">16</CornerRadius>
</ResourceDictionary>