feat(terminal): add secure release updates and installer
Some checks failed
ci / verify (push) Has been cancelled

This commit is contained in:
Ochenstarik 2026-09-19 02:08:26 +07:00
parent d45bee960a
commit 827d321a1a
14 changed files with 694 additions and 10 deletions

2
.gitignore vendored
View file

@ -11,3 +11,5 @@ backups/
restore-work/ restore-work/
apps/KronaStaffTerminal/bin/ apps/KronaStaffTerminal/bin/
apps/KronaStaffTerminal/obj/ apps/KronaStaffTerminal/obj/
artifacts/
.audit-build/

View file

@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: Krona Staff API title: Krona Staff API
version: 0.1.0 version: 0.2.0
description: Operational API for the Krona Staff foundation. HR domain endpoints are intentionally absent until owner requirements are approved. description: Operational API for the Krona Staff foundation. HR domain endpoints are intentionally absent until owner requirements are approved.
license: license:
name: Proprietary name: Proprietary

View file

@ -17,6 +17,10 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget> <PlatformTarget>x64</PlatformTarget>
<Version>0.2.0</Version>
<AssemblyVersion>0.2.0.0</AssemblyVersion>
<FileVersion>0.2.0.0</FileVersion>
<InformationalVersion>0.2.0</InformationalVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -81,8 +85,25 @@
<!-- Publish Properties --> <!-- Publish Properties -->
<PropertyGroup> <PropertyGroup>
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun> <PublishReadyToRun>False</PublishReadyToRun>
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
<PublishTrimmed>False</PublishTrimmed> <PublishTrimmed>False</PublishTrimmed>
</PropertyGroup> </PropertyGroup>
<Target Name="CopyUnpackagedWinUiResourcesToPublish" AfterTargets="Publish">
<ItemGroup>
<_WinUiRootResource Include="$(TargetDir)*.xbf" />
<_WinUiRootResource Include="$(TargetDir)$(AssemblyName).pri" />
<_WinUiAsset Include="$(TargetDir)Assets\**\*" />
<_WinUiStyle Include="$(TargetDir)Styles\**\*" />
</ItemGroup>
<Copy SourceFiles="@(_WinUiRootResource)"
DestinationFolder="$(PublishDir)"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_WinUiAsset)"
DestinationFiles="@(_WinUiAsset->'$(PublishDir)Assets\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_WinUiStyle)"
DestinationFiles="@(_WinUiStyle->'$(PublishDir)Styles\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
</Project> </Project>

View file

@ -14,6 +14,7 @@
RowSpacing="12" RowSpacing="12"
Background="{ThemeResource KronaAppBackgroundBrush}"> Background="{ThemeResource KronaAppBackgroundBrush}">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
@ -25,8 +26,21 @@
Title="Подключение" Title="Подключение"
Message="Запуск терминала…"/> Message="Запуск терминала…"/>
<TabView x:Name="MainTabs" <InfoBar x:Name="UpdateBar"
Grid.Row="1" Grid.Row="1"
Margin="0,0,0,4"
IsOpen="False"
IsClosable="True"
Severity="Informational"
Title="Доступно обновление">
<InfoBar.ActionButton>
<Button Content="Обновить"
Click="InstallUpdate_Click"/>
</InfoBar.ActionButton>
</InfoBar>
<TabView x:Name="MainTabs"
Grid.Row="2"
Background="{ThemeResource KronaWorkspaceBrush}" Background="{ThemeResource KronaWorkspaceBrush}"
IsAddTabButtonVisible="False" IsAddTabButtonVisible="False"
SelectionChanged="MainTabs_SelectionChanged"> SelectionChanged="MainTabs_SelectionChanged">
@ -205,6 +219,26 @@
<ToggleSwitch x:Name="MaxEnabledSwitch" Header="MAX" OffContent="Выключен" OnContent="Включён"/> <ToggleSwitch x:Name="MaxEnabledSwitch" Header="MAX" OffContent="Выключен" OnContent="Включён"/>
<PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/> <PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/>
<TextBox x:Name="MaxChatBox" Header="Chat ID MAX"/> <TextBox x:Name="MaxChatBox" Header="Chat ID MAX"/>
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<TextBlock Text="Обновления программы" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
<TextBlock x:Name="CurrentVersionText" Opacity="0.75"/>
<ToggleSwitch x:Name="UpdateChecksSwitch"
Header="Автоматическая проверка"
OffContent="Выключена"
OnContent="Проверять при запуске и каждые 6 часов"/>
<TextBox x:Name="UpdateFeedBox"
Header="Адрес канала обновлений"
IsReadOnly="True"
PlaceholderText="HTTPS-адрес последнего релиза"/>
<PasswordBox x:Name="UpdateTokenBox"
Header="Токен чтения приватного репозитория"
PasswordRevealMode="Peek"/>
<TextBlock Text="Токен нужен только пока обновления берутся из приватного Forgejo. Позже этот канал можно заменить Бизнес Платформой без изменения установщика."
TextWrapping="Wrap"
Opacity="0.7"/>
<Button Content="Проверить обновления"
HorizontalAlignment="Left"
Click="CheckForUpdates_Click"/>
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Сохранить настройки" <Button Content="Сохранить настройки"
Background="{ThemeResource KronaPrimaryBrush}" Background="{ThemeResource KronaPrimaryBrush}"

