feat(terminal): add secure release updates and installer
This commit is contained in:
parent
d45bee960a
commit
5b42a6d59b
14 changed files with 694 additions and 10 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -11,3 +11,5 @@ backups/
|
|||
restore-work/
|
||||
apps/KronaStaffTerminal/bin/
|
||||
apps/KronaStaffTerminal/obj/
|
||||
artifacts/
|
||||
.audit-build/
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
openapi: 3.1.0
|
||||
info:
|
||||
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.
|
||||
license:
|
||||
name: Proprietary
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<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>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
@ -81,8 +85,25 @@
|
|||
|
||||
<!-- Publish Properties -->
|
||||
<PropertyGroup>
|
||||
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
|
||||
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
|
||||
<PublishReadyToRun>False</PublishReadyToRun>
|
||||
<PublishTrimmed>False</PublishTrimmed>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
RowSpacing="12"
|
||||
Background="{ThemeResource KronaAppBackgroundBrush}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
|
@ -25,8 +26,21 @@
|
|||
Title="Подключение"
|
||||
Message="Запуск терминала…"/>
|
||||
|
||||
<TabView x:Name="MainTabs"
|
||||
<InfoBar x:Name="UpdateBar"
|
||||
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}"
|
||||
IsAddTabButtonVisible="False"
|
||||
SelectionChanged="MainTabs_SelectionChanged">
|
||||
|
|
@ -205,6 +219,26 @@
|
|||
<ToggleSwitch x:Name="MaxEnabledSwitch" Header="MAX" OffContent="Выключен" OnContent="Включён"/>
|
||||
<PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/>
|
||||
<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">
|
||||
<Button Content="Сохранить настройки"
|
||||
Background="{ThemeResource KronaPrimaryBrush}"
|
||||
|
|
|
|||
|
|
@ -14,11 +14,16 @@ public sealed partial class MainPage : Page
|
|||
private readonly TerminalStore _store = new();
|
||||
private readonly NotificationService _notifications = new();
|
||||
private readonly FingerprintScanner _scanner = new();
|
||||
private readonly IUpdateSource _updateSource = new ForgejoUpdateSource();
|
||||
private TerminalState _state = new();
|
||||
private bool _loaded;
|
||||
private TaskCompletionSource<bool>? _adminAuthCompletion;
|
||||
private ContentDialog? _adminAuthDialog;
|
||||
private readonly DispatcherQueueTimer _adminSessionTimer;
|
||||
private readonly DispatcherQueueTimer _updateTimer;
|
||||
private readonly CancellationTokenSource _updateCancellation = new();
|
||||
private AvailableUpdate? _availableUpdate;
|
||||
private bool _updateCheckInProgress;
|
||||
private bool _adminSessionActive;
|
||||
private bool _adminNavigationInProgress;
|
||||
private bool _stateLoadFailed;
|
||||
|
|
@ -31,7 +36,15 @@ public sealed partial class MainPage : Page
|
|||
|
||||
public MainPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
try
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
CrashLogService.Write(exception);
|
||||
throw;
|
||||
}
|
||||
_scanner.StatusChanged += message => Enqueue(() => SetStatus(message));
|
||||
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3"));
|
||||
_scanner.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template));
|
||||
|
|
@ -49,6 +62,10 @@ public sealed partial class MainPage : Page
|
|||
}
|
||||
};
|
||||
_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)
|
||||
|
|
@ -72,17 +89,21 @@ public sealed partial class MainPage : Page
|
|||
ApplyStartupSetting();
|
||||
RefreshLists();
|
||||
ConnectScanner();
|
||||
_updateTimer.Start();
|
||||
MainTabs.SelectedItem = TerminalTab;
|
||||
if (_store.RecoveryNotice is not null)
|
||||
{
|
||||
SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning);
|
||||
}
|
||||
await CheckForUpdatesAsync(false);
|
||||
}
|
||||
|
||||
private void Page_Unloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_loaded = false;
|
||||
_adminSessionTimer.Stop();
|
||||
_updateTimer.Stop();
|
||||
_updateCancellation.Cancel();
|
||||
_scanner.Dispose();
|
||||
}
|
||||
|
||||
|
|
@ -857,6 +878,117 @@ public sealed partial class MainPage : Page
|
|||
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)
|
||||
{
|
||||
if (!await EnsureAdminSessionAsync("проверка уведомлений")) return;
|
||||
|
|
@ -878,6 +1010,14 @@ public sealed partial class MainPage : Page
|
|||
_state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn;
|
||||
_state.Settings.MaxBotToken = MaxTokenBox.Password.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()
|
||||
|
|
@ -892,6 +1032,10 @@ public sealed partial class MainPage : Page
|
|||
MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled;
|
||||
MaxTokenBox.Password = _state.Settings.MaxBotToken;
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ public sealed class TerminalSettings
|
|||
public bool MaxEnabled { get; set; }
|
||||
public string MaxBotToken { 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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
- закрытый административный раздел с входом по PIN или отпечатку администратора;
|
||||
- автоматическая блокировка административного раздела через 5 минут и при сворачивании;
|
||||
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
|
||||
- проверка новых релизов Forgejo при запуске и каждые шесть часов;
|
||||
- загрузка обновления только после входа администратора и обязательной проверки SHA-256.
|
||||
|
||||
Исходное изображение пальца не сохраняется. Шаблон отпечатка хранится только локально и может быть расшифрован текущей учётной записью Windows.
|
||||
|
||||
|
|
@ -29,6 +31,16 @@
|
|||
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 длиной не менее шести символов.
|
||||
|
|
@ -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`. В релиз нужно загружать оба файла; без контрольной суммы встроенное
|
||||
обновление откажется запускать установщик.
|
||||
|
|
|
|||
261
apps/KronaStaffTerminal/Services/UpdateService.cs
Normal file
261
apps/KronaStaffTerminal/Services/UpdateService.cs
Normal 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; } = "";
|
||||
}
|
||||
}
|
||||
101
installer/KronaStaffTerminal.nsi
Normal file
101
installer/KronaStaffTerminal.nsi
Normal 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
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"$schema": "./schemas/module.schema.json",
|
||||
"id": "ru.krona.staff",
|
||||
"name": "Krona Staff",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"min_platform_version": "3.2.0",
|
||||
"dependencies": ["ru.krona.core"],
|
||||
"optional_dependencies": [],
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@krona/staff",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@krona/staff",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krona/staff",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
|
|
|||
82
scripts/build-terminal-release.ps1
Normal file
82
scripts/build-terminal-release.ps1
Normal 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
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
export const SERVICE_NAME = 'krona-staff';
|
||||
export const SERVICE_VERSION = '0.1.0';
|
||||
export const SERVICE_VERSION = '0.2.0';
|
||||
|
|
|
|||
Loading…
Reference in a new issue