From 536ced056a1ffa5a271797c56489f35b040a3aeb Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Tue, 11 Aug 2026 19:35:29 +0000 Subject: [PATCH 1/4] Validate the export against the preset's effective format Result validation read the selected export format directly when deciding which sample rate to expect. The archive preset forces FLAC and a stream copy keeps the source format, so a correct export was reported with a sample-rate warning whenever the Premiere profile was still selected. Both cases now resolve the format the same way the processing does, and a stream copy no longer claims a lossy source was written to a lossless format. --- .../AudioValidationServiceTests.cs | 45 +++++++++++++++++++ CHANGELOG.md | 3 ++ Services/AudioValidationService.cs | 14 +++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs b/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs index e6165dc..9481c0b 100644 --- a/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs +++ b/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs @@ -175,6 +175,51 @@ public void BuildReport_PremiereProfileRequires48Khz() Assert.Contains(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.SampleRateMismatch); } + [Fact] + public void BuildReport_ArchivePresetKeepsSourceSampleRateWithPremiereSelected() + { + using var source = CreateInfo("flac", isLossy: false, sampleRate: 44_100); + using var output = CreateInfo("flac", isLossy: false, sampleRate: 44_100); + using var diagnostics = new AudioDiagnostics + { + IntegratedLoudnessLufs = -14, + TruePeakDb = -2 + }; + + // The archive preset forces FLAC, so the selected Premiere profile (48 kHz) + // must not be validated against. + var report = AudioValidationService.BuildReport( + CreateOptions(source, AudioPreset.ArchiveExport, ExportFormat.PremierePro), + source, + output, + sourceDiagnostics: null, + diagnostics, + outputDiagnosticsSkipped: false, + outputPath: @"C:\audio\song_archive_flac.flac"); + + Assert.DoesNotContain(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.SampleRateMismatch); + } + + [Fact] + public void BuildReport_StreamCopyKeepsSourceSampleRateWithPremiereSelected() + { + using var source = CreateInfo("mp3", isLossy: true, sampleRate: 44_100); + using var output = CreateInfo("mp3", isLossy: true, sampleRate: 44_100); + + var report = AudioValidationService.BuildReport( + CreateOptions(source, AudioPreset.ExtractCopy, ExportFormat.PremierePro), + source, + output, + sourceDiagnostics: null, + outputDiagnostics: null, + outputDiagnosticsSkipped: true, + outputPath: @"C:\audio\song_extracted.mp3"); + + Assert.DoesNotContain(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.SampleRateMismatch); + Assert.DoesNotContain(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.LossyToLossless); + Assert.Contains(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.StreamCopyMetadataOnly); + } + [Fact] public void BuildReport_ChannelCountChangeCreatesWarning() { diff --git a/CHANGELOG.md b/CHANGELOG.md index 66dd35b..7f1a2ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,14 @@ - A result validation that ends with a critical finding now fails the affected file instead of reporting it as exported successfully. The report stays attached to the item, so the findings remain visible in the validation panel. - A run that was stopped is no longer misreported: an inactivity timeout now ends the wait for the process directly and is reported as a timeout, while only an actual user cancellation is reported as one. - After a process is terminated the app waits briefly for it to exit and stops the output readers before reading what was captured, so the collected output can no longer be read while it is still being written. +- Result validation no longer expects 48 kHz when the Premiere profile is selected but the preset does not use it: the archive preset forces FLAC and a stream copy keeps the source format, so both reported a sample-rate warning for a correct export. A stream copy also no longer claims that a lossy source was written to a lossless format. ### Changed - The release packaging script now requires an explicit `-Version` in `major.minor.patch` form instead of falling back to a stale default version. +## 0.17.0 - 2026-06-13 + ### Added - A video/audio URL can now be dropped onto the window to fill the download field. diff --git a/Services/AudioValidationService.cs b/Services/AudioValidationService.cs index aeb8dbe..aa352bb 100644 --- a/Services/AudioValidationService.cs +++ b/Services/AudioValidationService.cs @@ -128,8 +128,10 @@ internal static AudioComparisonReport BuildReport( AddPeakFindings(outputDiagnostics, findings); AddLoudnessFinding(options, outputDiagnostics, findings); + // A stream copy keeps the source codec, so the selected export format says + // nothing about the result and the lossless note would be misleading. var effectiveExportFormat = ExportFormat.ResolveForPreset(options.Preset, options.ExportFormat); - if (sourceInfo.IsLikelyLossy && effectiveExportFormat.IsLossless) + if (!options.Preset.IsCopyOnly && sourceInfo.IsLikelyLossy && effectiveExportFormat.IsLossless) { AddFinding(findings, AudioComparisonFindingKind.LossyToLossless, AudioInsightSeverity.Info); } @@ -416,7 +418,15 @@ private static bool RequiresOutputDiagnostics(AudioPreset preset) private static int? GetExpectedSampleRate(ProcessingOptions options) { - return options.ExportFormat.Id == ExportFormat.PremierePro.Id ? 48_000 : null; + // A stream copy keeps the source rate no matter which export format is selected, + // and the archive preset forces FLAC, so both must not expect the Premiere rate. + if (options.Preset.IsCopyOnly) + { + return null; + } + + var exportFormat = ExportFormat.ResolveForPreset(options.Preset, options.ExportFormat); + return exportFormat.Id == ExportFormat.PremierePro.Id ? 48_000 : null; } private static bool CodecsMatch(string expectedCodec, string actualCodec) From b4b8b0a3e36fe199d41e3373fb3f7536544358ff Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Tue, 11 Aug 2026 19:36:06 +0000 Subject: [PATCH 2/4] Keep failing commands and log writes from ending the session Asynchronous commands run as async void, so any exception that escaped them reached the dispatcher handler and shut the app down. They now report through the view model instead: the error is logged and shown as status, and a cancelled command counts as a normal outcome. The most likely trigger was saving the run log after a finished batch, which is now caught separately - the exported files are done at that point, so a read-only or full output folder is only a warning. Writing the crash log is wrapped as well so a failure there cannot throw inside the crash handler. --- App.xaml.cs | 49 ++++++++++++++++++-------- CHANGELOG.md | 3 ++ Resources/Strings.en.resx | 2 ++ Resources/Strings.resx | 2 ++ ViewModels/AsyncRelayCommand.cs | 14 +++++++- ViewModels/MainViewModel.Processing.cs | 20 +++++++++-- ViewModels/MainViewModel.cs | 28 +++++++++++---- 7 files changed, 94 insertions(+), 24 deletions(-) diff --git a/App.xaml.cs b/App.xaml.cs index 8f9f4ac..2bcfe7d 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -34,11 +34,22 @@ private static void OnDispatcherUnhandledException(object sender, System.Windows { var logPath = WriteCrashLog(e.Exception); var loc = LocalizationService.Instance; - System.Windows.MessageBox.Show( - $"{loc["Error_AppCrash"]}{Environment.NewLine}{Environment.NewLine}{loc["Error_CrashLogSaved"]}{Environment.NewLine}{logPath}", - "Audio Quality Enhancer", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Error); + var message = logPath is null + ? loc["Error_AppCrash"] + : $"{loc["Error_AppCrash"]}{Environment.NewLine}{Environment.NewLine}{loc["Error_CrashLogSaved"]}{Environment.NewLine}{logPath}"; + + try + { + System.Windows.MessageBox.Show( + message, + "Audio Quality Enhancer", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Error); + } + catch + { + // Reporting the crash must never prevent the orderly shutdown below. + } e.Handled = true; Current.Shutdown(-1); @@ -58,17 +69,27 @@ private static void OnUnobservedTaskException(object? sender, UnobservedTaskExce e.SetObserved(); } - private static string WriteCrashLog(Exception exception) + /// Writes the crash log and returns its path, or null if it could not be written. + private static string? WriteCrashLog(Exception exception) { - var logDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "AudioQualityEnhancer", - "Logs"); + try + { + var logDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "AudioQualityEnhancer", + "Logs"); - Directory.CreateDirectory(logDirectory); + Directory.CreateDirectory(logDirectory); - var logPath = Path.Combine(logDirectory, $"crash_{DateTime.Now:yyyyMMdd_HHmmss}.log"); - File.WriteAllText(logPath, exception.ToString()); - return logPath; + var logPath = Path.Combine(logDirectory, $"crash_{DateTime.Now:yyyyMMdd_HHmmss}.log"); + File.WriteAllText(logPath, exception.ToString()); + return logPath; + } + catch + { + // A failing crash log (read-only profile, full disk) must not throw inside + // the crash handler and turn a handled error into a hard termination. + return null; + } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f1a2ad..a260a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ - A run that was stopped is no longer misreported: an inactivity timeout now ends the wait for the process directly and is reported as a timeout, while only an actual user cancellation is reported as one. - After a process is terminated the app waits briefly for it to exit and stops the output readers before reading what was captured, so the collected output can no longer be read while it is still being written. - Result validation no longer expects 48 kHz when the Premiere profile is selected but the preset does not use it: the archive preset forces FLAC and a stream copy keeps the source format, so both reported a sample-rate warning for a correct export. A stream copy also no longer claims that a lossy source was written to a lossless format. +- An unexpected error in an asynchronous command (start, download, analysis, retry, preview) is now logged and shown as status instead of shutting the app down; cancelling a running command is treated as a normal outcome. +- A log file that cannot be written after a batch run (read-only or full output folder) is reported as a warning instead of ending the session through the crash handler. The exported files are already finished at that point. +- The crash handler no longer fails when the crash log itself cannot be written and shows the error without a log path in that case. ### Changed diff --git a/Resources/Strings.en.resx b/Resources/Strings.en.resx index b0a1db7..233fe13 100644 --- a/Resources/Strings.en.resx +++ b/Resources/Strings.en.resx @@ -545,4 +545,6 @@ Preview segment: loudest section starting at {0:0} seconds. FFmpeg stopped responding and was terminated. Not enough free disk space in the output folder (about {0} MB needed, {1} MB free). + The log file could not be saved. The exported files are not affected. + Unexpected error: {0} diff --git a/Resources/Strings.resx b/Resources/Strings.resx index caf1aa0..127ff5c 100644 --- a/Resources/Strings.resx +++ b/Resources/Strings.resx @@ -546,4 +546,6 @@ Vorschau-Abschnitt: lautester Bereich ab {0:0} Sekunden. FFmpeg hat zu lange nicht reagiert und wurde beendet. Zu wenig freier Speicherplatz im Ausgabeordner (benötigt etwa {0} MB, frei {1} MB). + Die Logdatei konnte nicht gespeichert werden. Die Ausgabedateien sind davon nicht betroffen. + Unerwarteter Fehler: {0} diff --git a/ViewModels/AsyncRelayCommand.cs b/ViewModels/AsyncRelayCommand.cs index 1f47aee..e28b457 100644 --- a/ViewModels/AsyncRelayCommand.cs +++ b/ViewModels/AsyncRelayCommand.cs @@ -6,12 +6,14 @@ public sealed class AsyncRelayCommand : ICommand { private readonly Func _execute; private readonly Func? _canExecute; + private readonly Action? _onError; private bool _isExecuting; - public AsyncRelayCommand(Func execute, Func? canExecute = null) + public AsyncRelayCommand(Func execute, Func? canExecute = null, Action? onError = null) { _execute = execute; _canExecute = canExecute; + _onError = onError; } public event EventHandler? CanExecuteChanged; @@ -34,6 +36,16 @@ public async void Execute(object? parameter) RaiseCanExecuteChanged(); await _execute(); } + catch (OperationCanceledException) + { + // Cancelling a running command is a normal outcome, not a failure. + } + catch (Exception ex) when (_onError is not null) + { + // This runs as async void, so an escaping exception would reach the + // dispatcher handler and take the whole app down instead of the command. + _onError(ex); + } finally { _isExecuting = false; diff --git a/ViewModels/MainViewModel.Processing.cs b/ViewModels/MainViewModel.Processing.cs index 4098cc5..89418f2 100644 --- a/ViewModels/MainViewModel.Processing.cs +++ b/ViewModels/MainViewModel.Processing.cs @@ -113,8 +113,7 @@ private async Task StartProcessingAsync() if (SaveLogFile) { - var logPath = await _logService.SaveAsync(OutputDirectory, "audio-quality-enhancer-batch", CancellationToken.None); - _logService.Info(LocalizationService.Instance.Format("Log_LogSavedFormat", logPath)); + await TrySaveBatchLogAsync(); } if (SaveReportFile) @@ -187,6 +186,23 @@ private async Task ValidateProcessedItemAsync(BatchProcessingItem item, Ca return true; } + /// + /// Saves the run log without ever failing the run: the files are already exported, + /// so a read-only or full output folder is reported as a warning, not as a crash. + /// + private async Task TrySaveBatchLogAsync() + { + try + { + var logPath = await _logService.SaveAsync(OutputDirectory, "audio-quality-enhancer-batch", CancellationToken.None); + _logService.Info(LocalizationService.Instance.Format("Log_LogSavedFormat", logPath)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) + { + _logService.Warning(LocalizationService.Instance["Error_LogSaveFailed"]); + } + } + private async Task SaveQualityReportAsync(CancellationToken cancellationToken) { if (SelectedPreset is null || SelectedExportFormat is null) diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 735ac3c..87574fb 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -170,19 +170,19 @@ public MainViewModel() BatchItemsView = CollectionViewSource.GetDefaultView(BatchItems); BatchItemsView.Filter = FilterBatchItem; - _selectFileCommand = new AsyncRelayCommand(SelectFileAsync, () => !IsBusy); - _downloadFromUrlCommand = new AsyncRelayCommand(DownloadFromUrlAsync, CanDownloadFromUrl); - _analyzeDiagnosticsCommand = new AsyncRelayCommand(AnalyzeDiagnosticsAsync, CanAnalyzeDiagnostics); + _selectFileCommand = new AsyncRelayCommand(SelectFileAsync, () => !IsBusy, HandleCommandError); + _downloadFromUrlCommand = new AsyncRelayCommand(DownloadFromUrlAsync, CanDownloadFromUrl, HandleCommandError); + _analyzeDiagnosticsCommand = new AsyncRelayCommand(AnalyzeDiagnosticsAsync, CanAnalyzeDiagnostics, HandleCommandError); _selectOutputFolderCommand = new RelayCommand(SelectOutputFolder, () => !IsBusy); - _startCommand = new AsyncRelayCommand(StartProcessingAsync, CanStartProcessing); + _startCommand = new AsyncRelayCommand(StartProcessingAsync, CanStartProcessing, HandleCommandError); _cancelCommand = new RelayCommand(CancelProcessing, () => IsBusy); _removeSelectedFileCommand = new RelayCommand(RemoveSelectedFile, () => !IsBusy && SelectedBatchItem is not null); _clearFinishedFilesCommand = new RelayCommand(ClearFinishedFiles, () => !IsBusy && _batchQueueService.GetFinishedItems(BatchItems).Count > 0); - _retrySelectedFileCommand = new AsyncRelayCommand(RetrySelectedFileAsync, CanRetrySelectedFile); - _retryFailedFilesCommand = new AsyncRelayCommand(RetryFailedFilesAsync, CanRetryFailedFiles); + _retrySelectedFileCommand = new AsyncRelayCommand(RetrySelectedFileAsync, CanRetrySelectedFile, HandleCommandError); + _retryFailedFilesCommand = new AsyncRelayCommand(RetryFailedFilesAsync, CanRetryFailedFiles, HandleCommandError); _playSourceCommand = new RelayCommand(PlaySourcePreview, () => !IsBusy && File.Exists(InputPath)); _playOutputCommand = new RelayCommand(PlayOutputPreview, () => !IsBusy && File.Exists(LastOutputPath)); - _renderProcessedPreviewCommand = new AsyncRelayCommand(RenderProcessedPreviewAsync, CanRenderProcessedPreview); + _renderProcessedPreviewCommand = new AsyncRelayCommand(RenderProcessedPreviewAsync, CanRenderProcessedPreview, HandleCommandError); _playProcessedPreviewCommand = new RelayCommand(PlayProcessedPreview, CanPlayProcessedPreview); _stopPreviewCommand = new RelayCommand(StopPreview); _openOutputFolderCommand = new RelayCommand(OpenOutputFolder, () => Directory.Exists(OutputDirectory)); @@ -419,6 +419,20 @@ private static string FormatLocalized(string resourceKey, object?[] arguments) : LocalizationService.Instance.Format(resourceKey, arguments); } + /// + /// Last line of defence for asynchronous commands: an unexpected failure is logged + /// and shown as status instead of ending the session through the crash handler. + /// + private void HandleCommandError(Exception exception) + { + IsBusy = false; + SetProcessingPhase("Phase_Error"); + SetStatus("Error_UnexpectedFormat", exception.Message); + _logService.Error(LocalizationService.Instance.Format( + "Error_UnexpectedFormat", + $"{exception.GetType().Name}: {exception.Message}")); + } + private void RaiseCommandStates() { _selectFileCommand.RaiseCanExecuteChanged(); From 4fc64fe6c2b133e15911ab319f89e73c6c075cb7 Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Tue, 11 Aug 2026 19:36:24 +0000 Subject: [PATCH 3/4] Bound the tool probe and the output drain with timeouts The startup check ran " -version" without a time limit, so a binary that never answers (broken download, unreachable network path) kept the tool status pending forever. The probe now stops after 20 seconds, kills the process and reports the tool as unavailable; an outer cancellation kills it too instead of leaving it behind. The process runner waited without a limit for its output readers to close. A child process that inherited the pipes can keep them open, so the wait is bounded: generous on the normal path, where the last lines carry the loudness measurements, and short after a cancellation. --- CHANGELOG.md | 2 + Resources/Strings.en.resx | 1 + Resources/Strings.resx | 1 + Services/ProcessRunner.cs | 18 ++++++--- Services/ToolDiscoveryService.cs | 67 +++++++++++++++++++++++++------- 5 files changed, 69 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a260a13..82c4efc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ ### Changed - The release packaging script now requires an explicit `-Version` in `major.minor.patch` form instead of falling back to a stale default version. +- The tool check for FFmpeg, FFprobe and yt-dlp is bounded by a timeout: a binary that never answers is terminated and reported as unavailable instead of blocking the startup check indefinitely. +- Reading the remaining tool output after a process exits is bounded as well, so a pipe that a child process keeps open cannot hang a run. ## 0.17.0 - 2026-06-13 diff --git a/Resources/Strings.en.resx b/Resources/Strings.en.resx index 233fe13..d83fddb 100644 --- a/Resources/Strings.en.resx +++ b/Resources/Strings.en.resx @@ -545,6 +545,7 @@ Preview segment: loudest section starting at {0:0} seconds. FFmpeg stopped responding and was terminated. Not enough free disk space in the output folder (about {0} MB needed, {1} MB free). + {0} did not answer the check in time. The log file could not be saved. The exported files are not affected. Unexpected error: {0} diff --git a/Resources/Strings.resx b/Resources/Strings.resx index 127ff5c..55c3383 100644 --- a/Resources/Strings.resx +++ b/Resources/Strings.resx @@ -546,6 +546,7 @@ Vorschau-Abschnitt: lautester Bereich ab {0:0} Sekunden. FFmpeg hat zu lange nicht reagiert und wurde beendet. Zu wenig freier Speicherplatz im Ausgabeordner (benötigt etwa {0} MB, frei {1} MB). + {0} hat beim Prüfen nicht rechtzeitig geantwortet. Die Logdatei konnte nicht gespeichert werden. Die Ausgabedateien sind davon nicht betroffen. Unerwarteter Fehler: {0} diff --git a/Services/ProcessRunner.cs b/Services/ProcessRunner.cs index 9121fda..1c60fee 100644 --- a/Services/ProcessRunner.cs +++ b/Services/ProcessRunner.cs @@ -7,7 +7,13 @@ namespace AudioQualityEnhancer.Services; internal sealed class ProcessRunner : IProcessRunner { private static readonly TimeSpan TerminationWaitTimeout = TimeSpan.FromSeconds(2); - private static readonly TimeSpan ReaderDrainTimeout = TimeSpan.FromSeconds(2); + + // The output readers normally finish right after the process exits. A child process + // that inherited the pipes can keep them open, so the drain is bounded: generous on + // the normal path (the last lines carry the loudness measurements) and short after + // a cancellation, where the remaining output is discarded anyway. + private static readonly TimeSpan ReaderDrainTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan CancelDrainTimeout = TimeSpan.FromSeconds(2); public async Task RunAsync(ProcessRunOptions options, CancellationToken cancellationToken) { @@ -96,7 +102,7 @@ public async Task RunAsync(ProcessRunOptions options, Cancellatio } await process.WaitForExitAsync(exitCancellation.Token).ConfigureAwait(false); - await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task).ConfigureAwait(false); + await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task, ReaderDrainTimeout).ConfigureAwait(false); return CreateResult(process, stdout, stderr, startedAt, wasCancelled: false, timedOut: timedOutFlag == 1); } @@ -105,7 +111,7 @@ public async Task RunAsync(ProcessRunOptions options, Cancellatio var timedOut = timedOutFlag == 1; TryKill(process); await TryWaitForExitAsync(process).ConfigureAwait(false); - await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task).ConfigureAwait(false); + await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task, CancelDrainTimeout).ConfigureAwait(false); return CreateResult( process, stdout, @@ -207,16 +213,16 @@ private static async Task TryWaitForExitAsync(Process process) } } - private static async Task TryWaitForReadersAsync(Process process, Task outputClosed, Task errorClosed) + private static async Task TryWaitForReadersAsync(Process process, Task outputClosed, Task errorClosed, TimeSpan timeout) { try { - await Task.WhenAll(outputClosed, errorClosed).WaitAsync(ReaderDrainTimeout).ConfigureAwait(false); + await Task.WhenAll(outputClosed, errorClosed).WaitAsync(timeout).ConfigureAwait(false); return; } catch { - // Best effort drain after cancellation. + // Best effort drain; a reader that never closes must not hang the run. } TryCancelOutputRead(process); diff --git a/Services/ToolDiscoveryService.cs b/Services/ToolDiscoveryService.cs index 7128816..681936b 100644 --- a/Services/ToolDiscoveryService.cs +++ b/Services/ToolDiscoveryService.cs @@ -5,6 +5,11 @@ namespace AudioQualityEnhancer.Services; public sealed class ToolDiscoveryService { + // The probe only runs " -version". A binary that never answers (broken + // download, unreachable network path) would otherwise block the startup check + // forever, so it is bounded and reported as unavailable instead. + internal static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(20); + private readonly object _cacheLock = new(); private readonly Dictionary _locationCache = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _statusCache = new(StringComparer.OrdinalIgnoreCase); @@ -41,23 +46,26 @@ public async Task GetStatusAsync(string toolName, CancellationToken private static async Task ProbeToolAsync(string toolName, ToolLocation location, string versionArgument, CancellationToken cancellationToken) { - try + using var process = new Process(); + process.StartInfo = new ProcessStartInfo { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = location.ExecutablePath, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - process.StartInfo.ArgumentList.Add(versionArgument); + FileName = location.ExecutablePath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + process.StartInfo.ArgumentList.Add(versionArgument); + + using var probeCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + probeCancellation.CancelAfter(ProbeTimeout); + try + { process.Start(); - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken); + var outputTask = process.StandardOutput.ReadToEndAsync(probeCancellation.Token); + var errorTask = process.StandardError.ReadToEndAsync(probeCancellation.Token); + await process.WaitForExitAsync(probeCancellation.Token); var output = await outputTask; var error = await errorTask; @@ -76,6 +84,22 @@ private static async Task ProbeToolAsync(string toolName, ToolLocati versionLine, LocalizationService.Instance.Format("Error_ToolExitCodeFormat", toolName, process.ExitCode)); } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + TryKill(process); + return new ToolStatus( + toolName, + location.ExecutablePath, + location.Source, + false, + null, + LocalizationService.Instance.Format("Error_ToolTimeoutFormat", toolName)); + } + catch (OperationCanceledException) + { + TryKill(process); + throw; + } catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException) { return new ToolStatus( @@ -88,6 +112,21 @@ private static async Task ProbeToolAsync(string toolName, ToolLocati } } + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + // The probe result is already decided; cleanup must never throw on top of it. + } + } + private ToolLocation GetOrLocateTool(string toolName) { lock (_cacheLock) From 191e6e5551565a1c2c6997aae3887f1df7298167 Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Tue, 11 Aug 2026 19:36:36 +0000 Subject: [PATCH 4/4] Restrict the update link to https on the project host The release link from the API is opened with the shell when the user clicks the update notice, so it is now checked first: only an absolute https URL on github.com is used, anything else falls back to the known releases page. The update check also shares one HttpClient instead of creating a new connection pool per instance. --- .../AppUpdateServiceTests.cs | 13 ++++++++++ CHANGELOG.md | 1 + Services/AppUpdateService.cs | 26 +++++++++++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs b/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs index 3cf444e..8c66edb 100644 --- a/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs +++ b/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs @@ -17,6 +17,19 @@ public void ParseVersion_StripsPrefixAndNormalizesToThreeParts(string? tag, stri Assert.Equal(expected, AppUpdateService.ParseVersion(tag)?.ToString()); } + [Theory] + [InlineData("https://github.com/Kentarohakase/AudioQualityEnhancer/releases/tag/v0.18.0", "https://github.com/Kentarohakase/AudioQualityEnhancer/releases/tag/v0.18.0")] + [InlineData("http://github.com/Kentarohakase/AudioQualityEnhancer/releases", AppUpdateService.ReleasesPageUrl)] + [InlineData("https://example.com/releases", AppUpdateService.ReleasesPageUrl)] + [InlineData("file:///C:/Windows/System32/cmd.exe", AppUpdateService.ReleasesPageUrl)] + [InlineData("C:\\Windows\\System32\\cmd.exe", AppUpdateService.ReleasesPageUrl)] + [InlineData("", AppUpdateService.ReleasesPageUrl)] + [InlineData(null, AppUpdateService.ReleasesPageUrl)] + public void ResolveReleaseUrl_AcceptsOnlyHttpsProjectLinks(string? url, string expected) + { + Assert.Equal(expected, AppUpdateService.ResolveReleaseUrl(url)); + } + [Theory] [InlineData("0.16.0", "0.17.0", true)] [InlineData("0.16.0", "0.16.1", true)] diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c4efc..f375f31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - The release packaging script now requires an explicit `-Version` in `major.minor.patch` form instead of falling back to a stale default version. - The tool check for FFmpeg, FFprobe and yt-dlp is bounded by a timeout: a binary that never answers is terminated and reported as unavailable instead of blocking the startup check indefinitely. - Reading the remaining tool output after a process exits is bounded as well, so a pipe that a child process keeps open cannot hang a run. +- The update check accepts only an https link to the project host from the release API and otherwise falls back to the known releases page, because the link is handed to the shell when it is opened. ## 0.17.0 - 2026-06-13 diff --git a/Services/AppUpdateService.cs b/Services/AppUpdateService.cs index cd451db..3e07e62 100644 --- a/Services/AppUpdateService.cs +++ b/Services/AppUpdateService.cs @@ -13,10 +13,14 @@ public sealed class AppUpdateService private const string LatestReleaseApi = "https://api.github.com/repos/Kentarohakase/AudioQualityEnhancer/releases/latest"; internal const string ReleasesPageUrl = "https://github.com/Kentarohakase/AudioQualityEnhancer/releases/latest"; + // One shared client for the whole app: the check runs once per session and a new + // client per instance would leak its connection pool. + private static readonly HttpClient SharedHttpClient = new() { Timeout = TimeSpan.FromSeconds(10) }; + private readonly HttpClient _httpClient; public AppUpdateService() - : this(new HttpClient { Timeout = TimeSpan.FromSeconds(10) }) + : this(SharedHttpClient) { } @@ -52,7 +56,7 @@ internal AppUpdateService(HttpClient httpClient) } var url = root.TryGetProperty("html_url", out var urlElement) ? urlElement.GetString() : null; - return new AppUpdateInfo(latest.ToString(), string.IsNullOrWhiteSpace(url) ? ReleasesPageUrl : url!); + return new AppUpdateInfo(latest.ToString(), ResolveReleaseUrl(url)); } catch { @@ -61,6 +65,24 @@ internal AppUpdateService(HttpClient httpClient) } } + /// + /// The release link is handed to the shell when the user clicks the update notice, + /// so only an https link on the project host is accepted. Anything else (other + /// schemes, another host, a missing field) falls back to the known releases page. + /// + internal static string ResolveReleaseUrl(string? url) + { + if (!string.IsNullOrWhiteSpace(url) && + Uri.TryCreate(url.Trim(), UriKind.Absolute, out var parsed) && + parsed.Scheme == Uri.UriSchemeHttps && + string.Equals(parsed.Host, "github.com", StringComparison.OrdinalIgnoreCase)) + { + return parsed.AbsoluteUri; + } + + return ReleasesPageUrl; + } + internal static Version? ParseVersion(string? tag) { if (string.IsNullOrWhiteSpace(tag))