View file

@ -14,11 +14,16 @@ public sealed partial class MainPage : Page
private readonly TerminalStore _store = new(); private readonly TerminalStore _store = new();
private readonly NotificationService _notifications = new(); private readonly NotificationService _notifications = new();
private readonly FingerprintScanner _scanner = new(); private readonly FingerprintScanner _scanner = new();
private readonly IUpdateSource _updateSource = new ForgejoUpdateSource();
private TerminalState _state = new(); private TerminalState _state = new();
private bool _loaded; private bool _loaded;
private TaskCompletionSource<bool>? _adminAuthCompletion; private TaskCompletionSource<bool>? _adminAuthCompletion;
private ContentDialog? _adminAuthDialog; private ContentDialog? _adminAuthDialog;
private readonly DispatcherQueueTimer _adminSessionTimer; private readonly DispatcherQueueTimer _adminSessionTimer;
private readonly DispatcherQueueTimer _updateTimer;
private readonly CancellationTokenSource _updateCancellation = new();
private AvailableUpdate? _availableUpdate;
private bool _updateCheckInProgress;
private bool _adminSessionActive; private bool _adminSessionActive;
private bool _adminNavigationInProgress; private bool _adminNavigationInProgress;
private bool _stateLoadFailed; private bool _stateLoadFailed;
@ -31,7 +36,15 @@ public sealed partial class MainPage : Page
public MainPage() public MainPage()
{ {
InitializeComponent(); try
{
InitializeComponent();
}
catch (Exception exception)
{
CrashLogService.Write(exception);
throw;
}
_scanner.StatusChanged += message => Enqueue(() => SetStatus(message)); _scanner.StatusChanged += message => Enqueue(() => SetStatus(message));
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3")); _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3"));
_scanner.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template)); _scanner.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template));
@ -49,6 +62,10 @@ public sealed partial class MainPage : Page
} }
}; };
_adminSessionTimer.Start(); _adminSessionTimer.Start();
_updateTimer = DispatcherQueue.CreateTimer();
_updateTimer.Interval = TimeSpan.FromHours(6);
_updateTimer.IsRepeating = true;
_updateTimer.Tick += (_, _) => EnqueueTask(() => CheckForUpdatesAsync(false));
} }
private async void Page_Loaded(object sender, RoutedEventArgs e) private async void Page_Loaded(object sender, RoutedEventArgs e)
@ -72,17 +89,21 @@ public sealed partial class MainPage : Page
ApplyStartupSetting(); ApplyStartupSetting();
RefreshLists(); RefreshLists();
ConnectScanner(); ConnectScanner();
_updateTimer.Start();
MainTabs.SelectedItem = TerminalTab; MainTabs.SelectedItem = TerminalTab;
if (_store.RecoveryNotice is not null) if (_store.RecoveryNotice is not null)
{ {
SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning); SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning);
} }
await CheckForUpdatesAsync(false);
} }
private void Page_Unloaded(object sender, RoutedEventArgs e) private void Page_Unloaded(object sender, RoutedEventArgs e)
{ {
_loaded = false; _loaded = false;
_adminSessionTimer.Stop(); _adminSessionTimer.Stop();
_updateTimer.Stop();
_updateCancellation.Cancel();
_scanner.Dispose(); _scanner.Dispose();
} }
@ -857,6 +878,117 @@ public sealed partial class MainPage : Page
SetStatus("Настройки сохранены.", InfoBarSeverity.Success); SetStatus("Настройки сохранены.", InfoBarSeverity.Success);
} }
private async void CheckForUpdates_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("проверка обновлений")) return;
CopyUpdateSettingsFromControls();
await _store.SaveAsync(_state);
await CheckForUpdatesAsync(true);
}
private async Task CheckForUpdatesAsync(bool reportUpToDate)
{
if (_updateCheckInProgress ||
(!_state.Settings.UpdateChecksEnabled && !reportUpToDate) ||
_updateCancellation.IsCancellationRequested)
{
return;
}
_updateCheckInProgress = true;
try
{
var update = await _updateSource.FindUpdateAsync(
_state.Settings,
_updateCancellation.Token);
_state.Settings.LastUpdateCheckUtc = DateTimeOffset.UtcNow;
if (update is null)
{
_availableUpdate = null;
UpdateBar.IsOpen = false;
if (reportUpToDate)
{
SetStatus(
$"Установлена последняя версия {ForgejoUpdateSource.CurrentVersion.ToString(3)}.",
InfoBarSeverity.Success);
}
return;
}
_availableUpdate = update;
UpdateBar.Message = $"{update.ReleaseName}. Установка доступна администратору.";
UpdateBar.IsOpen = true;
if (reportUpToDate)
{
SetStatus($"Доступна версия {update.DisplayVersion}.", InfoBarSeverity.Informational);
}
}
catch (OperationCanceledException) when (_updateCancellation.IsCancellationRequested)
{
}
catch (Exception exception)
{
CrashLogService.Write(exception);
if (reportUpToDate)
{
SetStatus($"Не удалось проверить обновления: {exception.Message}", InfoBarSeverity.Warning);
}
}
finally
{
_updateCheckInProgress = false;
}
}
private async void InstallUpdate_Click(object sender, RoutedEventArgs e)
{
if (_availableUpdate is null) return;
if (!await EnsureAdminSessionAsync("установка обновления")) return;
var update = _availableUpdate;
var notes = string.IsNullOrWhiteSpace(update.ReleaseNotes)
? "После загрузки программа закроется и откроется установщик."
: update.ReleaseNotes;
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = $"Установить Krona Staff {update.DisplayVersion}?",
Content = new TextBlock
{
Text = notes,
TextWrapping = TextWrapping.Wrap,
MaxWidth = 520
},
PrimaryButtonText = "Скачать и установить",
CloseButtonText = "Позже",
DefaultButton = ContentDialogButton.Primary
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
try
{
UpdateBar.IsClosable = false;
UpdateBar.Message = $"Загрузка версии {update.DisplayVersion}: 0%";
var progress = new Progress<double>(value =>
UpdateBar.Message = $"Загрузка версии {update.DisplayVersion}: {value:P0}");
var installerPath = await _updateSource.DownloadAndVerifyAsync(
update,
_state.Settings,
progress,
_updateCancellation.Token);
AppendSecurityAudit("application.update", "started", $"version={update.DisplayVersion}");
await _store.SaveAsync(_state, createRecoveryPoint: true);
ForgejoUpdateSource.LaunchInstaller(installerPath);
((App)Application.Current).CompleteExit();
}
catch (Exception exception)
{
CrashLogService.Write(exception);
UpdateBar.IsClosable = true;
SetStatus($"Обновление не установлено: {exception.Message}", InfoBarSeverity.Error);
}
}
private async void TestNotification_Click(object sender, RoutedEventArgs e) private async void TestNotification_Click(object sender, RoutedEventArgs e)
{ {
if (!await EnsureAdminSessionAsync("проверка уведомлений")) return; if (!await EnsureAdminSessionAsync("проверка уведомлений")) return;
@ -878,6 +1010,14 @@ public sealed partial class MainPage : Page
_state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn; _state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn;
_state.Settings.MaxBotToken = MaxTokenBox.Password.Trim(); _state.Settings.MaxBotToken = MaxTokenBox.Password.Trim();
_state.Settings.MaxChatId = MaxChatBox.Text.Trim(); _state.Settings.MaxChatId = MaxChatBox.Text.Trim();
CopyUpdateSettingsFromControls();
}
private void CopyUpdateSettingsFromControls()
{
_state.Settings.UpdateChecksEnabled = UpdateChecksSwitch.IsOn;
_state.Settings.UpdateFeedUrl = UpdateFeedBox.Text.Trim();
_state.Settings.UpdateAccessToken = UpdateTokenBox.Password.Trim();
} }
private void LoadSettings() private void LoadSettings()
@ -892,6 +1032,10 @@ public sealed partial class MainPage : Page
MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled; MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled;
MaxTokenBox.Password = _state.Settings.MaxBotToken; MaxTokenBox.Password = _state.Settings.MaxBotToken;
MaxChatBox.Text = _state.Settings.MaxChatId; MaxChatBox.Text = _state.Settings.MaxChatId;
UpdateChecksSwitch.IsOn = _state.Settings.UpdateChecksEnabled;
UpdateFeedBox.Text = _state.Settings.UpdateFeedUrl;
UpdateTokenBox.Password = _state.Settings.UpdateAccessToken;
CurrentVersionText.Text = $"Текущая версия: {ForgejoUpdateSource.CurrentVersion.ToString(3)}";
} }
private void ApplyStartupSetting() private void ApplyStartupSetting()

