From 1b416743aa9086bb80ddafc4f8e53978a8cc061b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 12:42:33 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=D0=92=D0=B5=D1=80=D1=81=D0=B8=D1=8F=201.3.?= =?UTF-8?q?1:=20=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D0=B6=D0=B5?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D0=B8=D1=8E=20=D0=B8=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D0=BE=D0=BA=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Галочка в настройках по умолчанию выключена: .bak по-прежнему только при замене файла без объединения и при исключении подписантов. С галочкой копия пишется перед каждой перезаписью существующего .sig. На главном окне — версия вместо автора, кнопки списка над зоной файлов, «Применить ко всем»; пункт «Объединить .sig…» убран из меню. Co-authored-by: sozertsatel --- .github/workflows/test-latest.yml | 75 +++++++++++ CHANGELOG.md | 19 +++ src/SignService/Services/AppSettings.cs | 13 ++ src/SignService/Services/DocumentSigner.cs | 15 ++- src/SignService/SignService.csproj | 2 +- .../ViewModels/MainWindowViewModel.cs | 123 ++++++++++++------ src/SignService/ViewModels/SignFileItem.cs | 96 +++++++++++++- src/SignService/Views/MainWindow.axaml | 75 ++++++----- src/SignService/Views/MainWindow.axaml.cs | 24 ---- src/SignService/Views/SettingsDialog.axaml | 9 +- src/SignService/Views/SettingsDialog.axaml.cs | 2 +- tests/SignService.Tests/CoSignTests.cs | 100 +++++++++++++- tests/SignService.Tests/UiSmokeTests.cs | 36 ++++- 13 files changed, 469 insertions(+), 120 deletions(-) create mode 100644 .github/workflows/test-latest.yml diff --git a/.github/workflows/test-latest.yml b/.github/workflows/test-latest.yml new file mode 100644 index 0000000..65a9a0e --- /dev/null +++ b/.github/workflows/test-latest.yml @@ -0,0 +1,75 @@ +# Постоянная тестовая сборка форка: тег test-latest всегда указывает +# на последний коммит, а SignService.exe по одной и той же ссылке +# заменяется. На zaynullinmi/SignService задание не выполняется. +name: Test build + +on: + push: + branches: + - cursor/v1.3.1-ui-tweaks-cb6f + - cursor/cosign-discovery-gost-cb6f + - main + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: test-latest + cancel-in-progress: false + +jobs: + publish-test-latest: + name: Тестовая сборка test-latest + if: github.repository == 'sozertsatel/SignService' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + # Те же флаги, что у задания «Publish self-contained single-file exe» + # в .github/workflows/build.yml. + - name: Publish self-contained single-file exe + run: > + dotnet publish src/SignService -c Release -r win-x64 + --self-contained true + -p:PublishSingleFile=true + -p:IncludeNativeLibrariesForSelfExtract=true + -p:EnableCompressionInSingleFile=true + -o publish + + - name: SHA-256 + working-directory: publish + run: sha256sum SignService.exe | tee SignService.exe.sha256 + + - name: Обновить pre-release test-latest + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="$(sed -n 's/.*\([^<]*\)<\/Version>.*/\1/p' src/SignService/SignService.csproj | head -1)" + sha="$(git rev-parse HEAD)" + notes="$(printf '%s\n' \ + "Тестовая сборка форка, не выпуск для пользователей. Автообновление SignService запрашивает только релизы https://github.com/zaynullinmi/SignService/releases/latest и этот pre-release не видит." \ + "" \ + "- Версия: ${version}" \ + "- Коммит: ${sha}" \ + "- Ветка: ${GITHUB_REF_NAME}" \ + "" \ + "https://github.com/sozertsatel/SignService/releases/download/test-latest/SignService.exe")" + if gh release view test-latest >/dev/null 2>&1; then + gh release delete test-latest --yes --cleanup-tag + fi + # Тег без релиза тоже сдвигаем, иначе create останется на старом коммите. + git push origin ":refs/tags/test-latest" || true + gh release create test-latest \ + publish/SignService.exe \ + publish/SignService.exe.sha256 \ + --target "$sha" \ + --title "Тестовая сборка (последняя)" \ + --prerelease \ + --latest=false \ + --notes "$notes" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3548704..22b10d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # История версий SignService +## v1.3.1 — 09.10.2026 + +- **Резервная копия .sig.** В «Настройках…» — галочка «Всегда создавать + резервную копию .sig (.bak) перед подписанием», по умолчанию выключена. + Без неё копия по-прежнему создаётся только в рискованных случаях: замена + файла подписи без объединения или исключение подписантов. С галочкой + .bak пишется перед каждой перезаписью существующего .sig, в том числе + при обычном дописывании. +- **Версия на главном окне.** Строка с автором убрана (автор остаётся + в «О программе»). На её месте — версия программы. +- «Обзор…» и «Инструменты» стоят над списком файлов в одной строке, + «Очистить список» — у правого края. +- Пункт «Объединить .sig…» убран из меню «Инструменты»: соподписание + выбирается в строке файла. +- **«Применить ко всем»** рядом с «Инструменты». Все файлы очереди, + включая добавленные позже, берут режим первого файла. Если добавить + к существующей подписи нельзя, строка остаётся на «Создать новую» + и пишет об этом. Состояние галочки запоминается. + ## v1.3.0 — 09.10.2026 - **Проверка ГОСТ-подписей КриптоПро.** Подписи, у которых в signatureAlgorithm diff --git a/src/SignService/Services/AppSettings.cs b/src/SignService/Services/AppSettings.cs index 091c51e..7ed305c 100644 --- a/src/SignService/Services/AppSettings.cs +++ b/src/SignService/Services/AppSettings.cs @@ -38,6 +38,19 @@ public AppSettings(string settingsDirectory) /// Объединять свою подпись с существующим .sig (соподписание). public bool MergeWithExisting { get; set; } = true; + /// + /// Сохранять .bak перед любой перезаписью существующего .sig. + /// По умолчанию выключено: копия всё равно создаётся, если файл заменяется + /// без объединения или из него исключаются подписанты. + /// + public bool CreateSignatureBackup { get; set; } + + /// + /// Все файлы очереди используют режим первого файла + /// («Создать новую» или «Добавить к существующей»). + /// + public bool ApplyCoSignToAll { get; set; } + /// Добавлять штамп времени TSA (CAdES-T) в подпись. public bool UseTimestamp { get; set; } diff --git a/src/SignService/Services/DocumentSigner.cs b/src/SignService/Services/DocumentSigner.cs index de9c126..bebeec7 100644 --- a/src/SignService/Services/DocumentSigner.cs +++ b/src/SignService/Services/DocumentSigner.cs @@ -28,6 +28,13 @@ public sealed record SignOptions /// Объединять ли с уже существующим файлом .sig (соподписание). public bool MergeWithExisting { get; init; } + /// + /// Сохранять .bak перед любой перезаписью существующего .sig, в том числе + /// при обычном дописывании. Без этого копия создаётся только в рискованных + /// случаях: замена без объединения или исключение подписантов. + /// + public bool CreateBackup { get; init; } + /// Пути к .sig других подписантов для объединения. public IReadOnlyList ExtraSignatures { get; init; } = Array.Empty(); @@ -257,11 +264,11 @@ void AddInputPath(string? path) if (!(await File.ReadAllBytesAsync(targetPath, cancellationToken)).AsSpan().SequenceEqual(data)) throw new IOException("Документ изменился во время подписания. Подпись не сохранена; повторите операцию."); if (poaPackage is not null) PowerOfAttorneyService.CopyPackage(poaPackage, targetPath); - // Резервная копия — только когда прежний файл теряет данные: он заменяется без - // объединения или из него исключаются подписанты. Дописанная подпись сохраняет - // всех прежних подписантов байт в байт, копия тогда не нужна. + // Рискованные случаи — всегда: файл заменяется без объединения или из него + // исключаются подписанты. Галочка добавляет копию и при обычном дописывании. string? backupPath = null; - if (File.Exists(signaturePath) && (!appended || excluded.Count > 0)) + var riskyOverwrite = !appended || excluded.Count > 0; + if (File.Exists(signaturePath) && (riskyOverwrite || options.CreateBackup)) backupPath = SignatureBackup.Create(signaturePath); await AtomicFile.WriteAsync(signaturePath, output, overwrite: true, cancellationToken); diff --git a/src/SignService/SignService.csproj b/src/SignService/SignService.csproj index d326609..34eb9d8 100644 --- a/src/SignService/SignService.csproj +++ b/src/SignService/SignService.csproj @@ -10,7 +10,7 @@ true SignService — подписание документов ЭЦП SignService - 1.3.0 + 1.3.1 Зайнуллин Марат Илгамович Зайнуллин Марат Илгамович © 2026 Зайнуллин Марат Илгамович, +7-963-694-2461, zaynullinmi@gmail.com diff --git a/src/SignService/ViewModels/MainWindowViewModel.cs b/src/SignService/ViewModels/MainWindowViewModel.cs index 3961fe6..50ccc37 100644 --- a/src/SignService/ViewModels/MainWindowViewModel.cs +++ b/src/SignService/ViewModels/MainWindowViewModel.cs @@ -38,6 +38,8 @@ public MainWindowViewModel(CertificateProvider certificateProvider, DocumentSign _initializing = true; IsDetached = _settings.DetachedSignature; MergeWithExisting = _settings.MergeWithExisting; + CreateSignatureBackup = _settings.CreateSignatureBackup; + ApplyCoSignToAll = _settings.ApplyCoSignToAll; UseTimestamp = _settings.UseTimestamp; TsaUrl = _settings.TsaUrl; UseStamp = _settings.UseStamp; @@ -59,6 +61,9 @@ public MainWindowViewModel(CertificateProvider certificateProvider, DocumentSign /// Настройки приложения — для окна «О программе». public AppSettings Settings => _settings; + /// Версия программы для нижней строки главного окна. + public string VersionDisplay => "Версия " + UpdateService.CurrentVersion; + // Тихая проверка обновлений при запуске: при наличии новой версии — строка // в статусе/логе, никаких всплывающих окон. private async Task CheckUpdatesOnStartAsync() @@ -102,6 +107,16 @@ private async Task CheckUpdatesOnStartAsync() [ObservableProperty] private bool _mergeWithExisting = true; + /// Сохранять .bak перед любой перезаписью существующего .sig. + [ObservableProperty] + private bool _createSignatureBackup; + + /// Режим первого файла в очереди применяется ко всем остальным. + [ObservableProperty] + private bool _applyCoSignToAll; + + private bool _syncingCoSign; + [ObservableProperty] private bool _includeExpiredCertificates; @@ -163,7 +178,6 @@ private async Task CheckUpdatesOnStartAsync() [NotifyCanExecuteChangedFor(nameof(RemoveFromStoreCommand))] [NotifyCanExecuteChangedFor(nameof(StampOnlyCommand))] [NotifyCanExecuteChangedFor(nameof(ExtractCommand))] - [NotifyCanExecuteChangedFor(nameof(MergeFilesCommand))] [NotifyCanExecuteChangedFor(nameof(BuildContainerCommand))] [NotifyCanExecuteChangedFor(nameof(VerifySignatureCommand))] [NotifyCanExecuteChangedFor(nameof(SplitSignaturesCommand))] @@ -214,9 +228,6 @@ private void Log(string message) /// Запрос диалога выбора контейнеров для извлечения. public event EventHandler? ExtractRequested; - /// Запрос диалога выбора подписей для объединения без подписания. - public event EventHandler? MergeFilesRequested; - /// Запрос диалога выбора PDF для штампа без подписания. public event EventHandler? StampOnlyRequested; @@ -300,6 +311,63 @@ partial void OnMergeWithExistingChanged(bool value) foreach (var file in Files) file.ApplyPreferredMode(value, resetPin: !_initializing); + if (ApplyCoSignToAll) + SyncSharedCoSignMode(); + } + + partial void OnCreateSignatureBackupChanged(bool value) + { + if (!_initializing) + { + _settings.CreateSignatureBackup = value; + _settings.Save(); + } + + foreach (var file in Files) + file.SetBackupMention(value); + } + + partial void OnApplyCoSignToAllChanged(bool value) + { + if (!_initializing) + { + _settings.ApplyCoSignToAll = value; + _settings.Save(); + } + + SyncSharedCoSignMode(); + } + + /// Смена режима в строке: при «Применить ко всем» источник — первый файл. + internal void NotifyCoSignSelectionChanged(SignFileItem item) + { + if (_syncingCoSign || !ApplyCoSignToAll || Files.Count == 0 || !ReferenceEquals(Files[0], item)) + return; + SyncSharedCoSignMode(); + } + + private void SyncSharedCoSignMode() + { + if (_syncingCoSign) + return; + _syncingCoSign = true; + try + { + var add = Files.Count > 0 && Files[0].SelectedCoSignOption is { CreateNew: false }; + for (var i = 0; i < Files.Count; i++) + { + var file = Files[i]; + file.CoSignLocked = ApplyCoSignToAll && i > 0; + if (!ApplyCoSignToAll || i == 0) + file.StopFollowingSharedMode(); + else + file.FollowSharedMode(add); + } + } + finally + { + _syncingCoSign = false; + } } partial void OnUseTimestampChanged(bool value) @@ -739,6 +807,7 @@ public async Task AddFilesAsync(params string[] filePaths) continue; var item = new SignFileItem(path) { Owner = this }; + item.SetBackupMention(CreateSignatureBackup); item.SignsStampedCopy = SignsStampedCopy(item); Files.Add(item); added.Add(item); @@ -845,6 +914,8 @@ private async Task DiscoverAsync(IReadOnlyList t matched.Add(System.IO.Path.GetFullPath(path)); } + if (ApplyCoSignToAll) + SyncSharedCoSignMode(); return new DiscoveryOutcome(withMatches, attached, matched); } finally @@ -873,9 +944,6 @@ public void AttachSignatureFiles(SignFileItem item, IReadOnlyList paths) [RelayCommand(CanExecute = nameof(CanBrowse))] private void Extract() => ExtractRequested?.Invoke(this, EventArgs.Empty); - [RelayCommand(CanExecute = nameof(CanBrowse))] - private void MergeFiles() => MergeFilesRequested?.Invoke(this, EventArgs.Empty); - [RelayCommand(CanExecute = nameof(CanBrowse))] private void BuildContainer() => BuildContainerRequested?.Invoke(this, EventArgs.Empty); @@ -1191,39 +1259,6 @@ private void OpenFolder(SignFileItem item) } } - /// Объединяет выбранные файлы подписей в один — без создания своей подписи. - public async Task MergeSignatureFilesAsync(IReadOnlyList paths) - { - if (paths.Count == 0) - return; - - IsBusy = true; - try - { - var r = await Task.Run(() => CmsExtractor.MergeSignatureFiles(paths)); - var parts = new List - { - $"подписантов: {r.SignerCount}", - r.AttachedOutput ? "прикреплённая (документ внутри)" : "откреплённая", - r.DocumentNote, - }; - if (r.ExcludedSigners.Count > 0) - parts.Add("исключены не соответствующие документу: " + string.Join("; ", r.ExcludedSigners)); - if (r.UnverifiedSigners.Count > 0) - parts.Add("не удалось проверить: " + string.Join("; ", r.UnverifiedSigners)); - - StatusText = $"Объединено → «{System.IO.Path.GetFileName(r.OutputPath)}»: {string.Join(", ", parts)}"; - } - catch (Exception ex) - { - StatusText = "Объединение не выполнено: " + ex.Message; - } - finally - { - IsBusy = false; - } - } - /// Извлекает содержимое выбранных контейнеров в файлы рядом с ними. public async Task ExtractContainersAsync(IReadOnlyList containerPaths) { @@ -1265,7 +1300,12 @@ public async Task ExtractContainersAsync(IReadOnlyList containerPaths) } [RelayCommand] - private void RemoveFile(SignFileItem item) => Files.Remove(item); + private void RemoveFile(SignFileItem item) + { + Files.Remove(item); + if (ApplyCoSignToAll) + SyncSharedCoSignMode(); + } [RelayCommand(CanExecute = nameof(CanClear))] private void Clear() @@ -1459,6 +1499,7 @@ private async Task SignAllAsync() StampWithDate = StampWithDate, StampLogoPath = StampLogoPath, StampParameters = stampParameters, + CreateBackup = CreateSignatureBackup, PowerOfAttorney = poaPackage?.Info, PreparedPowerOfAttorney = poaPackage, }; diff --git a/src/SignService/ViewModels/SignFileItem.cs b/src/SignService/ViewModels/SignFileItem.cs index 085b012..a3e529b 100644 --- a/src/SignService/ViewModels/SignFileItem.cs +++ b/src/SignService/ViewModels/SignFileItem.cs @@ -48,6 +48,9 @@ public partial class SignFileItem : ObservableObject private readonly List _extraSignatures = new(); private IReadOnlyList _discovered = Array.Empty(); private bool _adjustingSelection; + private bool _mentionBackup; + private bool _followingShared; + private bool _sharedWantsAdd; private int _unionCount; private string _planText = "будет создан новый файл подписи"; @@ -143,9 +146,20 @@ public SignFileItem(string filePath) [ObservableProperty] private CoSignOption? _selectedCoSignOption; + /// Режим задаёт первый файл («Применить ко всем») — список в этой строке только для чтения. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanChooseCoSign))] + private bool _coSignLocked; + + /// Общий режим «добавить» для этого файла невозможен. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(WarningDisplay))] + [NotifyPropertyChangedFor(nameof(HasWarning))] + private bool _sharedAddUnavailable; + /// Режим можно менять, пока файл ждёт подписания и программа не занята. public bool CanChooseCoSign => Status is SignStatus.Pending or SignStatus.Failed && !IsDiscovering && !SignsStampedCopy - && Owner?.IsBusy != true; + && !CoSignLocked && Owner?.IsBusy != true; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasExtra))] @@ -177,7 +191,9 @@ public void ApplyDiscovery(SignatureDiscovery.DocumentDiscovery discovery, bool CoSignOptions.Add(new CoSignOption(match.Path, match.SignerCount)); CoSignOptions.Add(new CoSignOption(null, 0)); - if (pinned && previous is null) + if (_followingShared) + ApplySharedSelection(); + else if (pinned && previous is null) SelectedCoSignOption = CoSignOptions.Last(option => option.CreateNew); else if (pinned) SelectedCoSignOption = CoSignOptions.FirstOrDefault(option => @@ -192,6 +208,40 @@ public void ApplyDiscovery(SignatureDiscovery.DocumentDiscovery discovery, bool UpdatePlan(); } + /// Упоминать ли в плане, что перед потерей данных сохранится .bak. + public void SetBackupMention(bool enabled) + { + if (_mentionBackup == enabled) + return; + _mentionBackup = enabled; + UpdatePlan(); + } + + /// + /// Повторяет режим первого файла. Если добавить не к чему, остаётся «Создать новую» + /// и строка явно об этом говорит. + /// + public void FollowSharedMode(bool addToExisting) + { + _followingShared = true; + _sharedWantsAdd = addToExisting; + CoSignPinned = false; + _adjustingSelection = true; + ApplySharedSelection(); + _adjustingSelection = false; + UpdatePlan(); + } + + /// Снимает общий режим, не меняя уже выбранный пункт. + public void StopFollowingSharedMode() + { + _followingShared = false; + if (!SharedAddUnavailable) + return; + SharedAddUnavailable = false; + UpdatePlan(); + } + /// Общий переключатель «добавить / создать новую» для файлов без ручного выбора. public void ApplyPreferredMode(bool addToExisting, bool resetPin) { @@ -312,6 +362,25 @@ partial void OnSelectedCoSignOptionChanged(CoSignOption? value) if (!_adjustingSelection && value is not null) CoSignPinned = true; UpdatePlan(); + if (!_adjustingSelection && value is not null) + Owner?.NotifyCoSignSelectionChanged(this); + } + + private void ApplySharedSelection() + { + var add = _sharedWantsAdd && !SignsStampedCopy + ? CoSignOptions.FirstOrDefault(option => !option.CreateNew) + : null; + if (add is null) + { + SelectedCoSignOption = CoSignOptions.LastOrDefault(option => option.CreateNew) ?? CoSignOptions.LastOrDefault(); + SharedAddUnavailable = _sharedWantsAdd; + } + else + { + SelectedCoSignOption = add; + SharedAddUnavailable = false; + } } private void UpdatePlan() @@ -341,6 +410,8 @@ private string BuildPlan() $"будет добавлена подпись №{baseCount + 1} к «{Path.GetFileName(path)}» ({SelectedCoSignOption.SignerCount} {SignatureDiscovery.SignerWord(SelectedCoSignOption.SignerCount)})"; if (others.Count > 0) sentence += "; также объединятся: " + string.Join(", ", others.Select(match => "«" + Path.GetFileName(match.Path) + "»")); + if (_mentionBackup) + sentence += " (сохранится резервная копия)"; return sentence + extras; } @@ -348,6 +419,7 @@ private string BuildPlan() if (File.Exists(canonical)) return "будет создан новый файл подписи — существующий «" + Path.GetFileName(canonical) + "» будет заменён (сохранится резервная копия)" + extras; + return "будет создан новый файл подписи" + extras; } @@ -358,13 +430,23 @@ private string BuildPlan() private string? DiscoveryWarning() { - if (Status != SignStatus.Pending || IsDiscovering || SignsStampedCopy) + if (Status != SignStatus.Pending || IsDiscovering) return null; + string? fallback = null; + if (SharedAddUnavailable) + fallback = SignsStampedCopy + ? "Для копии со штампом режим «Добавить к существующей» недоступен — будет создан новый файл подписи." + : "Нет подходящей подписи — будет создан новый файл. Режим «Добавить к существующей», как у первого файла, здесь недоступен."; + if (SignsStampedCopy) + return fallback; var names = PlannedDiscovered().SelectMany(match => match.MismatchedSignerNames).Distinct().ToList(); - if (names.Count == 0) - return null; - return "Внимание: при объединении будут исключены подписанты, чья подпись не соответствует документу: " - + string.Join("; ", names) + ". Перед исключением программа запросит подтверждение."; + var discovery = names.Count == 0 + ? null + : "Внимание: при объединении будут исключены подписанты, чья подпись не соответствует документу: " + + string.Join("; ", names) + ". Перед исключением программа запросит подтверждение."; + if (fallback is null) + return discovery; + return discovery is null ? fallback : fallback + " " + discovery; } private string SignedNote => string.IsNullOrEmpty(Message) ? string.Empty : $" ⚠ {Message}"; diff --git a/src/SignService/Views/MainWindow.axaml b/src/SignService/Views/MainWindow.axaml index a10ecd7..91c9af5 100644 --- a/src/SignService/Views/MainWindow.axaml +++ b/src/SignService/Views/MainWindow.axaml @@ -9,7 +9,7 @@ WindowStartupLocation="CenterScreen" DragDrop.AllowDrop="True"> - + @@ -104,8 +104,39 @@ + + + + + -