View file

@ -78,6 +78,10 @@ public sealed class TerminalSettings
public bool MaxEnabled { get; set; } public bool MaxEnabled { get; set; }
public string MaxBotToken { get; set; } = ""; public string MaxBotToken { get; set; } = "";
public string MaxChatId { get; set; } = ""; public string MaxChatId { get; set; } = "";
public bool UpdateChecksEnabled { get; set; } = true;
public string UpdateFeedUrl { get; set; } = "https://git.ochenstarik.ru/api/v1/repos/ochenstarik/krona-staff/releases/latest";
public string UpdateAccessToken { get; set; } = "";
public DateTimeOffset LastUpdateCheckUtc { get; set; }
} }
public sealed class TerminalState public sealed class TerminalState

View file

@ -18,6 +18,8 @@
- закрытый административный раздел с входом по PIN или отпечатку администратора; - закрытый административный раздел с входом по PIN или отпечатку администратора;
- автоматическая блокировка административного раздела через 5 минут и при сворачивании; - автоматическая блокировка административного раздела через 5 минут и при сворачивании;
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI. - шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
- проверка новых релизов Forgejo при запуске и каждые шесть часов;
- загрузка обновления только после входа администратора и обязательной проверки SHA-256.
Исходное изображение пальца не сохраняется. Шаблон отпечатка хранится только локально и может быть расшифрован текущей учётной записью Windows. Исходное изображение пальца не сохраняется. Шаблон отпечатка хранится только локально и может быть расшифрован текущей учётной записью Windows.
@ -29,6 +31,16 @@
dotnet run --project apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Debug -p:Platform=x64 --launch-profile "KronaStaffTerminal (Unpackaged)" dotnet run --project apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Debug -p:Platform=x64 --launch-profile "KronaStaffTerminal (Unpackaged)"
``` ```
Для обычной установки скачайте из раздела Releases файл
`KronaStaffTerminal-Setup-<версия>.exe`. В конце установки можно открыть
[официальную страницу ZKFinger SDK для Windows](https://www.zkteco.com/en/Biometrics_Module_SDK/ZKFinger-SDK-for-Windows),
где находится драйвер совместимого сканера SLK20R.
Драйвер не включается внутрь платного продукта: EULA из полученного SDK разрешает
распространение в составе только бесплатных или некоммерческих пакетов. Для
коммерческого комплекта требуется отдельное письменное право распространения от
ZKTeco. Уже установленный драйвер продолжает работать как обычно.
## Первый сотрудник ## Первый сотрудник
1. Откройте вкладку «Сотрудники» и при первом входе создайте PIN длиной не менее шести символов. 1. Откройте вкладку «Сотрудники» и при первом входе создайте PIN длиной не менее шести символов.
@ -47,6 +59,29 @@ dotnet run --project apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Debug
После первичной настройки добавление сотрудников, регистрация и удаление отпечатков, изменение прав, уведомлений и параметров сканера доступны только в административном сеансе. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включается администратором в настройках. После первичной настройки добавление сотрудников, регистрация и удаление отпечатков, изменение прав, уведомлений и параметров сканера доступны только в административном сеансе. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включается администратором в настройках.
При блокировке Windows (`Win+L`) терминал продолжает работать. При полном выходе
из учётной записи Windows система завершает все пользовательские программы, поэтому
сканирование останавливается до следующего входа. Для работы вообще без открытого
сеанса потребуется отдельная Windows-служба сканера.
## Уведомления ## Уведомления
На вкладке «Уведомления и сканер» включите нужный канал, укажите токен бота и идентификатор чата, сохраните настройки и нажмите «Отправить тест». При успешном распознавании сообщение содержит имя сотрудника и местное время прихода. На вкладке «Уведомления и сканер» включите нужный канал, укажите токен бота и идентификатор чата, сохраните настройки и нажмите «Отправить тест». При успешном распознавании сообщение содержит имя сотрудника и местное время прихода.
## Обновления
Пока репозиторий приватный, администратор терминала указывает в настройках токен
Forgejo только с правом чтения репозитория. Токен хранится вместе с остальными
настройками в зашифрованном Windows DPAPI-файле текущего пользователя и не
встраивается в программу. Источник обновлений реализован отдельным адаптером:
впоследствии Forgejo можно заменить публичным портом Бизнес Платформы.
Для сборки установщика и двух файлов релиза:
```powershell
.\scripts\build-terminal-release.ps1
```
Скрипт публикует x64-приложение, создаёт установщик NSIS и соседний файл
`.sha256`. В релиз нужно загружать оба файла; без контрольной суммы встроенное
обновление откажется запускать установщик.

View file

@ -0,0 +1,261 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Reflection;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using KronaStaffTerminal.Models;
namespace KronaStaffTerminal.Services;
public sealed record AvailableUpdate(
Version Version,
string DisplayVersion,
string ReleaseName,
string ReleaseNotes,
Uri ReleasePage,
Uri InstallerUrl,
Uri ChecksumUrl,
string InstallerFileName);
public interface IUpdateSource
{
Task<AvailableUpdate?> FindUpdateAsync(TerminalSettings settings, CancellationToken cancellationToken);
Task<string> DownloadAndVerifyAsync(
AvailableUpdate update,
TerminalSettings settings,
IProgress<double>? progress,
CancellationToken cancellationToken);
}
public sealed class ForgejoUpdateSource : IUpdateSource
{
private const string InstallerPrefix = "KronaStaffTerminal-Setup-";
private static readonly HttpClient HttpClient = CreateHttpClient();
public async Task<AvailableUpdate?> FindUpdateAsync(
TerminalSettings settings,
CancellationToken cancellationToken)
{
if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) ||
feedUri.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException(
"Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo.");
}
using var request = CreateRequest(HttpMethod.Get, feedUri, settings.UpdateAccessToken);
using var response = await HttpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (response.StatusCode is HttpStatusCode.Unauthorized or
HttpStatusCode.Forbidden or
HttpStatusCode.NotFound)
{
throw new InvalidOperationException(
"Нет доступа к обновлениям. Укажите токен чтения приватного репозитория в настройках.");
}
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var release = await JsonSerializer.DeserializeAsync<ForgejoRelease>(
stream,
cancellationToken: cancellationToken)
?? throw new InvalidDataException("Forgejo вернул пустое описание релиза.");
if (!TryParseVersion(release.TagName, out var releaseVersion) ||
releaseVersion <= CurrentVersion)
{
return null;
}
var installer = release.Assets.FirstOrDefault(asset =>
asset.Name.StartsWith(InstallerPrefix, StringComparison.OrdinalIgnoreCase) &&
asset.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase));
var checksum = release.Assets.FirstOrDefault(asset =>
string.Equals(asset.Name, installer?.Name + ".sha256", StringComparison.OrdinalIgnoreCase));
if (installer is null ||
checksum is null ||
!Uri.TryCreate(installer.BrowserDownloadUrl, UriKind.Absolute, out var installerUri) ||
!Uri.TryCreate(checksum.BrowserDownloadUrl, UriKind.Absolute, out var checksumUri) ||
!Uri.TryCreate(release.HtmlUrl, UriKind.Absolute, out var releasePage))
{
throw new InvalidDataException(
"В релизе нет установщика Windows или файла контрольной суммы.");
}
return new AvailableUpdate(
releaseVersion,
releaseVersion.ToString(3),
string.IsNullOrWhiteSpace(release.Name)
? $"Krona Staff {releaseVersion.ToString(3)}"
: release.Name,
release.Body ?? "",
releasePage,
installerUri,
checksumUri,
installer.Name);
}
public async Task<string> DownloadAndVerifyAsync(
AvailableUpdate update,
TerminalSettings settings,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var updateDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Krona",
"StaffTerminal",
"updates",
update.DisplayVersion);
Directory.CreateDirectory(updateDirectory);
var installerPath = Path.Combine(
updateDirectory,
Path.GetFileName(update.InstallerFileName));
var temporaryPath = installerPath + ".download";
using var checksumRequest = CreateRequest(
HttpMethod.Get,
update.ChecksumUrl,
settings.UpdateAccessToken);
using var checksumResponse = await HttpClient.SendAsync(
checksumRequest,
cancellationToken);
checksumResponse.EnsureSuccessStatusCode();
var checksumText = await checksumResponse.Content.ReadAsStringAsync(cancellationToken);
var expectedHash = checksumText
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
if (expectedHash is null ||
expectedHash.Length != 64 ||
expectedHash.Any(character => !Uri.IsHexDigit(character)))
{
throw new InvalidDataException(
"Файл контрольной суммы обновления повреждён.");
}
using var installerRequest = CreateRequest(
HttpMethod.Get,
update.InstallerUrl,
settings.UpdateAccessToken);
using var installerResponse = await HttpClient.SendAsync(
installerRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
installerResponse.EnsureSuccessStatusCode();
var totalBytes = installerResponse.Content.Headers.ContentLength;
await using (var source = await installerResponse.Content.ReadAsStreamAsync(cancellationToken))
await using (var destination = new FileStream(
temporaryPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
81920,
FileOptions.Asynchronous))
{
var buffer = new byte[81920];
long received = 0;
int count;
while ((count = await source.ReadAsync(buffer, cancellationToken)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
received += count;
if (totalBytes is > 0)
{
progress?.Report((double)received / totalBytes.Value);
}
}
await destination.FlushAsync(cancellationToken);
}
await using var downloaded = File.OpenRead(temporaryPath);
var actualHash = Convert.ToHexString(
await SHA256.HashDataAsync(downloaded, cancellationToken));
if (!CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(expectedHash),
Convert.FromHexString(actualHash)))
{
File.Delete(temporaryPath);
throw new InvalidDataException(
"Контрольная сумма обновления не совпала. Установка отменена.");
}
File.Move(temporaryPath, installerPath, true);
return installerPath;
}
public static Version CurrentVersion =>
Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0);
public static void LaunchInstaller(string installerPath) =>
Process.Start(new ProcessStartInfo
{
FileName = installerPath,
UseShellExecute = true
});
private static HttpRequestMessage CreateRequest(
HttpMethod method,
Uri uri,
string token)
{
var request = new HttpRequestMessage(method, uri);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization =
new AuthenticationHeaderValue("token", token.Trim());
}
return request;
}
private static HttpClient CreateHttpClient()
{
var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
client.DefaultRequestHeaders.UserAgent.ParseAdd("KronaStaffTerminal/0.2");
return client;
}
private static bool TryParseVersion(string? value, out Version version)
{
var normalized = (value ?? "")
.Trim()
.TrimStart('v', 'V')
.Split('-', '+')[0];
if (Version.TryParse(normalized, out var parsed))
{
version = parsed;
return true;
}
version = new Version(0, 0, 0);
return false;
}
private sealed class ForgejoRelease
{
[JsonPropertyName("tag_name")]
public string TagName { get; set; } = "";
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; } = "";
[JsonPropertyName("assets")]
public List<ForgejoAsset> Assets { get; set; } = [];
}
private sealed class ForgejoAsset
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("browser_download_url")]
public string BrowserDownloadUrl { get; set; } = "";
}
}

View file

@ -0,0 +1,101 @@
Unicode True
SetCompressor /SOLID lzma
!include "MUI2.nsh"
!ifndef APP_VERSION
!define APP_VERSION "0.2.0"
!endif
!ifndef PUBLISH_DIR
!error "PUBLISH_DIR is required"
!endif
!ifndef OUTPUT_DIR
!error "OUTPUT_DIR is required"
!endif
!ifndef PROJECT_ROOT
!error "PROJECT_ROOT is required"
!endif
!define APP_NAME "Krona Staff Terminal"
!define APP_EXE "KronaStaffTerminal.exe"
!define APP_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\KronaStaffTerminal"
!define DRIVER_URL "https://www.zkteco.com/en/Biometrics_Module_SDK/ZKFinger-SDK-for-Windows"
Name "${APP_NAME}"
OutFile "${OUTPUT_DIR}\KronaStaffTerminal-Setup-${APP_VERSION}.exe"
InstallDir "$LOCALAPPDATA\Programs\Krona Staff Terminal"
InstallDirRegKey HKCU "${APP_REGISTRY_KEY}" "InstallLocation"
RequestExecutionLevel user
Icon "${PROJECT_ROOT}\apps\KronaStaffTerminal\Assets\AppIcon.ico"
BrandingText "Krona"
ShowInstDetails show
ShowUninstDetails show
VIProductVersion "${APP_VERSION}.0"
VIAddVersionKey /LANG=1049 "ProductName" "${APP_NAME}"
VIAddVersionKey /LANG=1049 "CompanyName" "Krona"
VIAddVersionKey /LANG=1049 "LegalCopyright" "Copyright © 2026 Krona"
VIAddVersionKey /LANG=1049 "FileDescription" "Установщик ${APP_NAME}"
VIAddVersionKey /LANG=1049 "FileVersion" "${APP_VERSION}"
VIAddVersionKey /LANG=1049 "ProductVersion" "${APP_VERSION}"
!define MUI_ABORTWARNING
!define MUI_ICON "${PROJECT_ROOT}\apps\KronaStaffTerminal\Assets\AppIcon.ico"
!define MUI_UNICON "${PROJECT_ROOT}\apps\KronaStaffTerminal\Assets\AppIcon.ico"
!define MUI_FINISHPAGE_RUN "$INSTDIR\${APP_EXE}"
!define MUI_FINISHPAGE_RUN_TEXT "Запустить Krona Staff Terminal"
!define MUI_FINISHPAGE_LINK "Скачать официальный драйвер ZKTeco/BioLink SLK20R"
!define MUI_FINISHPAGE_LINK_LOCATION "${DRIVER_URL}"
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_COMPONENTS
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "Russian"
Section "Krona Staff Terminal" SecMain
SectionIn RO
SetShellVarContext current
SetOutPath "$INSTDIR"
SetOverwrite try
File /r "${PUBLISH_DIR}\*"
WriteUninstaller "$INSTDIR\Uninstall.exe"
CreateDirectory "$SMPROGRAMS\Krona"
CreateShortcut "$SMPROGRAMS\Krona\Krona Staff Terminal.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayName" "${APP_NAME}"
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayVersion" "${APP_VERSION}"
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "Publisher" "Krona"
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayIcon" "$INSTDIR\${APP_EXE}"
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\""
WriteRegDWORD HKCU "${APP_REGISTRY_KEY}" "NoModify" 1
WriteRegDWORD HKCU "${APP_REGISTRY_KEY}" "NoRepair" 1
SectionEnd
Section "Значок на рабочем столе" SecDesktop
SetShellVarContext current
CreateShortcut "$DESKTOP\Krona Staff Terminal.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
SectionEnd
LangString DESC_SecMain ${LANG_RUSSIAN} "Основная программа учёта прихода и ухода."
LangString DESC_SecDesktop ${LANG_RUSSIAN} "Создать значок программы на рабочем столе."
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
!insertmacro MUI_DESCRIPTION_TEXT ${SecMain} $(DESC_SecMain)
!insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} $(DESC_SecDesktop)
!insertmacro MUI_FUNCTION_DESCRIPTION_END
Section "Uninstall"
SetShellVarContext current
Delete "$DESKTOP\Krona Staff Terminal.lnk"
Delete "$SMPROGRAMS\Krona\Krona Staff Terminal.lnk"
RMDir "$SMPROGRAMS\Krona"
DeleteRegKey HKCU "${APP_REGISTRY_KEY}"
RMDir /r "$INSTDIR"
SectionEnd

View file

@ -2,7 +2,7 @@
"$schema": "./schemas/module.schema.json", "$schema": "./schemas/module.schema.json",
"id": "ru.krona.staff", "id": "ru.krona.staff",
"name": "Krona Staff", "name": "Krona Staff",
"version": "0.1.0", "version": "0.2.0",
"min_platform_version": "3.2.0", "min_platform_version": "3.2.0",
"dependencies": ["ru.krona.core"], "dependencies": ["ru.krona.core"],
"optional_dependencies": [], "optional_dependencies": [],

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "@krona/staff", "name": "@krona/staff",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@krona/staff", "name": "@krona/staff",
"version": "0.1.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"@fastify/helmet": "^13.0.2", "@fastify/helmet": "^13.0.2",
"@fastify/sensible": "^6.0.3", "@fastify/sensible": "^6.0.3",

View file

@ -1,6 +1,6 @@
{ {
"name": "@krona/staff", "name": "@krona/staff",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {

View file

@ -0,0 +1,82 @@
[CmdletBinding()]
param(
[string]$Version
)
$ErrorActionPreference = 'Stop'
$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$projectPath = Join-Path $repositoryRoot 'apps\KronaStaffTerminal\KronaStaffTerminal.csproj'
$installerScript = Join-Path $repositoryRoot 'installer\KronaStaffTerminal.nsi'
$stagingRoot = Join-Path $repositoryRoot 'artifacts\terminal-release'
$publishDirectory = Join-Path $stagingRoot 'publish'
$outputDirectory = Join-Path $repositoryRoot 'artifacts'
if ([string]::IsNullOrWhiteSpace($Version)) {
[xml]$project = Get-Content -LiteralPath $projectPath -Raw
$Version = [string]$project.Project.PropertyGroup.Version
}
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
throw "Version must use MAJOR.MINOR.PATCH format."
}
$expectedStagingPrefix = $repositoryRoot.TrimEnd('\') + '\artifacts\'
$resolvedStaging = [System.IO.Path]::GetFullPath($stagingRoot)
if (-not $resolvedStaging.StartsWith($expectedStagingPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to clean staging outside repository artifacts."
}
if (Test-Path -LiteralPath $stagingRoot) {
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $publishDirectory -Force | Out-Null
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
$publishArguments = @(
'publish',
$projectPath,
'-c', 'Release',
'-r', 'win-x64',
'--self-contained', 'true',
'-p:Platform=x64',
"-p:Version=$Version",
"-p:BaseOutputPath=$(Join-Path $stagingRoot 'bin')\",
'-o', $publishDirectory
)
& dotnet @publishArguments
if ($LASTEXITCODE -ne 0) {
throw "Terminal publish failed."
}
$nsisCandidates = @(
(Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe'),
(Join-Path $env:ProgramFiles 'NSIS\makensis.exe'),
(Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'Programs\NSIS\makensis.exe')
)
$makensis = $nsisCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
if ($null -eq $makensis) {
throw "NSIS 3 is not installed."
}
$nsisArguments = @(
'/INPUTCHARSET', 'UTF8',
"/DAPP_VERSION=$Version",
"/DPUBLISH_DIR=$publishDirectory",
"/DOUTPUT_DIR=$outputDirectory",
"/DPROJECT_ROOT=$repositoryRoot",
$installerScript
)
& $makensis @nsisArguments
if ($LASTEXITCODE -ne 0) {
throw "Installer compilation failed."
}
$installerPath = Join-Path $outputDirectory "KronaStaffTerminal-Setup-$Version.exe"
$hash = (Get-FileHash -LiteralPath $installerPath -Algorithm SHA256).Hash.ToLowerInvariant()
$checksumPath = "$installerPath.sha256"
$checksumLine = "$hash $([System.IO.Path]::GetFileName($installerPath))" + [Environment]::NewLine
[System.IO.File]::WriteAllText(
$checksumPath,
$checksumLine,
[System.Text.UTF8Encoding]::new($false))
Write-Output $installerPath
Write-Output $checksumPath

View file

@ -1,2 +1,2 @@
export const SERVICE_NAME = 'krona-staff'; export const SERVICE_NAME = 'krona-staff';
export const SERVICE_VERSION = '0.1.0'; export const SERVICE_VERSION = '0.2.0';