diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..8b78ae0d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{cs,xaml,csproj,wixproj,wxs,resx,xml}] +indent_style = space +indent_size = 4 + +[*.{md,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.bat] +indent_style = space +indent_size = 4 + +[*.ps1] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..43e26afe --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +* text=auto + +*.bat text eol=crlf +*.cs text eol=crlf +*.csproj text eol=crlf +*.resx text eol=crlf +*.wixproj text eol=crlf +*.wxs text eol=crlf +*.xaml text eol=crlf +*.xml text eol=crlf +*.md text eol=lf +*.ps1 text eol=crlf +*.yml text eol=lf +*.yaml text eol=lf + +*.bmp binary +*.gif binary +*.ico binary +*.jpg binary +*.png binary +*.rtf binary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..315dfbd9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Summary + + + +## User Impact + + + +## Validation + +- [ ] `git diff --check` +- [ ] XML/XAML/project files parse +- [ ] Release x64 build +- [ ] Smoke conversion checked +- [ ] Installer or Explorer integration checked when relevant + +## Notes + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..e9bcf34a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,47 @@ +name: build + +on: + push: + branches: + - integration + - master + - "codex/**" + pull_request: + +permissions: + contents: read + +env: + BUILD_CONFIGURATION: Release + BUILD_PLATFORM: x64 + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + windows: + name: Windows x64 + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Restore and build + shell: pwsh + run: .\build.ps1 -Configuration $env:BUILD_CONFIGURATION -Platform $env:BUILD_PLATFORM + + - name: Upload app artifact + uses: actions/upload-artifact@v4 + with: + name: ZFileConverter-app-x64 + path: Application/FileConverter/bin/x64/Release/** + if-no-files-found: error + + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: ZFileConverter-installer-x64 + path: Installer/bin/x64/Release/*.msi + if-no-files-found: warn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..c2e14484 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: release + +on: + workflow_dispatch: + inputs: + version: + description: "Release tag, for example v2.2.0" + required: true + type: string + prerelease: + description: "Mark as prerelease" + required: true + default: true + type: boolean + +permissions: + contents: write + +env: + BUILD_CONFIGURATION: Release + BUILD_PLATFORM: x64 + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + windows-release: + name: Windows release + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Build + shell: pwsh + run: .\build.ps1 -Configuration $env:BUILD_CONFIGURATION -Platform $env:BUILD_PLATFORM + + - name: Prepare artifacts + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $tag = "${{ inputs.version }}" + $version = $tag.TrimStart("v") + New-Item -ItemType Directory -Force artifacts | Out-Null + Copy-Item "Installer\bin\x64\Release\ZFileConverter-setup.msi" "artifacts\ZFileConverter-$version-x64-setup.msi" + Compress-Archive -Path "Application\FileConverter\bin\x64\Release\*" -DestinationPath "artifacts\ZFileConverter-$version-x64-app.zip" -Force + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: ZFileConverter-release-${{ inputs.version }} + path: artifacts/* + if-no-files-found: error + + - name: Create draft GitHub release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.version }} + PRERELEASE: ${{ inputs.prerelease }} + run: | + $ErrorActionPreference = "Stop" + $files = Get-ChildItem artifacts -File | ForEach-Object { $_.FullName } + $arguments = @( + "release", + "create", + $env:RELEASE_TAG + ) + $files + @( + "--target", + $env:GITHUB_SHA, + "--title", + "ZFileConverter $env:RELEASE_TAG", + "--generate-notes", + "--draft" + ) + if ($env:PRERELEASE -eq "true") { + $arguments += "--prerelease" + } + gh @arguments diff --git a/.gitignore b/.gitignore index fe9b220a..6002a914 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ [Rr]eleases/ build/ bld/ +artifacts/ [Bb]in/ [Oo]bj/ diff --git a/Application/FileConverter/Application.xaml.cs b/Application/FileConverter/Application.xaml.cs index 95f89210..925ab244 100644 --- a/Application/FileConverter/Application.xaml.cs +++ b/Application/FileConverter/Application.xaml.cs @@ -1,7 +1,7 @@ // License: http://www.gnu.org/licenses/gpl.html GPL version 3. -/* File Converter - This program allow you to convert file format to another. - Copyright (C) 2026 Adrien Allard +/* ZFileConverter - This program allows you to convert one file format to another. + Copyright (C) 2026 ZaidNAlAsali and File Converter contributors email: adrien.allard.pro@gmail.com This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -152,6 +152,12 @@ protected override void OnExit(ExitEventArgs e) return; } + if (!upgradeService.UpgradeVersionDescription.InstallerIsVerified) + { + Debug.LogError("Refuse to start upgrade installer because it has not passed integrity verification."); + return; + } + // Start process. Debug.Log($"Start file converter upgrade from version {ApplicationVersion} to {upgradeService.UpgradeVersionDescription.LatestVersion}."); @@ -218,9 +224,9 @@ private void RegisterServices() private void Initialize() { #if BUILD32 - Diagnostics.Debug.Log("File Converter v" + ApplicationVersion.ToString() + " (32 bits)"); + Diagnostics.Debug.Log("ZFileConverter v" + ApplicationVersion.ToString() + " (32 bits)"); #else - Diagnostics.Debug.Log("File Converter v" + ApplicationVersion.ToString() + " (64 bits)"); + Diagnostics.Debug.Log("ZFileConverter v" + ApplicationVersion.ToString() + " (64 bits)"); #endif // Retrieve arguments. @@ -276,7 +282,8 @@ private void Initialize() if (index >= args.Length - 1) { Debug.LogError(errorCode: 0x0B, $"Invalid format."); - break; + Application.AskForShutdown(); + return; } string shellExtensionPath = args[index + 1]; @@ -296,7 +303,8 @@ private void Initialize() if (index >= args.Length - 1) { Debug.LogError(errorCode: 0x0D, $"Invalid format."); - break; + Application.AskForShutdown(); + return; } string shellExtensionPath = args[index + 1]; @@ -311,6 +319,24 @@ private void Initialize() return; } + case "repair-shell-extension": + { + string shellExtensionPath = Helpers.GetDefaultShellExtensionPath(); + if (index < args.Length - 1 && !args[index + 1].StartsWith("--")) + { + shellExtensionPath = args[index + 1]; + index++; + } + + if (!Helpers.RepairShellExtension(shellExtensionPath)) + { + Debug.LogError(errorCode: 0x10, $"Failed to repair shell extension {shellExtensionPath}."); + } + + Application.AskForShutdown(); + return; + } + case "version": Console.WriteLine(ApplicationVersion.ToString()); Application.AskForShutdown(); @@ -371,6 +397,7 @@ private void Initialize() default: Debug.LogError($"Unknown application argument: '--{parameterTitle}'."); + Application.AskForShutdown(); return; } } @@ -388,7 +415,7 @@ private void RunConversions(List filePaths, string conversionPresetName) ISettingsService settingsService = Ioc.Default.GetRequiredService(); if (settingsService.Settings == null) { - Debug.LogError(errorCode: 0x04, "Can't load File Converter settings. The application will now shutdown, if you want to fix the problem yourself please edit or delete the file: C:\\Users\\UserName\\AppData\\Local\\FileConverter\\Settings.user.xml."); + Debug.LogError(errorCode: 0x04, "Can't load ZFileConverter settings. The application will now shutdown, if you want to fix the problem yourself please edit or delete the file: C:\\Users\\UserName\\AppData\\Local\\FileConverter\\Settings.user.xml."); Application.AskForShutdown(); return; } diff --git a/Application/FileConverter/Controls/ConversionJobControl.xaml b/Application/FileConverter/Controls/ConversionJobControl.xaml index caed4eb7..3976fd39 100644 --- a/Application/FileConverter/Controls/ConversionJobControl.xaml +++ b/Application/FileConverter/Controls/ConversionJobControl.xaml @@ -15,7 +15,7 @@ - + @@ -54,15 +54,27 @@ - - - + + + + + + + diff --git a/Application/FileConverter/ConversionJobs/ConversionJob.cs b/Application/FileConverter/ConversionJobs/ConversionJob.cs index ecf68545..90e5ca8b 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJob.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJob.cs @@ -4,12 +4,15 @@ namespace FileConverter.ConversionJobs { using System; using System.ComponentModel; + using System.IO; using System.Runtime.CompilerServices; using System.Windows.Input; + using CommunityToolkit.Mvvm.DependencyInjection; using CommunityToolkit.Mvvm.Input; using FileConverter.Diagnostics; + using FileConverter.Services; public class ConversionJob : INotifyPropertyChanged { @@ -19,6 +22,8 @@ public class ConversionJob : INotifyPropertyChanged private string errorMessage = string.Empty; private string userState = string.Empty; private RelayCommand cancelCommand; + private RelayCommand openOutputFolderCommand; + private RelayCommand retryCommand; private readonly string initialInputPath; private int currentOutputFilePathIndex; @@ -64,6 +69,8 @@ public ConversionPreset ConversionPreset private set; } + public string InitialInputPath => this.initialInputPath; + public string InputFilePath { get; @@ -102,6 +109,8 @@ private set this.state = value; this.NotifyPropertyChanged(); Application.Current.Dispatcher.Invoke(() => this.cancelCommand?.NotifyCanExecuteChanged()); + Application.Current.Dispatcher.Invoke(() => this.openOutputFolderCommand?.NotifyCanExecuteChanged()); + Application.Current.Dispatcher.Invoke(() => this.retryCommand?.NotifyCanExecuteChanged()); } } @@ -168,6 +177,32 @@ public ICommand CancelCommand } } + public ICommand OpenOutputFolderCommand + { + get + { + if (this.openOutputFolderCommand == null) + { + this.openOutputFolderCommand = new RelayCommand(this.OpenOutputFolder, this.CanOpenOutputFolder); + } + + return this.openOutputFolderCommand; + } + } + + public ICommand RetryCommand + { + get + { + if (this.retryCommand == null) + { + this.retryCommand = new RelayCommand(this.RetryConversion, this.CanRetryConversion); + } + + return this.retryCommand; + } + } + protected bool CancelIsRequested { get; @@ -200,6 +235,18 @@ protected virtual InputPostConversionAction InputPostConversionAction protected virtual bool IsCancelable() => this.State == ConversionState.InProgress; + private bool CanOpenOutputFolder() + { + return this.State == ConversionState.Done && !string.IsNullOrEmpty(this.OutputFilePath); + } + + private bool CanRetryConversion() + { + return this.State == ConversionState.Failed && + this.ConversionPreset != null && + !string.IsNullOrEmpty(this.initialInputPath); + } + protected string[] OutputFilePaths { get; @@ -220,8 +267,14 @@ public void PrepareConversion(params string[] outputFilePaths) this.InputFilePath = this.initialInputPath; - string extension = System.IO.Path.GetExtension(this.initialInputPath); - extension = extension.Substring(1, extension.Length - 1); + string extension = PathHelpers.GetExtensionWithoutDot(this.initialInputPath); + if (string.IsNullOrEmpty(extension)) + { + this.ConversionFailed(Properties.Resources.ErrorInputTypeIncompatibleWithOutputType); + Debug.Log($"Input file has no extension: {this.InputFilePath}."); + return; + } + string extensionCategory = Helpers.GetExtensionCategory(extension); if (!Helpers.IsOutputTypeCompatibleWithCategory(this.ConversionPreset.OutputType, extensionCategory)) { @@ -232,7 +285,18 @@ public void PrepareConversion(params string[] outputFilePaths) this.OutputFilePaths = outputFilePaths; if (this.OutputFilePaths.Length == 0) { - int outputFilesCount = this.GetOutputFilesCount(); + int outputFilesCount; + try + { + outputFilesCount = this.GetOutputFilesCount(); + } + catch (Exception exception) + { + this.ConversionFailed(Properties.Resources.ErrorDuringJobInitialization); + Debug.Log(exception.ToString()); + return; + } + this.OutputFilePaths = new string[outputFilesCount]; } @@ -246,13 +310,15 @@ public void PrepareConversion(params string[] outputFilePaths) string path = this.ConversionPreset.GenerateOutputFilePath(this.initialInputPath, index + 1, this.OutputFilePaths.Length); - if (!PathHelpers.IsPathValid(path)) + if (!PathHelpers.TryNormalizeGeneratedPath(path, out string normalizedPath, out string outputPathErrorMessage)) { this.ConversionFailed(Properties.Resources.ErrorInvalidOutputPath); - Debug.Log($"Invalid output path generated: {path} from input: {this.InputFilePath}."); + Debug.Log($"Invalid output path generated: {path} from input: {this.InputFilePath}. {outputPathErrorMessage}"); return; } + path = normalizedPath; + if (path == this.InputFilePath) { // If the input post conversion action is to move or delete the input file, change its name in order to keep the output name intact. @@ -378,6 +444,49 @@ public virtual void Cancel() this.ConversionFailed(Properties.Resources.ErrorCanceled); } + private void OpenOutputFolder() + { + if (string.IsNullOrEmpty(this.OutputFilePath)) + { + return; + } + + try + { + string outputFilePath = this.OutputFilePath; + if (File.Exists(outputFilePath)) + { + System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{outputFilePath}\""); + return; + } + + string outputDirectory = Path.GetDirectoryName(outputFilePath); + if (!string.IsNullOrEmpty(outputDirectory) && Directory.Exists(outputDirectory)) + { + System.Diagnostics.Process.Start("explorer.exe", $"\"{outputDirectory}\""); + return; + } + + Debug.Log($"Can't open output folder because the path does not exist: {outputFilePath}."); + } + catch (Exception exception) + { + Debug.Log($"Can't open output folder: {exception.Message}."); + } + } + + private void RetryConversion() + { + try + { + Ioc.Default.GetRequiredService().RetryConversionJob(this); + } + catch (Exception exception) + { + Debug.Log($"Can't retry conversion: {exception.Message}."); + } + } + protected virtual int GetOutputFilesCount() { return 1; @@ -419,30 +528,43 @@ protected virtual void OnConversionSucceed() this.ChangeOutputFileTimestampToMatchOriginal(); - // Apply the input post conversion action. - switch (this.InputPostConversionAction) + try { - case InputPostConversionAction.None: - break; - - case InputPostConversionAction.MoveInArchiveFolder: - string basePath = System.IO.Path.GetDirectoryName(this.initialInputPath); - string inputFilename = System.IO.Path.GetFileName(this.initialInputPath); - string archivePath = basePath + "\\" + this.ConversionPreset.ConversionArchiveFolderName; - if (!System.IO.Directory.Exists(archivePath)) - { - System.IO.Directory.CreateDirectory(archivePath); - } - - string newPath = PathHelpers.GenerateUniquePath(archivePath + "\\" + inputFilename); - System.IO.File.Move(this.InputFilePath, newPath); - Debug.Log($"Input file moved in archive folder: '{newPath}'"); - break; - - case InputPostConversionAction.Delete: - System.IO.File.Delete(this.InputFilePath); - Debug.Log($"Input file deleted: '{this.initialInputPath}'"); - break; + // Apply the input post conversion action. + switch (this.InputPostConversionAction) + { + case InputPostConversionAction.None: + break; + + case InputPostConversionAction.MoveInArchiveFolder: + string basePath = System.IO.Path.GetDirectoryName(this.initialInputPath); + if (string.IsNullOrEmpty(basePath)) + { + basePath = System.Environment.CurrentDirectory; + } + + string inputFilename = System.IO.Path.GetFileName(this.initialInputPath); + string archivePath = basePath + "\\" + this.ConversionPreset.ConversionArchiveFolderName; + if (!System.IO.Directory.Exists(archivePath)) + { + System.IO.Directory.CreateDirectory(archivePath); + } + + string newPath = PathHelpers.GenerateUniquePath(archivePath + "\\" + inputFilename); + System.IO.File.Move(this.InputFilePath, newPath); + Debug.Log($"Input file moved in archive folder: '{newPath}'"); + break; + + case InputPostConversionAction.Delete: + System.IO.File.Delete(this.InputFilePath); + Debug.Log($"Input file deleted: '{this.initialInputPath}'"); + break; + } + } + catch (Exception exception) + { + this.ConversionFailed($"Post conversion action failed: {exception.Message}"); + return; } Debug.Log(string.Empty); @@ -468,6 +590,63 @@ protected void ConversionFailed(string exitingMessage) this.ErrorMessage = exitingMessage; } + protected void DeleteFileIfExists(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + return; + } + + try + { + if (System.IO.File.Exists(filePath)) + { + System.IO.File.Delete(filePath); + } + + this.DeleteEmptyTemporaryParentFolder(filePath); + } + catch (Exception exception) + { + Debug.Log($"Can't delete file '{filePath}'."); + Debug.Log($"An exception has been thrown: {exception}."); + } + } + + private void DeleteEmptyTemporaryParentFolder(string filePath) + { + string parentFolder = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(parentFolder)) + { + return; + } + + DirectoryInfo parent = Directory.GetParent(parentFolder); + if (parent == null) + { + return; + } + + string tempRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ZFileConverter")); + string candidateRoot = Path.GetFullPath(parent.FullName); + if (!string.Equals( + tempRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + candidateRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + Directory.Delete(parentFolder, false); + } + catch + { + // Best effort only; concurrent conversions may still be using the folder. + } + } + protected void NotifyPropertyChanged([CallerMemberName] string propertyName = "") { if (this.PropertyChanged != null) diff --git a/Application/FileConverter/ConversionJobs/ConversionJobFactory.cs b/Application/FileConverter/ConversionJobs/ConversionJobFactory.cs index 34886bb2..52f11b74 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJobFactory.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJobFactory.cs @@ -6,8 +6,7 @@ public static class ConversionJobFactory { public static ConversionJob Create(ConversionPreset conversionPreset, string inputFilePath) { - string inputFileExtension = System.IO.Path.GetExtension(inputFilePath); - inputFileExtension = inputFileExtension.ToLowerInvariant().Substring(1, inputFileExtension.Length - 1); + string inputFileExtension = PathHelpers.GetExtensionWithoutDot(inputFilePath); if (inputFileExtension == "cda") { return new ConversionJob_ExtractCDA(conversionPreset, inputFilePath); diff --git a/Application/FileConverter/ConversionJobs/ConversionJob_Excel.cs b/Application/FileConverter/ConversionJobs/ConversionJob_Excel.cs index c4d4d021..1ed5488d 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJob_Excel.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJob_Excel.cs @@ -48,7 +48,7 @@ protected override int GetOutputFilesCount() Excel.Worksheet worksheet = sheet as Excel.Worksheet; if (worksheet != null) { - pagesCount = worksheet.PageSetup.Pages.Count; + pagesCount += worksheet.PageSetup.Pages.Count; } } @@ -78,8 +78,7 @@ protected override void Initialize() { // Generate intermediate file path. string fileName = Path.GetFileNameWithoutExtension(this.InputFilePath); - string tempPath = Path.GetTempPath(); - this.intermediateFilePath = PathHelpers.GenerateUniquePath(tempPath + fileName + ".pdf"); + this.intermediateFilePath = PathHelpers.GenerateTemporaryFilePath(fileName + ".pdf"); ConversionPreset intermediatePreset = new ConversionPreset("Pdf to image", this.ConversionPreset, "pdf"); this.pdf2ImageConversionJob = ConversionJobFactory.Create(intermediatePreset, this.intermediateFilePath); @@ -102,48 +101,55 @@ protected override void Convert() return; } - // Make this document the active document. - this.document.Activate(); - - this.UserState = Properties.Resources.ConversionStateConversion; - - Debug.Log("Convert excel document to pdf."); - this.document.ExportAsFixedFormat(Excel.Enums.XlFixedFormatType.xlTypePDF, this.intermediateFilePath); + try + { + // Make this document the active document. + this.document.Activate(); - Debug.Log($"Close excel document '{this.InputFilePath}'."); - this.document.Close(false); - this.document = null; + this.UserState = Properties.Resources.ConversionStateConversion; - this.ReleaseOfficeApplicationInstanceIfNeeded(); + Debug.Log("Convert excel document to pdf."); + this.document.ExportAsFixedFormat(Excel.Enums.XlFixedFormatType.xlTypePDF, this.intermediateFilePath); + } + finally + { + this.CloseDocumentIfNeeded(); + this.ReleaseOfficeApplicationInstanceIfNeeded(); + } if (this.pdf2ImageConversionJob != null) { - if (!System.IO.File.Exists(this.intermediateFilePath)) + Task updateProgress = null; + try { - this.ConversionFailed(Properties.Resources.ErrorCantFindOutputFiles); - return; - } + if (!System.IO.File.Exists(this.intermediateFilePath)) + { + this.ConversionFailed(Properties.Resources.ErrorCantFindOutputFiles); + return; + } - Task updateProgress = this.UpdateProgress(); + updateProgress = this.UpdateProgress(); - Debug.Log("Convert pdf to images."); + Debug.Log("Convert pdf to images."); - this.pdf2ImageConversionJob.StartConversion(); + this.pdf2ImageConversionJob.StartConversion(); - if (this.pdf2ImageConversionJob.State != ConversionState.Done) - { - this.ConversionFailed(this.pdf2ImageConversionJob.ErrorMessage); - return; + if (this.pdf2ImageConversionJob.State != ConversionState.Done) + { + this.ConversionFailed(this.pdf2ImageConversionJob.ErrorMessage); + return; + } } - - if (!string.IsNullOrEmpty(this.intermediateFilePath)) + finally { - Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); + updateProgress?.Wait(); - File.Delete(this.intermediateFilePath); + if (!string.IsNullOrEmpty(this.intermediateFilePath)) + { + Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); + this.DeleteFileIfExists(this.intermediateFilePath); + } } - - updateProgress.Wait(); } } @@ -160,6 +166,7 @@ protected override void InitializeOfficeApplicationInstanceIfNecessary() { Visible = false }; + this.HardenOfficeApplicationInstance(this.application); } protected override void ReleaseOfficeApplicationInstanceIfNeeded() @@ -176,15 +183,11 @@ protected override void ReleaseOfficeApplicationInstanceIfNeeded() private async Task UpdateProgress() { - while (this.pdf2ImageConversionJob.State != ConversionState.Done && + while (this.pdf2ImageConversionJob != null && + this.pdf2ImageConversionJob.State != ConversionState.Done && this.pdf2ImageConversionJob.State != ConversionState.Failed) { - if (this.pdf2ImageConversionJob != null && this.pdf2ImageConversionJob.State == ConversionState.InProgress) - { - this.Progress = this.pdf2ImageConversionJob.Progress; - } - - if (this.pdf2ImageConversionJob != null && this.pdf2ImageConversionJob.State == ConversionState.InProgress) + if (this.pdf2ImageConversionJob.State == ConversionState.InProgress) { this.Progress = this.pdf2ImageConversionJob.Progress; this.UserState = this.pdf2ImageConversionJob.UserState; @@ -215,10 +218,30 @@ private bool TryLoadDocumentIfNecessary() { Debug.Log($"Load excel document '{this.InputFilePath}'."); - this.document = this.application.Workbooks.Open(this.InputFilePath, System.Reflection.Missing.Value, true); + this.document = this.application.Workbooks.Open(this.InputFilePath, 0, true); } return this.document != null; } + + private void CloseDocumentIfNeeded() + { + if (this.document == null) + { + return; + } + + try + { + Debug.Log($"Close excel document '{this.InputFilePath}'."); + this.document.Close(false); + } + catch (Exception exception) + { + Debug.Log($"Failed to close excel document '{this.InputFilePath}': {exception.Message}."); + } + + this.document = null; + } } } diff --git a/Application/FileConverter/ConversionJobs/ConversionJob_ExtractCDA.cs b/Application/FileConverter/ConversionJobs/ConversionJob_ExtractCDA.cs index 66c8b381..21c0b848 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJob_ExtractCDA.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJob_ExtractCDA.cs @@ -40,7 +40,7 @@ public override void Cancel() { base.Cancel(); - this.compressionConversionJob.Cancel(); + this.compressionConversionJob?.Cancel(); } protected override void Initialize() @@ -105,12 +105,17 @@ protected override void Initialize() // Generate intermediate file path. string fileName = Path.GetFileName(this.OutputFilePath); - string tempPath = Path.GetTempPath(); - this.intermediateFilePath = PathHelpers.GenerateUniquePath(tempPath + fileName + ".wav"); + this.intermediateFilePath = PathHelpers.GenerateTemporaryFilePath(fileName + ".wav"); // Sub conversion job (for compression). this.compressionConversionJob = ConversionJobFactory.Create(this.ConversionPreset, this.intermediateFilePath); this.compressionConversionJob.PrepareConversion(this.OutputFilePath); + if (this.compressionConversionJob.State == ConversionState.Failed) + { + this.ConversionFailed(this.compressionConversionJob.ErrorMessage); + return; + } + this.compressionThread = Helpers.InstantiateThread("CDACompressionThread", this.CompressAsync); } @@ -125,40 +130,45 @@ protected override void Convert() this.UserState = Properties.Resources.ConversionStateExtraction; - if (!this.diskDrive.IsCDReady()) - { - this.ConversionFailed(Properties.Resources.ErrorCDDriveNotReady); - return; - } - - if (!this.diskDrive.Refresh()) - { - Debug.Log("Can't refresh CD drive data."); - this.ConversionFailed(Properties.Resources.ErrorCDDriveNotReady); - return; - } - - if (!this.diskDrive.LockCD()) + bool cdLocked = false; + try { - Debug.Log("Can\'t lock cd."); - this.ConversionFailed(Properties.Resources.ErrorCDDriveNotReady); - return; + if (!this.diskDrive.IsCDReady()) + { + this.ConversionFailed(Properties.Resources.ErrorCDDriveNotReady); + return; + } + + if (!this.diskDrive.Refresh()) + { + Debug.Log("Can't refresh CD drive data."); + this.ConversionFailed(Properties.Resources.ErrorCDDriveNotReady); + return; + } + + if (!this.diskDrive.LockCD()) + { + Debug.Log("Can\'t lock cd."); + this.ConversionFailed(Properties.Resources.ErrorCDDriveNotReady); + return; + } + + cdLocked = true; + + WaveFormat waveFormat = new WaveFormat(44100, 16, 2); + + using (Stream waveStream = new FileStream(this.intermediateFilePath, FileMode.Create, FileAccess.Write)) + using (this.waveWriter = new WaveWriter(waveStream, waveFormat, this.diskDrive.TrackSize(this.cdaTrackNumber))) + { + this.diskDrive.ReadTrack(this.cdaTrackNumber, this.WriteWaveData, this.CdReadProgress); + } } - - WaveFormat waveFormat = new WaveFormat(44100, 16, 2); - - using (Stream waveStream = new FileStream(this.intermediateFilePath, FileMode.Create, FileAccess.Write)) - using (this.waveWriter = new WaveWriter(waveStream, waveFormat, this.diskDrive.TrackSize(this.cdaTrackNumber))) + finally { - this.diskDrive.ReadTrack(this.cdaTrackNumber, this.WriteWaveData, this.CdReadProgress); + this.waveWriter = null; + this.ReleaseCdDrive(cdLocked); } - this.waveWriter = null; - - this.diskDrive.UnLockCD(); - - this.diskDrive.Close(); - this.StateFlags = ConversionFlags.None; if (!File.Exists(this.intermediateFilePath)) @@ -179,18 +189,21 @@ protected override void Convert() this.compressionConversionJob.State != ConversionState.Failed) { this.Progress = this.compressionConversionJob.Progress; + Thread.Sleep(40); } + this.compressionThread.Join(); + + Debug.Log(string.Empty); + Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); + + this.DeleteIntermediateFileIfExists(); + if (this.compressionConversionJob.State == ConversionState.Failed) { this.ConversionFailed(this.compressionConversionJob.ErrorMessage); return; } - - Debug.Log(string.Empty); - Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); - - File.Delete(this.intermediateFilePath); } private void WriteWaveData(object sender, DataReadEventArgs eventArgs) @@ -220,5 +233,42 @@ private void CompressAsync() { this.compressionConversionJob.StartConversion(); } + + private void ReleaseCdDrive(bool cdLocked) + { + if (this.diskDrive == null) + { + return; + } + + if (cdLocked) + { + try + { + this.diskDrive.UnLockCD(); + } + catch (Exception exception) + { + Debug.Log($"Failed to unlock CD drive: {exception.Message}."); + } + } + + try + { + if (this.diskDrive.IsOpened) + { + this.diskDrive.Close(); + } + } + catch (Exception exception) + { + Debug.Log($"Failed to close CD drive: {exception.Message}."); + } + } + + private void DeleteIntermediateFileIfExists() + { + this.DeleteFileIfExists(this.intermediateFilePath); + } } } diff --git a/Application/FileConverter/ConversionJobs/ConversionJob_FFMPEG.cs b/Application/FileConverter/ConversionJobs/ConversionJob_FFMPEG.cs index 79f81586..4737e9a6 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJob_FFMPEG.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJob_FFMPEG.cs @@ -79,7 +79,7 @@ protected override void Initialize() { CreateNoWindow = true, UseShellExecute = false, - RedirectStandardOutput = true, + RedirectStandardOutput = false, RedirectStandardError = true }; @@ -88,7 +88,7 @@ protected override void Initialize() protected virtual void FillFFMpegArgumentsList() { - const string baseArgs = "-n -progress pipe:1"; + const string baseArgs = "-n"; bool customCommandEnabled = this.ConversionPreset.GetSettingsValue(ConversionPreset.ConversionSettingKeys.EnableFFMPEGCustomCommand); if (customCommandEnabled) @@ -166,8 +166,7 @@ protected virtual void FillFFMpegArgumentsList() { // http://blog.pkh.me/p/21-high-quality-gif-with-ffmpeg.html string fileName = Path.GetFileName(this.InputFilePath); - string tempPath = Path.GetTempPath(); - string paletteFilePath = PathHelpers.GenerateUniquePath(tempPath + fileName + " - palette.png"); + string paletteFilePath = PathHelpers.GenerateTemporaryFilePath(fileName + " - palette.png"); string transformArgs = ConversionJob_FFMPEG.ComputeTransformArgs(this.ConversionPreset); @@ -464,6 +463,10 @@ protected override void Convert() } exeProcess.WaitForExit(); + if (exeProcess.ExitCode != 0 && !this.CancelIsRequested && this.State != ConversionState.Failed) + { + this.ConversionFailed($"FFmpeg exited with code {exeProcess.ExitCode}."); + } } } catch @@ -471,6 +474,11 @@ protected override void Convert() this.ConversionFailed(Properties.Resources.ErrorFailedToLaunchFFMPEG); throw; } + + if (this.State == ConversionState.Failed || this.CancelIsRequested) + { + break; + } } Diagnostics.Debug.Log(string.Empty); @@ -487,12 +495,17 @@ protected override void Convert() Diagnostics.Debug.Log($"Delete intermediate file {currentPass.FileToDelete}."); - File.Delete(currentPass.FileToDelete); + this.DeleteFileIfExists(currentPass.FileToDelete); } } private void ParseFFMPEGOutput(string input) { + if (string.IsNullOrEmpty(input)) + { + return; + } + Match match = this.durationRegex.Match(input); if (match.Success && match.Groups.Count >= 6) { diff --git a/Application/FileConverter/ConversionJobs/ConversionJob_Gif.cs b/Application/FileConverter/ConversionJobs/ConversionJob_Gif.cs index bc9fde60..af130262 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJob_Gif.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJob_Gif.cs @@ -20,8 +20,8 @@ public override void Cancel() { base.Cancel(); - this.pngConversionJob.Cancel(); - this.gifConversionJob.Cancel(); + this.pngConversionJob?.Cancel(); + this.gifConversionJob?.Cancel(); } protected override void Initialize() @@ -33,8 +33,7 @@ protected override void Initialize() throw new Exception("The conversion preset must be valid."); } - string extension = System.IO.Path.GetExtension(this.InputFilePath); - extension = extension.ToLowerInvariant().Substring(1, extension.Length - 1); + string extension = PathHelpers.GetExtensionWithoutDot(this.InputFilePath); string inputFilePath = string.Empty; @@ -43,8 +42,7 @@ protected override void Initialize() { // Generate intermediate file path. string fileName = Path.GetFileName(this.OutputFilePath); - string tempPath = Path.GetTempPath(); - this.intermediateFilePath = PathHelpers.GenerateUniquePath(tempPath + fileName + ".png"); + this.intermediateFilePath = PathHelpers.GenerateTemporaryFilePath(fileName + ".png"); // Convert input in png file to send it to ffmpeg for the gif conversion. ConversionPreset intermediatePreset = new ConversionPreset("To compatible image", OutputType.Png, this.ConversionPreset.InputTypes.ToArray()); @@ -70,41 +68,45 @@ protected override void Convert() throw new Exception("The conversion preset must be valid."); } - Task updateProgress = this.UpdateProgress(); - - if (this.pngConversionJob != null) + Task updateProgress = null; + try { - this.UserState = Properties.Resources.ConversionStateReadIntputImage; + if (this.pngConversionJob != null) + { + this.UserState = Properties.Resources.ConversionStateReadIntputImage; + + Diagnostics.Debug.Log(string.Empty); + Diagnostics.Debug.Log("Convert image to PNG (intermediate format)."); + this.pngConversionJob.StartConversion(); + + if (this.pngConversionJob.State != ConversionState.Done) + { + this.ConversionFailed(this.pngConversionJob.ErrorMessage); + return; + } + } Diagnostics.Debug.Log(string.Empty); - Diagnostics.Debug.Log("Convert image to PNG (intermediate format)."); - this.pngConversionJob.StartConversion(); + Diagnostics.Debug.Log("Convert png intermediate image to gif."); + updateProgress = this.UpdateProgress(); + this.gifConversionJob.StartConversion(); - if (this.pngConversionJob.State != ConversionState.Done) + if (this.gifConversionJob.State != ConversionState.Done) { - this.ConversionFailed(this.pngConversionJob.ErrorMessage); + this.ConversionFailed(this.gifConversionJob.ErrorMessage); return; } } - - Diagnostics.Debug.Log(string.Empty); - Diagnostics.Debug.Log("Convert png intermediate image to gif."); - this.gifConversionJob.StartConversion(); - - if (this.gifConversionJob.State != ConversionState.Done) + finally { - this.ConversionFailed(this.gifConversionJob.ErrorMessage); - return; - } - - if (!string.IsNullOrEmpty(this.intermediateFilePath)) - { - Diagnostics.Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); + updateProgress?.Wait(); - File.Delete(this.intermediateFilePath); + if (!string.IsNullOrEmpty(this.intermediateFilePath)) + { + Diagnostics.Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); + this.DeleteFileIfExists(this.intermediateFilePath); + } } - - updateProgress.Wait(); } private async Task UpdateProgress() diff --git a/Application/FileConverter/ConversionJobs/ConversionJob_Ico.cs b/Application/FileConverter/ConversionJobs/ConversionJob_Ico.cs index 9c6e3231..ed71f268 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJob_Ico.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJob_Ico.cs @@ -19,8 +19,8 @@ public override void Cancel() { base.Cancel(); - this.pngConversionJob.Cancel(); - this.icoConversionJob.Cancel(); + this.pngConversionJob?.Cancel(); + this.icoConversionJob?.Cancel(); } protected override void Initialize() @@ -34,8 +34,7 @@ protected override void Initialize() // Generate intermediate file path. string fileName = Path.GetFileName(this.OutputFilePath); - string tempPath = Path.GetTempPath(); - this.intermediateFilePath = PathHelpers.GenerateUniquePath(tempPath + fileName + ".png"); + this.intermediateFilePath = PathHelpers.GenerateTemporaryFilePath(fileName + ".png"); // Convert input in png file to send it to ffmpeg for the ico conversion. ConversionPreset intermediatePreset = new ConversionPreset("To compatible image", OutputType.Png, this.ConversionPreset.InputTypes.ToArray()); @@ -56,29 +55,34 @@ protected override void Convert() throw new Exception("The conversion preset must be valid."); } - Diagnostics.Debug.Log(string.Empty); - Diagnostics.Debug.Log("Convert image to PNG (intermediate format)."); - this.pngConversionJob.StartConversion(); - - if (this.pngConversionJob.State != ConversionState.Done) + try { - this.ConversionFailed(this.pngConversionJob.ErrorMessage); - return; + Diagnostics.Debug.Log(string.Empty); + Diagnostics.Debug.Log("Convert image to PNG (intermediate format)."); + this.pngConversionJob.StartConversion(); + + if (this.pngConversionJob.State != ConversionState.Done) + { + this.ConversionFailed(this.pngConversionJob.ErrorMessage); + return; + } + + Diagnostics.Debug.Log(string.Empty); + Diagnostics.Debug.Log("Convert png intermediate image to ICO."); + this.icoConversionJob.StartConversion(); + + if (this.icoConversionJob.State != ConversionState.Done) + { + this.ConversionFailed(this.icoConversionJob.ErrorMessage); + return; + } } - - Diagnostics.Debug.Log(string.Empty); - Diagnostics.Debug.Log("Convert png intermediate image to ICO."); - this.icoConversionJob.StartConversion(); - - if (this.icoConversionJob.State != ConversionState.Done) + finally { - this.ConversionFailed(this.icoConversionJob.ErrorMessage); - return; - } - - Diagnostics.Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); + Diagnostics.Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); - File.Delete(this.intermediateFilePath); + this.DeleteFileIfExists(this.intermediateFilePath); + } } } } diff --git a/Application/FileConverter/ConversionJobs/ConversionJob_ImageMagick.cs b/Application/FileConverter/ConversionJobs/ConversionJob_ImageMagick.cs index 312431f1..802c51b3 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJob_ImageMagick.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJob_ImageMagick.cs @@ -3,6 +3,8 @@ namespace FileConverter.ConversionJobs { using System; + using System.Globalization; + using System.Reflection; using FileConverter.Diagnostics; using ImageMagick; @@ -11,6 +13,16 @@ public class ConversionJob_ImageMagick : ConversionJob { private const float BaseDpiForPdfConversion = 200f; private const int PdfSuperSamplingRatio = 1; + private const int MaxPdfPageCount = 250; + private const ulong MaxImagePixels = 250000000UL; + private const ulong MaxMagickMemoryBytes = 512UL * 1024UL * 1024UL; + private const ulong MaxMagickMapBytes = 1024UL * 1024UL * 1024UL; + private const ulong MaxMagickDiskBytes = 2048UL * 1024UL * 1024UL; + private const uint MaxMagickThreads = 4; + private const uint MaxMagickSeconds = 180; + + private static readonly object ResourceLimitsLock = new object(); + private static bool resourceLimitsApplied; private bool isInputFilePdf; private int pageCount; @@ -29,6 +41,7 @@ protected override void Initialize() string applicationDirectory = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); MagickNET.SetGhostscriptDirectory(applicationDirectory); + ApplyImageMagickResourceLimits(); this.isInputFilePdf = System.IO.Path.GetExtension(this.InputFilePath).ToLowerInvariant() == ".pdf"; @@ -40,13 +53,16 @@ protected override void Initialize() protected override int GetOutputFilesCount() { + ApplyImageMagickResourceLimits(); + if (System.IO.Path.GetExtension(this.InputFilePath).ToLowerInvariant() == ".pdf") { using (MagickImageCollection images = new MagickImageCollection()) { MagickReadSettings settings = new MagickReadSettings(); settings.Density = new Density(1, 1); - images.Read(this.InputFilePath); + images.Read(this.InputFilePath, settings); + ValidatePdfPageCount(images.Count); return images.Count; } @@ -134,6 +150,7 @@ private void ConvertPdf() Debug.Log($"Load pdf {this.InputFilePath} succeed."); this.pageCount = images.Count; + ValidatePdfPageCount(this.pageCount); this.UserState = Properties.Resources.ConversionStateConversion; @@ -207,7 +224,7 @@ private void ConvertImage(MagickImage image, bool ignoreScale = false) uint width = System.Math.Min(image.Width, maximumSize); uint height = System.Math.Min(image.Height, maximumSize); - Debug.Log($"Clamp size to maximum size of {width}x{width} (from {image.Width}x{image.Height} to {width}x{height})."); + Debug.Log($"Clamp size to maximum size of {width}x{height} (from {image.Width}x{image.Height} to {width}x{height})."); image.Scale(width, height); } @@ -248,6 +265,58 @@ private void ConvertImage(MagickImage image, bool ignoreScale = false) image.Progress -= this.Image_Progress; } + private static void ApplyImageMagickResourceLimits() + { + if (resourceLimitsApplied) + { + return; + } + + lock (ResourceLimitsLock) + { + if (resourceLimitsApplied) + { + return; + } + + SetResourceLimit("Memory", MaxMagickMemoryBytes); + SetResourceLimit("Map", MaxMagickMapBytes); + SetResourceLimit("Disk", MaxMagickDiskBytes); + SetResourceLimit("Area", MaxImagePixels); + SetResourceLimit("Thread", MaxMagickThreads); + SetResourceLimit("Time", MaxMagickSeconds); + SetResourceLimit("ListLength", MaxPdfPageCount); + resourceLimitsApplied = true; + } + } + + private static void SetResourceLimit(string propertyName, object value) + { + try + { + PropertyInfo property = typeof(ResourceLimits).GetProperty(propertyName); + if (property == null || !property.CanWrite) + { + return; + } + + object typedValue = System.Convert.ChangeType(value, property.PropertyType, CultureInfo.InvariantCulture); + property.SetValue(null, typedValue, null); + } + catch (Exception exception) + { + Debug.Log($"Failed to set ImageMagick resource limit {propertyName}: {exception.Message}"); + } + } + + private static void ValidatePdfPageCount(int pages) + { + if (pages > MaxPdfPageCount) + { + throw new InvalidOperationException($"PDF conversion is limited to {MaxPdfPageCount} pages per file."); + } + } + private void Image_Progress(object sender, ProgressEventArgs eventArgs) { if (this.CancelIsRequested) diff --git a/Application/FileConverter/ConversionJobs/ConversionJob_Office.cs b/Application/FileConverter/ConversionJobs/ConversionJob_Office.cs index c9ca3e23..a01b2afa 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJob_Office.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJob_Office.cs @@ -2,8 +2,16 @@ namespace FileConverter.ConversionJobs { + using System; + using System.Globalization; + using System.Reflection; + + using FileConverter.Diagnostics; + public abstract class ConversionJob_Office : ConversionJob { + private const int MsoAutomationSecurityForceDisable = 3; + protected ConversionJob_Office() : base() { } @@ -28,6 +36,19 @@ protected abstract ApplicationName Application protected override bool IsCancelable() => false; + protected void HardenOfficeApplicationInstance(object officeApplication) + { + if (officeApplication == null) + { + return; + } + + this.TrySetOfficeApplicationProperty(officeApplication, "AutomationSecurity", MsoAutomationSecurityForceDisable); + this.TrySetOfficeApplicationProperty(officeApplication, "EnableEvents", false); + this.TrySetOfficeApplicationProperty(officeApplication, "DisplayAlerts", 0); + this.TrySetOfficeApplicationProperty(officeApplication, "AskToUpdateLinks", false); + } + protected override void Initialize() { base.Initialize(); @@ -65,5 +86,34 @@ protected override void OnConversionFailed() protected abstract void InitializeOfficeApplicationInstanceIfNecessary(); protected abstract void ReleaseOfficeApplicationInstanceIfNeeded(); + + private void TrySetOfficeApplicationProperty(object officeApplication, string propertyName, object value) + { + try + { + PropertyInfo property = officeApplication.GetType().GetProperty(propertyName); + if (property == null || !property.CanWrite) + { + return; + } + + object typedValue = this.ConvertValue(value, property.PropertyType); + property.SetValue(officeApplication, typedValue, null); + } + catch (Exception exception) + { + Debug.Log($"Could not set Office automation property {propertyName}: {exception.Message}"); + } + } + + private object ConvertValue(object value, Type propertyType) + { + if (propertyType.IsEnum) + { + return Enum.ToObject(propertyType, value); + } + + return System.Convert.ChangeType(value, propertyType, CultureInfo.InvariantCulture); + } } -} \ No newline at end of file +} diff --git a/Application/FileConverter/ConversionJobs/ConversionJob_PowerPoint.cs b/Application/FileConverter/ConversionJobs/ConversionJob_PowerPoint.cs index 110c7fc7..e590e74a 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJob_PowerPoint.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJob_PowerPoint.cs @@ -69,8 +69,7 @@ protected override void Initialize() { // Generate intermediate file path. string fileName = Path.GetFileNameWithoutExtension(this.InputFilePath); - string tempPath = Path.GetTempPath(); - this.intermediateFilePath = PathHelpers.GenerateUniquePath(tempPath + fileName + ".pdf"); + this.intermediateFilePath = PathHelpers.GenerateTemporaryFilePath(fileName + ".pdf"); ConversionPreset intermediatePreset = new ConversionPreset("Pdf to image", this.ConversionPreset, "pdf"); this.pdf2ImageConversionJob = ConversionJobFactory.Create(intermediatePreset, this.intermediateFilePath); @@ -93,45 +92,52 @@ protected override void Convert() return; } - this.UserState = Properties.Resources.ConversionStateConversion; - - Debug.Log("Convert PowerPoint document to pdf."); - this.document.ExportAsFixedFormat(this.intermediateFilePath, PowerPoint.Enums.PpFixedFormatType.ppFixedFormatTypePDF); - - Debug.Log($"Close PowerPoint document '{this.InputFilePath}'."); - this.document.Close(); - this.document = null; + try + { + this.UserState = Properties.Resources.ConversionStateConversion; - this.ReleaseOfficeApplicationInstanceIfNeeded(); + Debug.Log("Convert PowerPoint document to pdf."); + this.document.ExportAsFixedFormat(this.intermediateFilePath, PowerPoint.Enums.PpFixedFormatType.ppFixedFormatTypePDF); + } + finally + { + this.CloseDocumentIfNeeded(); + this.ReleaseOfficeApplicationInstanceIfNeeded(); + } if (this.pdf2ImageConversionJob != null) { - if (!System.IO.File.Exists(this.intermediateFilePath)) + Task updateProgress = null; + try { - this.ConversionFailed(Properties.Resources.ErrorCantFindOutputFiles); - return; - } + if (!System.IO.File.Exists(this.intermediateFilePath)) + { + this.ConversionFailed(Properties.Resources.ErrorCantFindOutputFiles); + return; + } - Task updateProgress = this.UpdateProgress(); + updateProgress = this.UpdateProgress(); - Debug.Log("Convert pdf to images."); + Debug.Log("Convert pdf to images."); - this.pdf2ImageConversionJob.StartConversion(); + this.pdf2ImageConversionJob.StartConversion(); - if (this.pdf2ImageConversionJob.State != ConversionState.Done) - { - this.ConversionFailed(this.pdf2ImageConversionJob.ErrorMessage); - return; + if (this.pdf2ImageConversionJob.State != ConversionState.Done) + { + this.ConversionFailed(this.pdf2ImageConversionJob.ErrorMessage); + return; + } } - - if (!string.IsNullOrEmpty(this.intermediateFilePath)) + finally { - Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); + updateProgress?.Wait(); - File.Delete(this.intermediateFilePath); + if (!string.IsNullOrEmpty(this.intermediateFilePath)) + { + Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); + this.DeleteFileIfExists(this.intermediateFilePath); + } } - - updateProgress.Wait(); } } @@ -145,6 +151,7 @@ protected override void InitializeOfficeApplicationInstanceIfNecessary() // Initialize PowerPoint application. Debug.Log("Instantiate PowerPoint application via interop."); this.application = new PowerPoint.Application(); + this.HardenOfficeApplicationInstance(this.application); } protected override void ReleaseOfficeApplicationInstanceIfNeeded() @@ -161,15 +168,11 @@ protected override void ReleaseOfficeApplicationInstanceIfNeeded() private async Task UpdateProgress() { - while (this.pdf2ImageConversionJob.State != ConversionState.Done && + while (this.pdf2ImageConversionJob != null && + this.pdf2ImageConversionJob.State != ConversionState.Done && this.pdf2ImageConversionJob.State != ConversionState.Failed) { - if (this.pdf2ImageConversionJob != null && this.pdf2ImageConversionJob.State == ConversionState.InProgress) - { - this.Progress = this.pdf2ImageConversionJob.Progress; - } - - if (this.pdf2ImageConversionJob != null && this.pdf2ImageConversionJob.State == ConversionState.InProgress) + if (this.pdf2ImageConversionJob.State == ConversionState.InProgress) { this.Progress = this.pdf2ImageConversionJob.Progress; this.UserState = this.pdf2ImageConversionJob.UserState; @@ -205,5 +208,25 @@ private bool TryLoadDocumentIfNecessary() return this.document != null; } + + private void CloseDocumentIfNeeded() + { + if (this.document == null) + { + return; + } + + try + { + Debug.Log($"Close PowerPoint document '{this.InputFilePath}'."); + this.document.Close(); + } + catch (Exception exception) + { + Debug.Log($"Failed to close PowerPoint document '{this.InputFilePath}': {exception.Message}."); + } + + this.document = null; + } } } diff --git a/Application/FileConverter/ConversionJobs/ConversionJob_Word.cs b/Application/FileConverter/ConversionJobs/ConversionJob_Word.cs index cf1360d8..8886b98e 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJob_Word.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJob_Word.cs @@ -70,8 +70,7 @@ protected override void Initialize() { // Generate intermediate file path. string fileName = Path.GetFileNameWithoutExtension(this.InputFilePath); - string tempPath = Path.GetTempPath(); - this.intermediateFilePath = PathHelpers.GenerateUniquePath(tempPath + fileName + ".pdf"); + this.intermediateFilePath = PathHelpers.GenerateTemporaryFilePath(fileName + ".pdf"); ConversionPreset intermediatePreset = new ConversionPreset("Pdf to image", this.ConversionPreset, "pdf"); this.pdf2ImageConversionJob = ConversionJobFactory.Create(intermediatePreset, this.intermediateFilePath); @@ -94,59 +93,66 @@ protected override void Convert() return; } - // Make this document the active document. - this.document.Activate(); - - this.UserState = Properties.Resources.ConversionStateConversion; - - Debug.Log("Convert word document to pdf."); - // this.document.ExportAsFixedFormat(this.intermediateFilePath, Word.WdExportFormat.wdExportFormatPDF); - this.document.ExportAsFixedFormat(this.intermediateFilePath, - Word.Enums.WdExportFormat.wdExportFormatPDF, - false, - Word.Enums.WdExportOptimizeFor.wdExportOptimizeForPrint, - Word.Enums.WdExportRange.wdExportAllDocument, - 1, 1, - Word.Enums.WdExportItem.wdExportDocumentContent, - true, - true, - Word.Enums.WdExportCreateBookmarks.wdExportCreateHeadingBookmarks, - true); + try + { + // Make this document the active document. + this.document.Activate(); - Debug.Log($"Close word document '{this.InputFilePath}'."); - this.document.Close(Word.Enums.WdSaveOptions.wdDoNotSaveChanges); - this.document = null; + this.UserState = Properties.Resources.ConversionStateConversion; - this.ReleaseOfficeApplicationInstanceIfNeeded(); + Debug.Log("Convert word document to pdf."); + // this.document.ExportAsFixedFormat(this.intermediateFilePath, Word.WdExportFormat.wdExportFormatPDF); + this.document.ExportAsFixedFormat(this.intermediateFilePath, + Word.Enums.WdExportFormat.wdExportFormatPDF, + false, + Word.Enums.WdExportOptimizeFor.wdExportOptimizeForPrint, + Word.Enums.WdExportRange.wdExportAllDocument, + 1, 1, + Word.Enums.WdExportItem.wdExportDocumentContent, + true, + true, + Word.Enums.WdExportCreateBookmarks.wdExportCreateHeadingBookmarks, + true); + } + finally + { + this.CloseDocumentIfNeeded(); + this.ReleaseOfficeApplicationInstanceIfNeeded(); + } if (this.pdf2ImageConversionJob != null) { - if (!System.IO.File.Exists(this.intermediateFilePath)) + Task updateProgress = null; + try { - this.ConversionFailed(Properties.Resources.ErrorCantFindOutputFiles); - return; - } + if (!System.IO.File.Exists(this.intermediateFilePath)) + { + this.ConversionFailed(Properties.Resources.ErrorCantFindOutputFiles); + return; + } - Task updateProgress = this.UpdateProgress(); + updateProgress = this.UpdateProgress(); - Debug.Log("Convert pdf to images."); + Debug.Log("Convert pdf to images."); - this.pdf2ImageConversionJob.StartConversion(); + this.pdf2ImageConversionJob.StartConversion(); - if (this.pdf2ImageConversionJob.State != ConversionState.Done) - { - this.ConversionFailed(this.pdf2ImageConversionJob.ErrorMessage); - return; + if (this.pdf2ImageConversionJob.State != ConversionState.Done) + { + this.ConversionFailed(this.pdf2ImageConversionJob.ErrorMessage); + return; + } } - - if (!string.IsNullOrEmpty(this.intermediateFilePath)) + finally { - Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); + updateProgress?.Wait(); - File.Delete(this.intermediateFilePath); + if (!string.IsNullOrEmpty(this.intermediateFilePath)) + { + Debug.Log($"Delete intermediate file {this.intermediateFilePath}."); + this.DeleteFileIfExists(this.intermediateFilePath); + } } - - updateProgress.Wait(); } } @@ -163,6 +169,7 @@ protected override void InitializeOfficeApplicationInstanceIfNecessary() { Visible = false }; + this.HardenOfficeApplicationInstance(this.application); } protected override void ReleaseOfficeApplicationInstanceIfNeeded() @@ -179,15 +186,11 @@ protected override void ReleaseOfficeApplicationInstanceIfNeeded() private async Task UpdateProgress() { - while (this.pdf2ImageConversionJob.State != ConversionState.Done && + while (this.pdf2ImageConversionJob != null && + this.pdf2ImageConversionJob.State != ConversionState.Done && this.pdf2ImageConversionJob.State != ConversionState.Failed) { - if (this.pdf2ImageConversionJob != null && this.pdf2ImageConversionJob.State == ConversionState.InProgress) - { - this.Progress = this.pdf2ImageConversionJob.Progress; - } - - if (this.pdf2ImageConversionJob != null && this.pdf2ImageConversionJob.State == ConversionState.InProgress) + if (this.pdf2ImageConversionJob.State == ConversionState.InProgress) { this.Progress = this.pdf2ImageConversionJob.Progress; this.UserState = this.pdf2ImageConversionJob.UserState; @@ -223,5 +226,25 @@ private bool TryLoadDocumentIfNecessary() return this.document != null; } + + private void CloseDocumentIfNeeded() + { + if (this.document == null) + { + return; + } + + try + { + Debug.Log($"Close word document '{this.InputFilePath}'."); + this.document.Close(Word.Enums.WdSaveOptions.wdDoNotSaveChanges); + } + catch (Exception exception) + { + Debug.Log($"Failed to close word document '{this.InputFilePath}': {exception.Message}."); + } + + this.document = null; + } } } diff --git a/Application/FileConverter/ConversionPreset/ConversionPreset.cs b/Application/FileConverter/ConversionPreset/ConversionPreset.cs index d06d203e..a0eda4fc 100644 --- a/Application/FileConverter/ConversionPreset/ConversionPreset.cs +++ b/Application/FileConverter/ConversionPreset/ConversionPreset.cs @@ -321,7 +321,14 @@ public void RemoveInputType(string inputType) public string GenerateOutputFilePath(string inputFilePath, int numberIndex, int numberMax) { - return PathHelpers.GenerateFilePathFromTemplate(inputFilePath, this.OutputType, this.OutputFileNameTemplate, numberIndex, numberMax); + return PathHelpers.GenerateFilePathFromTemplate( + inputFilePath, + this.OutputType, + this.OutputFileNameTemplate, + numberIndex, + numberMax, + this.ShortName, + this.FullName); } public void SetSettingsValue(string settingsKey, string value) diff --git a/Application/FileConverter/Diagnostics/Debug.cs b/Application/FileConverter/Diagnostics/Debug.cs index 6dda0063..42e21dc5 100644 --- a/Application/FileConverter/Diagnostics/Debug.cs +++ b/Application/FileConverter/Diagnostics/Debug.cs @@ -56,6 +56,18 @@ public static int FirstErrorCode public static DiagnosticsData[] Data => Debug.diagnosticsDataById.Values.ToArray(); + public static string DiagnosticsFolderPath => Debug.diagnosticsFolderPath; + + public static string AllContent + { + get + { + return string.Join( + Environment.NewLine + Environment.NewLine, + Debug.Data.Select(data => $"[{data.Name}]{Environment.NewLine}{data.Content}")); + } + } + public static void Log(string message) { Debug.LogInternal(error: false, message, ConsoleColor.White); diff --git a/Application/FileConverter/FileConverter.csproj b/Application/FileConverter/FileConverter.csproj index fd50ac17..95fbe6ac 100644 --- a/Application/FileConverter/FileConverter.csproj +++ b/Application/FileConverter/FileConverter.csproj @@ -1,424 +1,426 @@ - - - - - Debug - AnyCPU - {D27A76D2-43E4-43CC-9DA3-334B0B46F4E5} - WinExe - Properties - FileConverter - FileConverter - v4.8 - 512 - {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - 4 - - - - - - AnyCPU - true - full - false - bin\Debug\ - TRACE;DEBUG - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - x64 - bin\x64\Debug\ - TRACE;DEBUG - - - x64 - bin\x64\Release\ - - - Resources\ApplicationIcon.ico - - - true - bin\x86\Debug\ - TRACE;DEBUG;BUILD32 - full - x86 - prompt - MinimumRecommendedRules.ruleset - true - - - bin\x86\Release\ - TRACE;BUILD32 - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - true - - - - ..\..\Middleware\Markdown.Xaml.dll - - - - False - ..\..\Middleware\Ripper.dll - - - - - - - - - - - - 4.0 - - - - - - - - ..\..\Middleware\yeti.mmedia.dll - - - - - MSBuild:Compile - Designer - - - - ConversionJobControl.xaml - - - - EncodingQualitySliderControl.xaml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Resources.en.resx - True - True - True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - HelpWindow.xaml - - - DiagnosticsWindow.xaml - - - - - - SettingsWindow.xaml - - - - UpgradeWindow.xaml - - - - Designer - MSBuild:Compile - - - Designer - MSBuild:Compile - - - Designer - MSBuild:Compile - - - Designer - MSBuild:Compile - - - MSBuild:Compile - Designer - - - Application.xaml - Code - - - - - - MainWindow.xaml - Code - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - Designer - MSBuild:Compile - - - Designer - MSBuild:Compile - - - Designer - MSBuild:Compile - - - - - - true - - - Code - true - - - True - True - Resources.resx - - - True - Settings.settings - True - - - - - - - - - - - PublicResXFileCodeGenerator - Resources.en.Designer.cs - Designer - - - - - - - - - - - - PublicResXFileCodeGenerator - Resources.Designer.cs - Designer - - - - - - - - - - - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {0c44ca69-42d6-4357-bdfd-83069d1aba2f} - FileConverterExtension - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 8.4.0 - - - 14.10.2 - - - 10.0.3 - - - 1.1.135 - - - 1.7.4.11 - - - 1.7.4.11 - - - 1.7.4.11 - - - 2.7.2 - - - 2.0.2 - - - - + + + + + Debug + AnyCPU + {D27A76D2-43E4-43CC-9DA3-334B0B46F4E5} + WinExe + Properties + FileConverter + FileConverter + v4.8 + 512 + {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + 4 + + + + + + AnyCPU + true + full + false + bin\Debug\ + TRACE;DEBUG + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + x64 + bin\x64\Debug\ + TRACE;DEBUG + + + x64 + bin\x64\Release\ + + + Resources\ApplicationIcon.ico + + + true + bin\x86\Debug\ + TRACE;DEBUG;BUILD32 + full + x86 + prompt + MinimumRecommendedRules.ruleset + true + + + bin\x86\Release\ + TRACE;BUILD32 + true + pdbonly + x86 + prompt + MinimumRecommendedRules.ruleset + true + + + + ..\..\Middleware\Markdown.Xaml.dll + + + + False + ..\..\Middleware\Ripper.dll + + + + + + + + + + + + 4.0 + + + + + + + + ..\..\Middleware\yeti.mmedia.dll + + + + + MSBuild:Compile + Designer + + + + ConversionJobControl.xaml + + + + EncodingQualitySliderControl.xaml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Resources.en.resx + True + True + True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HelpWindow.xaml + + + DiagnosticsWindow.xaml + + + + + + SettingsWindow.xaml + + + + UpgradeWindow.xaml + + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + MSBuild:Compile + Designer + + + Application.xaml + Code + + + + + + MainWindow.xaml + Code + + + MSBuild:Compile + Designer + + + MSBuild:Compile + Designer + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + + + + true + + + Code + true + + + True + True + Resources.resx + + + True + Settings.settings + True + + + + + + + + + + + PublicResXFileCodeGenerator + Resources.en.Designer.cs + Designer + + + + + + + + + + + + PublicResXFileCodeGenerator + Resources.Designer.cs + Designer + + + + + + + + + + + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {0c44ca69-42d6-4357-bdfd-83069d1aba2f} + FileConverterExtension + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 8.4.0 + + + 14.14.0 + + + 10.0.3 + + + 1.1.135 + + + 1.7.4.11 + + + 1.7.4.11 + + + 1.7.4.11 + + + 2.7.2 + + + 2.0.2 + + + + copy /Y "$(SolutionDir)Middleware\ffmpeg\ffmpeg.exe" "$(TargetDir)ffmpeg.exe" copy /Y "$(SolutionDir)Middleware\gs\gsdll64.dll" "$(TargetDir)gsdll64.dll" copy /Y "$(SolutionDir)Middleware\gs\gswin64c.exe" "$(TargetDir)gswin64c.exe" @@ -426,13 +428,13 @@ copy /Y "$(ProjectDir)Settings.default.xml" "$(TargetDir)Settings.default.xml" robocopy $(TargetDir) $(TargetDir)\Languages "$(TargetName).resources.dll" /CREATE /S /XD Languages /IS /IT /NFL /NDL /NJH /NJS /NC /NS /NP if %25errorlevel%25 leq 1 exit 0 else exit %25errorlevel%25 robocopy $(TargetDir) $(TargetDir)\Languages "$(TargetName).resources.dll" /MOVE /S /XD Languages /XL /IS /IT /NFL /NDL /NJH /NJS /NC /NS /NP -if %25errorlevel%25 leq 1 exit 0 else exit %25errorlevel%25 - +if %25errorlevel%25 leq 1 exit 0 else exit %25errorlevel%25 + - \ No newline at end of file + --> + diff --git a/Application/FileConverter/Helpers.cs b/Application/FileConverter/Helpers.cs index 1112e10f..8e8c85de 100644 --- a/Application/FileConverter/Helpers.cs +++ b/Application/FileConverter/Helpers.cs @@ -1,357 +1,405 @@ -// License: http://www.gnu.org/licenses/gpl.html GPL version 3. - -namespace FileConverter -{ - using System; - using System.Collections.Generic; - using System.Globalization; - using System.IO; - using System.Reflection; - using System.Threading; - - using FileConverter.ConversionJobs; - using FileConverter.Services; - - using SharpShell.Helpers; - - using Microsoft.Win32; - using CommunityToolkit.Mvvm.DependencyInjection; - - public static class Helpers - { - public static readonly string[] CompatibleInputExtensions = { - "3gp","3gpp","aac","aiff","ape","arw","avi","avif","bik","bmp","cda","cr2","dds","dng","doc","docx", - "exr","flac","flv","gif","heic","ico","jfif","jpg","jpeg","m4a","m4b","m4v","mkv","mov","mp3","mp4", - "mpg","mpeg","nef","odp","ods","odt","oga","ogg","ogv","opus","pdf","png","ppt","pptx","psd", - "raf", "rm","svg","tga","tif","tiff", "ts", "vob","wav","webm","webp","wma","wmv","xls","xlsx" - }; - - public static string GetExtensionCategory(string extension) - { - switch (extension) - { - case "aac": - case "aiff": - case "ape": - case "cda": - case "flac": - case "mp3": - case "m4a": - case "m4b": - case "oga": - case "ogg": - case "opus": - case "wav": - case "wma": - return InputCategoryNames.Audio; - - case "3gp": - case "3gpp": - case "avi": - case "bik": - case "flv": - case "m4v": - case "mp4": - case "mpg": - case "mpeg": - case "mov": - case "mkv": - case "ogv": - case "rm": - case "ts": - case "vob": - case "webm": - case "wmv": - return InputCategoryNames.Video; - - case "arw": - case "avif": - case "bmp": - case "cr2": - case "dds": - case "dng": - case "exr": - case "heic": - case "ico": - case "jfif": - case "jpg": - case "jpeg": - case "nef": - case "png": - case "psd": - case "raf": - case "tga": - case "tif": - case "tiff": - case "svg": - case "xcf": - case "webp": - return InputCategoryNames.Image; - - case "gif": - return InputCategoryNames.AnimatedImage; - - case "pdf": - case "doc": - case "docx": - case "ppt": - case "pptx": - case "odp": - case "ods": - case "odt": - case "xls": - case "xlsx": - return InputCategoryNames.Document; - } - - return InputCategoryNames.Misc; - } - - public static bool RegisterShellExtension(string shellExtensionPath) - { - if (!Application.IsInAdmininstratorPrivileges) - { - Diagnostics.Debug.LogError("File Converter needs administrator privileges to register the shell extension."); - return false; - } - - if (!File.Exists(shellExtensionPath)) - { - Diagnostics.Debug.LogError($"Shell extension {shellExtensionPath} does not exists."); - return false; - } - - Diagnostics.Debug.Log($"Install and register shell extension: {shellExtensionPath}."); - - var regasm = new RegAsm(); - var success = regasm.Register64(shellExtensionPath, true); - if (success) - { - Diagnostics.Debug.Log($"{shellExtensionPath} installed and registered."); - Diagnostics.Debug.Log(regasm.StandardOutput); - return true; - } - else - { - Diagnostics.Debug.LogError(errorCode: 0x05, $"{shellExtensionPath} failed to register."); - Diagnostics.Debug.LogError(regasm.StandardError); - return false; - } - } - - public static bool UnregisterExtension(string shellExtensionPath) - { - if (!Application.IsInAdmininstratorPrivileges) - { - Diagnostics.Debug.LogError("File Converter needs administrator privileges to unregister the shell extension."); - return false; - } - - if (!File.Exists(shellExtensionPath)) - { - Diagnostics.Debug.LogError($"Shell extension {shellExtensionPath} does not exists."); - return false; - } - - Diagnostics.Debug.Log($"Unregister and uninstall shell extension: {shellExtensionPath}."); - - var regasm = new RegAsm(); - var success = regasm.Unregister64(shellExtensionPath); - if (success) - { - Diagnostics.Debug.Log($"{shellExtensionPath} uninstalled."); - Diagnostics.Debug.Log(regasm.StandardOutput); - return true; - } - else - { - Diagnostics.Debug.LogError(errorCode: 0x05, $"{shellExtensionPath} failed to uninstall."); - Diagnostics.Debug.LogError(regasm.StandardError); - return false; - } - } - - public static IEnumerable GetSupportedCultures() - { - // Get all cultures. - CultureInfo[] cultures = CultureInfo.GetCultures(CultureTypes.AllCultures); - - // Find the location where application installed. - string exeLocation = Path.GetDirectoryName(Uri.UnescapeDataString(new UriBuilder(Assembly.GetExecutingAssembly().CodeBase).Path)); - - // Return all culture for which satellite folder found with culture code. - foreach (CultureInfo cultureInfo in cultures) - { - if (!string.IsNullOrEmpty(cultureInfo.Name) && Directory.Exists(Path.Combine(exeLocation, "Languages", cultureInfo.Name))) - { - yield return cultureInfo; - } - } - } - - public static bool IsOutputTypeCompatibleWithCategory(OutputType outputType, string category) - { - if (category == InputCategoryNames.Misc) - { - // Misc category contains unsorted input extensions, so we consider that they are compatible to be tolerant. - return true; - } - - switch (outputType) - { - case OutputType.Aac: - case OutputType.Flac: - case OutputType.Mp3: - case OutputType.Ogg: - case OutputType.Wav: - return category == InputCategoryNames.Audio || category == InputCategoryNames.Video; - - case OutputType.Avi: - case OutputType.Mkv: - case OutputType.Mp4: - case OutputType.Ogv: - case OutputType.Webm: - return category == InputCategoryNames.Video || category == InputCategoryNames.AnimatedImage; - - case OutputType.Avif: - case OutputType.Ico: - case OutputType.Jpg: - case OutputType.Png: - case OutputType.Webp: - return category == InputCategoryNames.Image || category == InputCategoryNames.Document || category == InputCategoryNames.AnimatedImage; - - case OutputType.Gif: - return category == InputCategoryNames.Image || category == InputCategoryNames.Video || category == InputCategoryNames.AnimatedImage; - - case OutputType.Pdf: - return category == InputCategoryNames.Image || category == InputCategoryNames.Document; - - default: - return false; - } - } - - public static Thread InstantiateThread(string name, ThreadStart threadStart) - { - ISettingsService settingsService = Ioc.Default.GetRequiredService(); - CultureInfo currentCulture = settingsService?.Settings?.ApplicationLanguage; - - Thread thread = new Thread(threadStart); - thread.Name = name; - - if (currentCulture != null) - { - thread.CurrentCulture = currentCulture; - thread.CurrentUICulture = currentCulture; - } - - return thread; - } - - public static Thread InstantiateThread(string name, ParameterizedThreadStart parameterizedThreadStart) - { - ISettingsService settingsService = Ioc.Default.GetRequiredService(); - CultureInfo currentCulture = settingsService?.Settings?.ApplicationLanguage; - - Thread thread = new Thread(parameterizedThreadStart); - thread.Name = name; - - if (currentCulture != null) - { - thread.CurrentCulture = currentCulture; - thread.CurrentUICulture = currentCulture; - } - - return thread; - } - - /// - /// Check whether Microsoft office is available or not. - /// - /// The office application name. - /// Returns true if Office is installed on the computer. - /// source: http://stackoverflow.com/questions/3266675/how-to-detect-installed-version-of-ms-office/3267832#3267832 - /// source: http://www.codeproject.com/Articles/26520/Getting-Office-s-Version - public static bool IsMicrosoftOfficeApplicationAvailable(ConversionJobs.ConversionJob_Office.ApplicationName application) - { - string registryKeyPattern = @"Software\Microsoft\Windows\CurrentVersion\App Paths\"; - switch (application) - { - case ConversionJob_Office.ApplicationName.Word: - registryKeyPattern += "winword.exe"; - break; - - case ConversionJob_Office.ApplicationName.PowerPoint: - registryKeyPattern += "powerpnt.exe"; - break; - - case ConversionJob_Office.ApplicationName.Excel: - registryKeyPattern += "excel.exe"; - break; - - case ConversionJob_Office.ApplicationName.None: - return false; - } - - // Looks inside CURRENT_USER. - RegistryKey winwordKey = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(registryKeyPattern, false); - if (winwordKey != null) - { - string winwordPath = winwordKey.GetValue(string.Empty).ToString(); - if (!string.IsNullOrEmpty(winwordPath)) - { - return true; - } - } - - // If not found, looks inside LOCAL_MACHINE. - winwordKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(registryKeyPattern, false); - if (winwordKey != null) - { - string winwordPath = winwordKey.GetValue(string.Empty).ToString(); - if (!string.IsNullOrEmpty(winwordPath)) - { - return true; - } - } - - return false; - } - - public static ConversionJob_Office.ApplicationName GetOfficeApplicationCompatibleWithExtension(string extension) - { - switch (extension) - { - case "doc": - case "docx": - case "odt": - return ConversionJob_Office.ApplicationName.Word; - - case "ppt": - case "pptx": - case "odp": - return ConversionJob_Office.ApplicationName.PowerPoint; - - case "ods": - case "xls": - case "xlsx": - return ConversionJob_Office.ApplicationName.Excel; - } - - return ConversionJob_Office.ApplicationName.None; - } - - public static class InputCategoryNames - { - public const string Audio = "Audio"; - public const string Video = "Video"; - public const string Image = "Image"; - public const string AnimatedImage = "Animated Image"; - public const string Document = "Document"; - - public const string Misc = "Misc"; - } - +// License: http://www.gnu.org/licenses/gpl.html GPL version 3. + +namespace FileConverter +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.IO; + using System.Reflection; + using System.Threading; + + using FileConverter.ConversionJobs; + using FileConverter.Services; + + using SharpShell.Helpers; + + using Microsoft.Win32; + using CommunityToolkit.Mvvm.DependencyInjection; + + public static class Helpers + { + public static readonly string[] CompatibleInputExtensions = { + "3gp","3gpp","aac","aiff","ape","arw","avi","avif","bik","bmp","cda","cr2","dds","dng","doc","docx", + "exr","flac","flv","gif","heic","ico","jfif","jpg","jpeg","m4a","m4b","m4v","mkv","mov","mp3","mp4", + "mpg","mpeg","nef","odp","ods","odt","oga","ogg","ogv","opus","pdf","png","ppt","pptx","psd", + "raf", "rm","svg","tga","tif","tiff", "ts", "vob","wav","webm","webp","wma","wmv","xls","xlsx" + }; + + public static string GetExtensionCategory(string extension) + { + switch (extension) + { + case "aac": + case "aiff": + case "ape": + case "cda": + case "flac": + case "mp3": + case "m4a": + case "m4b": + case "oga": + case "ogg": + case "opus": + case "wav": + case "wma": + return InputCategoryNames.Audio; + + case "3gp": + case "3gpp": + case "avi": + case "bik": + case "flv": + case "m4v": + case "mp4": + case "mpg": + case "mpeg": + case "mov": + case "mkv": + case "ogv": + case "rm": + case "ts": + case "vob": + case "webm": + case "wmv": + return InputCategoryNames.Video; + + case "arw": + case "avif": + case "bmp": + case "cr2": + case "dds": + case "dng": + case "exr": + case "heic": + case "ico": + case "jfif": + case "jpg": + case "jpeg": + case "nef": + case "png": + case "psd": + case "raf": + case "tga": + case "tif": + case "tiff": + case "svg": + case "xcf": + case "webp": + return InputCategoryNames.Image; + + case "gif": + return InputCategoryNames.AnimatedImage; + + case "pdf": + case "doc": + case "docx": + case "ppt": + case "pptx": + case "odp": + case "ods": + case "odt": + case "xls": + case "xlsx": + return InputCategoryNames.Document; + } + + return InputCategoryNames.Misc; + } + + public static bool RegisterShellExtension(string shellExtensionPath) + { + if (!Application.IsInAdmininstratorPrivileges) + { + Diagnostics.Debug.LogError("ZFileConverter needs administrator privileges to register the shell extension."); + return false; + } + + if (!File.Exists(shellExtensionPath)) + { + Diagnostics.Debug.LogError($"Shell extension {shellExtensionPath} does not exists."); + return false; + } + + Diagnostics.Debug.Log($"Install and register shell extension: {shellExtensionPath}."); + + var regasm = new RegAsm(); + var success = regasm.Register64(shellExtensionPath, true); + if (success) + { + Diagnostics.Debug.Log($"{shellExtensionPath} installed and registered."); + Diagnostics.Debug.Log(regasm.StandardOutput); + return true; + } + else + { + Diagnostics.Debug.LogError(errorCode: 0x05, $"{shellExtensionPath} failed to register."); + Diagnostics.Debug.LogError(regasm.StandardError); + return false; + } + } + + public static string GetDefaultShellExtensionPath() + { + string executablePath = Uri.UnescapeDataString(new UriBuilder(Assembly.GetExecutingAssembly().CodeBase).Path); + string executableFolder = Path.GetDirectoryName(executablePath); + return Path.Combine(executableFolder, "FileConverterExtension.dll"); + } + + public static bool RepairShellExtension(string shellExtensionPath) + { + if (!Application.IsInAdmininstratorPrivileges) + { + Diagnostics.Debug.LogError("ZFileConverter needs administrator privileges to repair the shell extension."); + return false; + } + + if (!File.Exists(shellExtensionPath)) + { + Diagnostics.Debug.LogError($"Shell extension {shellExtensionPath} does not exists."); + return false; + } + + Diagnostics.Debug.Log($"Repair shell extension registration: {shellExtensionPath}."); + + var regasm = new RegAsm(); + if (regasm.Unregister64(shellExtensionPath)) + { + Diagnostics.Debug.Log($"{shellExtensionPath} previous registration removed."); + Diagnostics.Debug.Log(regasm.StandardOutput); + } + else + { + Diagnostics.Debug.Log("Previous shell extension registration could not be removed. Continuing with fresh registration."); + Diagnostics.Debug.Log(regasm.StandardError); + } + + bool success = regasm.Register64(shellExtensionPath, true); + if (success) + { + Diagnostics.Debug.Log($"{shellExtensionPath} repaired and registered."); + Diagnostics.Debug.Log(regasm.StandardOutput); + return true; + } + + Diagnostics.Debug.LogError(errorCode: 0x05, $"{shellExtensionPath} failed to register during repair."); + Diagnostics.Debug.LogError(regasm.StandardError); + return false; + } + + public static bool UnregisterExtension(string shellExtensionPath) + { + if (!Application.IsInAdmininstratorPrivileges) + { + Diagnostics.Debug.LogError("ZFileConverter needs administrator privileges to unregister the shell extension."); + return false; + } + + if (!File.Exists(shellExtensionPath)) + { + Diagnostics.Debug.LogError($"Shell extension {shellExtensionPath} does not exists."); + return false; + } + + Diagnostics.Debug.Log($"Unregister and uninstall shell extension: {shellExtensionPath}."); + + var regasm = new RegAsm(); + var success = regasm.Unregister64(shellExtensionPath); + if (success) + { + Diagnostics.Debug.Log($"{shellExtensionPath} uninstalled."); + Diagnostics.Debug.Log(regasm.StandardOutput); + return true; + } + else + { + Diagnostics.Debug.LogError(errorCode: 0x05, $"{shellExtensionPath} failed to uninstall."); + Diagnostics.Debug.LogError(regasm.StandardError); + return false; + } + } + + public static IEnumerable GetSupportedCultures() + { + // Get all cultures. + CultureInfo[] cultures = CultureInfo.GetCultures(CultureTypes.AllCultures); + + // Find the location where application installed. + string exeLocation = Path.GetDirectoryName(Uri.UnescapeDataString(new UriBuilder(Assembly.GetExecutingAssembly().CodeBase).Path)); + + // Return all culture for which satellite folder found with culture code. + foreach (CultureInfo cultureInfo in cultures) + { + if (!string.IsNullOrEmpty(cultureInfo.Name) && Directory.Exists(Path.Combine(exeLocation, "Languages", cultureInfo.Name))) + { + yield return cultureInfo; + } + } + } + + public static bool IsOutputTypeCompatibleWithCategory(OutputType outputType, string category) + { + if (category == InputCategoryNames.Misc) + { + // Misc category contains unsorted input extensions, so we consider that they are compatible to be tolerant. + return true; + } + + switch (outputType) + { + case OutputType.Aac: + case OutputType.Flac: + case OutputType.Mp3: + case OutputType.Ogg: + case OutputType.Wav: + return category == InputCategoryNames.Audio || category == InputCategoryNames.Video; + + case OutputType.Avi: + case OutputType.Mkv: + case OutputType.Mp4: + case OutputType.Ogv: + case OutputType.Webm: + return category == InputCategoryNames.Video || category == InputCategoryNames.AnimatedImage; + + case OutputType.Avif: + case OutputType.Ico: + case OutputType.Jpg: + case OutputType.Png: + case OutputType.Webp: + return category == InputCategoryNames.Image || category == InputCategoryNames.Document || category == InputCategoryNames.AnimatedImage; + + case OutputType.Gif: + return category == InputCategoryNames.Image || category == InputCategoryNames.Video || category == InputCategoryNames.AnimatedImage; + + case OutputType.Pdf: + return category == InputCategoryNames.Image || category == InputCategoryNames.Document; + + default: + return false; + } + } + + public static Thread InstantiateThread(string name, ThreadStart threadStart) + { + ISettingsService settingsService = Ioc.Default.GetRequiredService(); + CultureInfo currentCulture = settingsService?.Settings?.ApplicationLanguage; + + Thread thread = new Thread(threadStart); + thread.Name = name; + + if (currentCulture != null) + { + thread.CurrentCulture = currentCulture; + thread.CurrentUICulture = currentCulture; + } + + return thread; + } + + public static Thread InstantiateThread(string name, ParameterizedThreadStart parameterizedThreadStart) + { + ISettingsService settingsService = Ioc.Default.GetRequiredService(); + CultureInfo currentCulture = settingsService?.Settings?.ApplicationLanguage; + + Thread thread = new Thread(parameterizedThreadStart); + thread.Name = name; + + if (currentCulture != null) + { + thread.CurrentCulture = currentCulture; + thread.CurrentUICulture = currentCulture; + } + + return thread; + } + + /// + /// Check whether Microsoft office is available or not. + /// + /// The office application name. + /// Returns true if Office is installed on the computer. + /// source: http://stackoverflow.com/questions/3266675/how-to-detect-installed-version-of-ms-office/3267832#3267832 + /// source: http://www.codeproject.com/Articles/26520/Getting-Office-s-Version + public static bool IsMicrosoftOfficeApplicationAvailable(ConversionJobs.ConversionJob_Office.ApplicationName application) + { + string registryKeyPattern = @"Software\Microsoft\Windows\CurrentVersion\App Paths\"; + switch (application) + { + case ConversionJob_Office.ApplicationName.Word: + registryKeyPattern += "winword.exe"; + break; + + case ConversionJob_Office.ApplicationName.PowerPoint: + registryKeyPattern += "powerpnt.exe"; + break; + + case ConversionJob_Office.ApplicationName.Excel: + registryKeyPattern += "excel.exe"; + break; + + case ConversionJob_Office.ApplicationName.None: + return false; + } + + // Looks inside CURRENT_USER. + RegistryKey winwordKey = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(registryKeyPattern, false); + if (winwordKey != null) + { + string winwordPath = winwordKey.GetValue(string.Empty).ToString(); + if (!string.IsNullOrEmpty(winwordPath)) + { + return true; + } + } + + // If not found, looks inside LOCAL_MACHINE. + winwordKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(registryKeyPattern, false); + if (winwordKey != null) + { + string winwordPath = winwordKey.GetValue(string.Empty).ToString(); + if (!string.IsNullOrEmpty(winwordPath)) + { + return true; + } + } + + return false; + } + + public static ConversionJob_Office.ApplicationName GetOfficeApplicationCompatibleWithExtension(string extension) + { + switch (extension) + { + case "doc": + case "docx": + case "odt": + return ConversionJob_Office.ApplicationName.Word; + + case "ppt": + case "pptx": + case "odp": + return ConversionJob_Office.ApplicationName.PowerPoint; + + case "ods": + case "xls": + case "xlsx": + return ConversionJob_Office.ApplicationName.Excel; + } + + return ConversionJob_Office.ApplicationName.None; + } + + public static class InputCategoryNames + { + public const string Audio = "Audio"; + public const string Video = "Video"; + public const string Image = "Image"; + public const string AnimatedImage = "Animated Image"; + public const string Document = "Document"; + + public const string Misc = "Misc"; + } + public enum HardwareAccelerationMode { Off, diff --git a/Application/FileConverter/PathHelpers.cs b/Application/FileConverter/PathHelpers.cs index ea0b9aa2..0197d1df 100644 --- a/Application/FileConverter/PathHelpers.cs +++ b/Application/FileConverter/PathHelpers.cs @@ -4,6 +4,7 @@ namespace FileConverter { using System; using System.Collections.Generic; + using System.Globalization; using System.Text; using System.Text.RegularExpressions; @@ -17,6 +18,35 @@ public static class PathHelpers private static Regex filenameRegex = new Regex(@"[^\\]*", RegexOptions.RightToLeft); private static Regex directoryRegex = new Regex(@"^(?\\\\[^\\/:*?""""<>|\r\n]+\\|[A-Za-z]:\\)(?:(?[^\\]*)\\)*"); private static Regex dateRegex = new Regex(@"\(d:(?[^)]*)\)"); + private static Regex sourceCreatedDateRegex = new Regex(@"\((?:sourcecreated|sc):(?[^)]*)\)"); + private static Regex sourceModifiedDateRegex = new Regex(@"\((?:sourcemodified|sm):(?[^)]*)\)"); + private static Regex formattedNumberIndexRegex = new Regex(@"\(n:i:(?[^)]*)\)"); + private static Regex formattedNumberCountRegex = new Regex(@"\(n:c:(?[^)]*)\)"); + private static readonly HashSet ReservedDeviceNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9" + }; public static bool IsPathDriveLetterValid(string path) { @@ -62,6 +92,65 @@ public static bool IsPathValid(string path) return PathHelpers.pathRegex.IsMatch(path); } + public static bool TryNormalizeGeneratedPath(string path, out string normalizedPath, out string errorMessage) + { + normalizedPath = path; + errorMessage = null; + + if (string.IsNullOrWhiteSpace(path)) + { + errorMessage = "The generated output path is empty."; + return false; + } + + if (!PathHelpers.IsPathValid(path)) + { + errorMessage = "The generated output path is not a valid absolute Windows path."; + return false; + } + + if (PathHelpers.ContainsRelativeDirectorySegment(path)) + { + errorMessage = "The generated output path contains a relative directory segment."; + return false; + } + + try + { + normalizedPath = System.IO.Path.GetFullPath(path); + } + catch (Exception exception) + { + errorMessage = $"The generated output path could not be normalized: {exception.Message}"; + return false; + } + + if (!PathHelpers.IsPathValid(normalizedPath)) + { + errorMessage = "The normalized output path is not valid."; + return false; + } + + if (PathHelpers.ContainsReservedDeviceName(normalizedPath)) + { + errorMessage = "The generated output path contains a reserved Windows device name."; + return false; + } + + return true; + } + + public static string GetExtensionWithoutDot(string path) + { + string extension = System.IO.Path.GetExtension(path); + if (string.IsNullOrEmpty(extension) || extension.Length <= 1) + { + return string.Empty; + } + + return extension.Substring(1).ToLowerInvariant(); + } + public static string GetFileName(string path) { MatchCollection matchCollection = PathHelpers.filenameRegex.Matches(path); @@ -110,6 +199,23 @@ public static string GenerateUniquePath(string path, params string[] blacklist) return path; } + public static string GenerateTemporaryFilePath(string preferredFileName) + { + string safeFileName = SanitizeFileSystemToken(System.IO.Path.GetFileName(preferredFileName)); + if (string.IsNullOrWhiteSpace(safeFileName)) + { + safeFileName = "conversion.tmp"; + } + + string tempFolder = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "ZFileConverter", + Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(tempFolder); + + return System.IO.Path.Combine(tempFolder, safeFileName); + } + public static bool CreateFolders(string filePath) { // Create output folders that doesn't already exist. @@ -139,15 +245,27 @@ public static bool CreateFolders(string filePath) return true; } - public static string GenerateFilePathFromTemplate(string inputFilePath, OutputType outputFileExtension, string outputFilePathTemplate, int numberIndex, int numberMax) + public static string GenerateFilePathFromTemplate( + string inputFilePath, + OutputType outputFileExtension, + string outputFilePathTemplate, + int numberIndex, + int numberMax, + string presetName = null, + string presetFullName = null) { if (string.IsNullOrEmpty(inputFilePath)) { return "Invalid input file path (argument 0)."; } - string inputExtension = System.IO.Path.GetExtension(inputFilePath).Substring(1); - string inputPathWithoutExtension = inputFilePath.Substring(0, inputFilePath.Length - inputExtension.Length - 1); + string inputExtension = GetExtensionWithoutDot(inputFilePath); + string inputPathWithoutExtension = inputFilePath; + if (!string.IsNullOrEmpty(inputExtension)) + { + inputPathWithoutExtension = inputFilePath.Substring(0, inputFilePath.Length - inputExtension.Length - 1); + } + string outputExtension = outputFileExtension.ToString().ToLowerInvariant(); if (string.IsNullOrEmpty(outputFilePathTemplate)) @@ -158,6 +276,11 @@ public static string GenerateFilePathFromTemplate(string inputFilePath, OutputTy string fileName = System.IO.Path.GetFileName(inputPathWithoutExtension); string parentDirectory = System.IO.Path.GetDirectoryName(inputPathWithoutExtension); + if (string.IsNullOrEmpty(parentDirectory)) + { + parentDirectory = System.Environment.CurrentDirectory; + } + if (!parentDirectory.EndsWith(System.IO.Path.DirectorySeparatorChar.ToString())) { parentDirectory += System.IO.Path.DirectorySeparatorChar; @@ -208,11 +331,129 @@ public static string GenerateFilePathFromTemplate(string inputFilePath, OutputTy outputPath = outputPath.Replace("(n:i)", numberIndex.ToString()); outputPath = outputPath.Replace("(n:c)", numberMax.ToString()); - outputPath = dateRegex.Replace(outputPath, match => DateTime.Now.ToString(match.Groups["format"].Value).Replace('/', '-').Replace(':', '\'')); + outputPath = formattedNumberIndexRegex.Replace(outputPath, match => FormatNumber(numberIndex, match.Groups["format"].Value)); + outputPath = formattedNumberCountRegex.Replace(outputPath, match => FormatNumber(numberMax, match.Groups["format"].Value)); + + string safePresetName = SanitizeFileSystemToken(presetName); + string safePresetPath = SanitizePresetPath(presetFullName ?? presetName); + outputPath = outputPath.Replace("(preset)", safePresetName); + outputPath = outputPath.Replace("(presetname)", safePresetName); + outputPath = outputPath.Replace("(presetpath)", safePresetPath); + + outputPath = dateRegex.Replace(outputPath, match => FormatDate(DateTime.Now, match.Groups["format"].Value)); + outputPath = sourceCreatedDateRegex.Replace(outputPath, match => FormatDate(GetCreationTime(inputFilePath), match.Groups["format"].Value)); + outputPath = sourceModifiedDateRegex.Replace(outputPath, match => FormatDate(GetLastWriteTime(inputFilePath), match.Groups["format"].Value)); outputPath += "." + outputExtension; return outputPath; } + + private static string FormatNumber(int number, string format) + { + if (string.IsNullOrEmpty(format)) + { + return number.ToString(NumberFormatInfo.InvariantInfo); + } + + return number.ToString(format, NumberFormatInfo.InvariantInfo); + } + + private static string FormatDate(DateTime date, string format) + { + if (string.IsNullOrEmpty(format)) + { + return date.ToString(CultureInfo.InvariantCulture).Replace('/', '-').Replace(':', '\''); + } + + return date.ToString(format, CultureInfo.InvariantCulture).Replace('/', '-').Replace(':', '\''); + } + + private static DateTime GetCreationTime(string path) + { + if (!System.IO.File.Exists(path)) + { + return DateTime.Now; + } + + return System.IO.File.GetCreationTime(path); + } + + private static DateTime GetLastWriteTime(string path) + { + if (!System.IO.File.Exists(path)) + { + return DateTime.Now; + } + + return System.IO.File.GetLastWriteTime(path); + } + + private static bool ContainsRelativeDirectorySegment(string path) + { + string[] segments = path.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); + for (int index = 0; index < segments.Length; index++) + { + if (segments[index] == "." || segments[index] == "..") + { + return true; + } + } + + return false; + } + + private static bool ContainsReservedDeviceName(string path) + { + string root = System.IO.Path.GetPathRoot(path); + string pathWithoutRoot = string.IsNullOrEmpty(root) ? path : path.Substring(root.Length); + string[] segments = pathWithoutRoot.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); + + for (int index = 0; index < segments.Length; index++) + { + string segment = segments[index].TrimEnd(' ', '.'); + string nameWithoutExtension = System.IO.Path.GetFileNameWithoutExtension(segment); + if (ReservedDeviceNames.Contains(nameWithoutExtension)) + { + return true; + } + } + + return false; + } + + private static string SanitizePresetPath(string presetPath) + { + if (string.IsNullOrEmpty(presetPath)) + { + return string.Empty; + } + + string[] segments = presetPath.Split('/'); + for (int index = 0; index < segments.Length; index++) + { + segments[index] = SanitizeFileSystemToken(segments[index]); + } + + return string.Join(System.IO.Path.DirectorySeparatorChar.ToString(), segments); + } + + private static string SanitizeFileSystemToken(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + char[] invalidFileNameChars = System.IO.Path.GetInvalidFileNameChars(); + StringBuilder builder = new StringBuilder(value.Length); + for (int index = 0; index < value.Length; index++) + { + char character = value[index]; + builder.Append(Array.IndexOf(invalidFileNameChars, character) >= 0 ? '_' : character); + } + + return builder.ToString(); + } } } diff --git a/Application/FileConverter/Properties/AssemblyInfo.cs b/Application/FileConverter/Properties/AssemblyInfo.cs index fb176737..5a514370 100644 --- a/Application/FileConverter/Properties/AssemblyInfo.cs +++ b/Application/FileConverter/Properties/AssemblyInfo.cs @@ -5,12 +5,12 @@ // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. -[assembly: AssemblyTitle("FileConverter")] -[assembly: AssemblyDescription("")] +[assembly: AssemblyTitle("ZFileConverter")] +[assembly: AssemblyDescription("A maintained, Explorer-first file conversion utility.")] [assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("FileConverter")] -[assembly: AssemblyCopyright("Copyright © 2026")] +[assembly: AssemblyCompany("ZaidNAlAsali")] +[assembly: AssemblyProduct("ZFileConverter")] +[assembly: AssemblyCopyright("Copyright © 2026 ZaidNAlAsali and File Converter contributors")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] diff --git a/Application/FileConverter/Properties/Resources.en.resx b/Application/FileConverter/Properties/Resources.en.resx index 7338f453..8270ad3d 100644 --- a/Application/FileConverter/Properties/Resources.en.resx +++ b/Application/FileConverter/Properties/Resources.en.resx @@ -136,7 +136,7 @@ Audio - Automatically check for updates when File Converter starts + Automatically check for updates when ZFileConverter starts Automatically exit when all conversions are complete @@ -172,10 +172,10 @@ Don't forget to reward my work if you like it :) - The update will be downloaded in background, and installed once you exit File Converter. + The update will be downloaded in background, and installed once you exit ZFileConverter. - Install when I exit File Converter + Install when I exit ZFileConverter Encoding : @@ -184,7 +184,7 @@ Encoding speed : - File Converter is a shell extension. + ZFileConverter is a shell extension. That means it is integrated into the Windows explorer. @@ -217,10 +217,10 @@ None - The update has been downloaded. Exit File Converter and install the update now. + The update has been downloaded. Exit ZFileConverter and install the update now. - Install when I exit File Converter + Install when I exit ZFileConverter Report an issue @@ -268,6 +268,13 @@ ... (n:i): page number index (n:c): total page count +(n:i:D3): formatted page number index +(n:c:D3): formatted page count +(d:yyyy-MM-dd): current date +(sc:yyyy-MM-dd): source file creation date +(sm:yyyy-MM-dd): source file modified date +(preset): selected preset name +(presetpath): selected preset folder path Special paths: (p:d): my documents path @@ -275,7 +282,7 @@ Special paths: (p:v): my videos path (p:p): my pictures path -use maj for uppercase version +Use uppercase tokens for uppercase values when available. Output format @@ -308,7 +315,7 @@ use maj for uppercase version See change log ... - File Converter Settings + ZFileConverter Settings Settings @@ -320,13 +327,13 @@ use maj for uppercase version 90° clockwise rotation - An update for File Converter is available. It is strongly recommended that you install it as soon as possible! + An update for ZFileConverter is available. It is strongly recommended that you install it as soon as possible! Upgrade download in progress... - File Converter Updates + ZFileConverter Updates Defines how 'file size' relates to 'video quality'. A lower value will give you a smaller file, a greater value give you higher video quality. @@ -494,7 +501,7 @@ use maj for uppercase version Stereo - To use File Converter please open the explorer and right-click on any file you like to bring up the context menu where you will find File Converter commands (see the online documentation to see the list of compatible file formats). + To use ZFileConverter, open Explorer and right-click any supported file to bring up the context menu where you will find ZFileConverter commands (see the online documentation for compatible file formats). **This program is free software**. @@ -542,7 +549,7 @@ use maj for uppercase version Microsoft Word must be installed in order to convert Word documents. - Open File Converter website + Open ZFileConverter releases Error during conversion job initialization. diff --git a/Application/FileConverter/Properties/Resources.resx b/Application/FileConverter/Properties/Resources.resx index 11c79680..c2b0e1ef 100644 --- a/Application/FileConverter/Properties/Resources.resx +++ b/Application/FileConverter/Properties/Resources.resx @@ -1,619 +1,625 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - About - - - Allow you to choose what you want to do with your input files if the conversion succeed - - - Action when conversion succeed - - - New preset - - - Application - - - Audio - - - Automatically check for updates when File Converter starts - - - Automatically exit when all conversions finished - - - Clamp to lowest power of 2 size - - - Close - - - Conversion Presets - - - Files to convert - - - Converted from - - - Logs - - - Logs - - - Open documentation - - - Donate - - - Don't forget to reward my work if you like it :) - - - The update will be downloaded in background, and installed once you exit File Converter. - - - Install when I exit File Converter - - - Encoding : - - - Encoding speed : - - - File Converter is a shell extension. - - - That means it is integrated into the Windows explorer. - - - To use File Converter please open the explorer and right-click on any file you like to bring up the context menu where you will find File Converter commands (see the readme file to see the list of compatible file formats). - - - File name template - - - Frames per second : - - - Open GitHub project page - - - help ? - - - Input example - - - Input formats - - - Delete - - - Move in an archive folder - - - None - - - The update has been downloaded. Exit File Converter and install the update now. - - - Install when I exit File Converter - - - Report an issue - - - Move down selected preset - - - Move up selected preset - - - Constant bitrate encoding - - - Variable bitrate encoding - - - 90° - - - 90° counter clockwise rotation - - - Ok - - - 180° - - - 180° rotation - - - C:\Music\Artist\Album\Song.wav - - - Output - - - (p): input file path -(f): input filename -(o): output extension type -(i): input extension type -(d0): input parent folder -(d1): input sub parent folder -... -(n:i): page number index -(n:c): total page count - -Special paths: -(p:d): my documents path -(p:m): my music path -(p:v): my videos path -(p:p): my pictures path - -use maj for uppercase version - - - Output format - - - Preset - - - Preset Name - - - Quality : - - - Recommended bitrate range in blue - - - Delete - - - Rotate : - - - Save - - - Scale : - - - See change log ... - - - Settings - - - Settings - - - 270° - - - 90° clockwise rotation - - - An update for File Converter is available. It is strongly recommended that you install it as soon as possible! - - - Upgrade download in progress... - - - File Converter Updates - - - Define the ratio 'file size' versus 'video quality'. A lower value will give you a smaller file, a greater value give you a better video quality. - - - Faster - - - Fast - - - Medium - - - Slower - - - Slow - - - Super Fast - - - Define the ratio 'file size' versus 'compression duration'. A slow compression will give you a smaller file (for the same video quality) than a faster compression. - - - Ultra Fast - - - Very Fast - - - Very Slow - - - Video - - - PCM signed 16-bit little-endian - - - PCM signed 24-bit little-endian - - - PCM signed 32-bit little-endian - - - PCM signed 8-bit little-endian - - - None - - - No rotation - - - Conversion - - - Done - - - Extraction - - - Failed - - - In queue - - - Read input image - - - Read document - - - Animated Image - - - Audio - - - Document - - - Image - - - Video - - - Waiting duration before application exit - - - Language - - - Maximum number of simultaneous conversions - - - New preset - - - ###Downloading change log ... - - - Conversion Archives - - - Canceled. - - - Fail to find ffmpeg executable. You should try to reinstall the application. - - - Audio CD track extraction failed. - - - CD drive is not ready. - - - Fail to launch ffmpeg. - - - Fail to create output path folders. - - - Fail to generate a unique output file path. - - - Fail to read cd drive '{0}'. - - - Fail to retrieve input path drive letter. - - - Fail to retrieve track number from the cda input path. - - - Fail to use the CD drive because it is opened. - - - The input file type is not compatible with the selected output file type. - - - Invalid output path generated by output file path template. - - - Unsupported output format '{0}'. - - - Channel count : - - - Change the number of channels of the input file. - - - Mono - - - Same than input file - - - Stereo - - - **This program is free software**. - - - You can redistribute it and/or modify it under the terms of the GNU General Public License. - - - This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY. See the GNU General Public License (available in the installation folder: `LICENSE.md`) for more details. - - - The application is terminating. - - - The application will automatically terminate in {0} seconds. - - - The application will automatically terminate in 1 second. - - - Misc - - - Can't find the output file(s). - - - The conversion job failed but there is an output file that does exists. - - - Prepare conversion - - - Microsoft Office must be installed in order to convert Office documents. - - - Fail to open document with Microsoft Office (check if your licence/installation is valid). - - - Microsoft Excel must be installed in order to convert Excel documents. - - - Microsoft PowerPoint must be installed in order to convert PowerPoint documents. - - - Microsoft Word must be installed in order to convert Word documents. - - - Open File Converter website - - - Error during conversion job initialization. - - - New folder - - - New folder - - - Folder - - - Folder Name - - - Advanced mode - - - Custom Arguments - - - Custom FFMPEG command line arguments - - - No preset selected - - - Export - - - Import - - - Duplicate preset - - - Cancel - - - Error - - - Can't load file converter user settings. Do you want to fall back to default settings ? - - - Copy files in clipboard after conversion - - - Hardware acceleration mode - - - Advanced Media Framework (AMD) - - - CUDA (Nvidia) - - - Direct3D 11 - - - Direct3D 9/DXVA2 - - - Off - - - OpenCL - - - Vulkan - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + About + + + Allow you to choose what you want to do with your input files if the conversion succeed + + + Action when conversion succeed + + + New preset + + + Application + + + Audio + + + Automatically check for updates when ZFileConverter starts + + + Automatically exit when all conversions finished + + + Clamp to lowest power of 2 size + + + Close + + + Conversion Presets + + + Files to convert + + + Converted from + + + Logs + + + Logs + + + Open documentation + + + Donate + + + Don't forget to reward my work if you like it :) + + + The update will be downloaded in background, and installed once you exit ZFileConverter. + + + Install when I exit ZFileConverter + + + Encoding : + + + Encoding speed : + + + ZFileConverter is a shell extension. + + + That means it is integrated into the Windows explorer. + + + To use ZFileConverter, open Explorer and right-click any supported file to bring up the context menu where you will find ZFileConverter commands (see the readme file for compatible file formats). + + + File name template + + + Frames per second : + + + Open GitHub project page + + + help ? + + + Input example + + + Input formats + + + Delete + + + Move in an archive folder + + + None + + + The update has been downloaded. Exit ZFileConverter and install the update now. + + + Install when I exit ZFileConverter + + + Report an issue + + + Move down selected preset + + + Move up selected preset + + + Constant bitrate encoding + + + Variable bitrate encoding + + + 90° + + + 90° counter clockwise rotation + + + Ok + + + 180° + + + 180° rotation + + + C:\Music\Artist\Album\Song.wav + + + Output + + + (p): input file path +(f): input filename +(o): output extension type +(i): input extension type +(d0): input parent folder +(d1): input sub parent folder +... +(n:i): page number index +(n:c): total page count +(n:i:D3): formatted page number index +(n:c:D3): formatted page count +(d:yyyy-MM-dd): current date +(sc:yyyy-MM-dd): source file creation date +(sm:yyyy-MM-dd): source file modified date +(preset): selected preset name +(presetpath): selected preset folder path + +Special paths: +(p:d): my documents path +(p:m): my music path +(p:v): my videos path +(p:p): my pictures path + +Use uppercase tokens for uppercase values when available. + + + Output format + + + Preset + + + Preset Name + + + Quality : + + + Recommended bitrate range in blue + + + Delete + + + Rotate : + + + Save + + + Scale : + + + See change log ... + + + Settings + + + Settings + + + 270° + + + 90° clockwise rotation + + + An update for ZFileConverter is available. It is strongly recommended that you install it as soon as possible! + + + Upgrade download in progress... + + + ZFileConverter Updates + + + Define the ratio 'file size' versus 'video quality'. A lower value will give you a smaller file, a greater value give you a better video quality. + + + Faster + + + Fast + + + Medium + + + Slower + + + Slow + + + Super Fast + + + Define the ratio 'file size' versus 'compression duration'. A slow compression will give you a smaller file (for the same video quality) than a faster compression. + + + Ultra Fast + + + Very Fast + + + Very Slow + + + Video + + + PCM signed 16-bit little-endian + + + PCM signed 24-bit little-endian + + + PCM signed 32-bit little-endian + + + PCM signed 8-bit little-endian + + + None + + + No rotation + + + Conversion + + + Done + + + Extraction + + + Failed + + + In queue + + + Read input image + + + Read document + + + Animated Image + + + Audio + + + Document + + + Image + + + Video + + + Waiting duration before application exit + + + Language + + + Maximum number of simultaneous conversions + + + New preset + + + ###Downloading change log ... + + + Conversion Archives + + + Canceled. + + + Fail to find ffmpeg executable. You should try to reinstall the application. + + + Audio CD track extraction failed. + + + CD drive is not ready. + + + Fail to launch ffmpeg. + + + Fail to create output path folders. + + + Fail to generate a unique output file path. + + + Fail to read cd drive '{0}'. + + + Fail to retrieve input path drive letter. + + + Fail to retrieve track number from the cda input path. + + + Fail to use the CD drive because it is opened. + + + The input file type is not compatible with the selected output file type. + + + Invalid output path generated by output file path template. + + + Unsupported output format '{0}'. + + + Channel count : + + + Change the number of channels of the input file. + + + Mono + + + Same than input file + + + Stereo + + + **This program is free software**. + + + You can redistribute it and/or modify it under the terms of the GNU General Public License. + + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY. See the GNU General Public License (available in the installation folder: `LICENSE.md`) for more details. + + + The application is terminating. + + + The application will automatically terminate in {0} seconds. + + + The application will automatically terminate in 1 second. + + + Misc + + + Can't find the output file(s). + + + The conversion job failed but there is an output file that does exists. + + + Prepare conversion + + + Microsoft Office must be installed in order to convert Office documents. + + + Fail to open document with Microsoft Office (check if your licence/installation is valid). + + + Microsoft Excel must be installed in order to convert Excel documents. + + + Microsoft PowerPoint must be installed in order to convert PowerPoint documents. + + + Microsoft Word must be installed in order to convert Word documents. + + + Open ZFileConverter releases + + + Error during conversion job initialization. + + + New folder + + + New folder + + + Folder + + + Folder Name + + + Advanced mode + + + Custom Arguments + + + Custom FFMPEG command line arguments + + + No preset selected + + + Export + + + Import + + + Duplicate preset + + + Cancel + + + Error + + + Can't load file converter user settings. Do you want to fall back to default settings ? + + + Copy files in clipboard after conversion + + + Hardware acceleration mode + + + Advanced Media Framework (AMD) + + + CUDA (Nvidia) + + + Direct3D 11 + + + Direct3D 9/DXVA2 + + + Off + + + OpenCL + + + Vulkan + + diff --git a/Application/FileConverter/Registry.cs b/Application/FileConverter/Registry.cs index 38edb0f0..46d7e0a6 100644 --- a/Application/FileConverter/Registry.cs +++ b/Application/FileConverter/Registry.cs @@ -5,6 +5,7 @@ namespace FileConverter using System; using System.Collections.Generic; using System.IO; + using System.Linq; using System.Xml.Serialization; public class Registry : IDisposable @@ -24,20 +25,10 @@ public Entry[] SerializableEntries { get { - Entry[] entries = new Entry[this.registryEntries.Count]; - int index = 0; - foreach (KeyValuePair kvp in this.registryEntries) - { - if (kvp.Value == null) - { - continue; - } - - entries[index] = new Entry(kvp.Key, kvp.Value); - index++; - } - - return entries; + return this.registryEntries + .Where(kvp => kvp.Value != null) + .Select(kvp => new Entry(kvp.Key, kvp.Value)) + .ToArray(); } set diff --git a/Application/FileConverter/Services/ConversionJobRegisteredEventArgs.cs b/Application/FileConverter/Services/ConversionJobRegisteredEventArgs.cs new file mode 100644 index 00000000..f80f1192 --- /dev/null +++ b/Application/FileConverter/Services/ConversionJobRegisteredEventArgs.cs @@ -0,0 +1,21 @@ +// License: http://www.gnu.org/licenses/gpl.html GPL version 3. + +namespace FileConverter.Services +{ + using System; + + using FileConverter.ConversionJobs; + + public class ConversionJobRegisteredEventArgs : EventArgs + { + public ConversionJobRegisteredEventArgs(ConversionJob conversionJob) + { + this.ConversionJob = conversionJob; + } + + public ConversionJob ConversionJob + { + get; + } + } +} diff --git a/Application/FileConverter/Services/ConversionService.cs b/Application/FileConverter/Services/ConversionService.cs index 6233d81e..934c5b57 100644 --- a/Application/FileConverter/Services/ConversionService.cs +++ b/Application/FileConverter/Services/ConversionService.cs @@ -15,11 +15,13 @@ namespace FileConverter.Services public class ConversionService : ObservableObject, IConversionService { + private readonly object conversionQueueLock = new object(); private readonly List conversionJobs = new List(); private readonly int numberOfConversionThread = 1; private ISettingsService settingsService; + private bool conversionQueueIsRunning; public ConversionService(ISettingsService settingsService) { @@ -38,10 +40,12 @@ public ConversionService(ISettingsService settingsService) if (this.numberOfConversionThread <= 0) { this.numberOfConversionThread = System.Math.Max(1, Environment.ProcessorCount / 2); - Debug.Log($"The number of processors on this computer is {settingsService.Settings.MaximumNumberOfSimultaneousConversions}. Set the default number of conversion threads to {settingsService.Settings.MaximumNumberOfSimultaneousConversions}"); + Debug.Log($"The number of processors on this computer is {Environment.ProcessorCount}. Set the default number of conversion threads to {this.numberOfConversionThread}"); } } + public event System.EventHandler ConversionJobRegistered; + public event System.EventHandler ConversionJobsTerminated; public ReadOnlyCollection ConversionJobs @@ -52,107 +56,167 @@ public ReadOnlyCollection ConversionJobs public void RegisterConversionJob(ConversionJob conversionJob) { + if (conversionJob == null) + { + throw new ArgumentNullException(nameof(conversionJob)); + } + this.conversionJobs.Add(conversionJob); this.OnPropertyChanged(nameof(this.ConversionJobs)); + this.ConversionJobRegistered?.Invoke(this, new ConversionJobRegisteredEventArgs(conversionJob)); } public void ConvertFilesAsync() { + lock (this.conversionQueueLock) + { + if (this.conversionQueueIsRunning) + { + Debug.Log("Conversion queue is already running."); + return; + } + + this.conversionQueueIsRunning = true; + } + Thread fileConvertionThread = Helpers.InstantiateThread("ConversionQueueThread", this.ConvertFiles); fileConvertionThread.Start(); } - private void ConvertFiles() + public void RetryConversionJob(ConversionJob conversionJob) { - // Prepare conversions. - for (int index = 0; index < this.ConversionJobs.Count; index++) + if (conversionJob == null) { - this.ConversionJobs[index].PrepareConversion(); + throw new ArgumentNullException(nameof(conversionJob)); } - System.Collections.Specialized.StringCollection files = new System.Collections.Specialized.StringCollection(); - // Convert! - Thread[] jobThreads = new Thread[this.numberOfConversionThread]; - while (true) + lock (this.conversionQueueLock) { - // Compute conversion flags. - ConversionFlags conversionFlags = ConversionFlags.None; - bool allJobAreFinished = true; - for (int jobIndex = 0; jobIndex < this.conversionJobs.Count; jobIndex++) + if (this.conversionQueueIsRunning) { - ConversionJob conversionJob = this.conversionJobs[jobIndex]; - allJobAreFinished &= !(conversionJob.State == ConversionState.Ready || conversionJob.State == ConversionState.InProgress); + Debug.Log("Can't retry a conversion while the queue is running."); + return; + } + } + + ConversionJob retryJob = ConversionJobFactory.Create(conversionJob.ConversionPreset, conversionJob.InitialInputPath); + this.RegisterConversionJob(retryJob); + this.ConvertFilesAsync(); + } + + private void ConvertFiles() + { + try + { + List activeJobs = new List(); - if (conversionJob.State == ConversionState.InProgress) + // Prepare conversions. + for (int index = 0; index < this.ConversionJobs.Count; index++) + { + if (this.ConversionJobs[index].State == ConversionState.Unknown) { - conversionFlags |= conversionJob.StateFlags; + this.ConversionJobs[index].PrepareConversion(); + activeJobs.Add(this.ConversionJobs[index]); } } - if (allJobAreFinished) + if (activeJobs.Count == 0) { - break; + Debug.Log("No pending conversion jobs to run."); + return; } - // Start job if possible. - for (int jobIndex = 0; jobIndex < this.conversionJobs.Count; jobIndex++) + System.Collections.Specialized.StringCollection files = new System.Collections.Specialized.StringCollection(); + // Convert! + Thread[] jobThreads = new Thread[this.numberOfConversionThread]; + while (true) { - ConversionJob conversionJob = this.conversionJobs[jobIndex]; - if (conversionJob.State == ConversionState.Ready && conversionJob.CanStartConversion(conversionFlags)) + // Compute conversion flags. + ConversionFlags conversionFlags = ConversionFlags.None; + bool allJobAreFinished = true; + for (int jobIndex = 0; jobIndex < activeJobs.Count; jobIndex++) { - // Find a thread to execute the job. - Thread jobThread = null; - for (int threadIndex = 0; threadIndex < jobThreads.Length; threadIndex++) + ConversionJob conversionJob = activeJobs[jobIndex]; + allJobAreFinished &= !(conversionJob.State == ConversionState.Ready || conversionJob.State == ConversionState.InProgress); + + if (conversionJob.State == ConversionState.InProgress) { - Thread thread = jobThreads[threadIndex]; - if (thread == null || !thread.IsAlive) - { - jobThread = Helpers.InstantiateThread(conversionJob.GetType().Name, this.ExecuteConversionJob); - jobThreads[threadIndex] = jobThread; - break; - } + conversionFlags |= conversionJob.StateFlags; } + } + + if (allJobAreFinished) + { + break; + } - if (jobThread != null) + // Start job if possible. + for (int jobIndex = 0; jobIndex < activeJobs.Count; jobIndex++) + { + ConversionJob conversionJob = activeJobs[jobIndex]; + if (conversionJob.State == ConversionState.Ready && conversionJob.CanStartConversion(conversionFlags)) { - jobThread.Start(conversionJob); + // Find a thread to execute the job. + Thread jobThread = null; + for (int threadIndex = 0; threadIndex < jobThreads.Length; threadIndex++) + { + Thread thread = jobThreads[threadIndex]; + if (thread == null || !thread.IsAlive) + { + jobThread = Helpers.InstantiateThread(conversionJob.GetType().Name, this.ExecuteConversionJob); + jobThreads[threadIndex] = jobThread; + break; + } + } + + if (jobThread != null) + { + jobThread.Start(conversionJob); + + while (conversionJob.State == ConversionState.Ready) + { + Debug.Log("Wait the launch of the conversion thread before launching any other thread."); + Thread.Sleep(20); + } + } - while (conversionJob.State == ConversionState.Ready) + if (!files.Contains(conversionJob.OutputFilePath)) { - Debug.Log("Wait the launch of the conversion thread before launching any other thread."); - Thread.Sleep(20); + files.Add(conversionJob.OutputFilePath); } - } - if (!files.Contains(conversionJob.OutputFilePath)) - { - files.Add(conversionJob.OutputFilePath); + break; } - - break; } + + Thread.Sleep(50); } - Thread.Sleep(50); - } + // Copy the output files to the clipboard + if (this.settingsService.Settings.CopyFilesInClipboardAfterConversion && files.Count > 0) + { + Thread clipboardThread = Helpers.InstantiateThread("CopyFilesToClipboardThread", this.CopyFilesToClipboard); + clipboardThread.SetApartmentState(ApartmentState.STA); + clipboardThread.Start(files); + } - // Copy the output files to the clipboard - if (this.settingsService.Settings.CopyFilesInClipboardAfterConversion && files.Count > 0) - { - Thread clipboardThread = Helpers.InstantiateThread("CopyFilesToClipboardThread", this.CopyFilesToClipboard); - clipboardThread.SetApartmentState(ApartmentState.STA); - clipboardThread.Start(files); - } + bool allConversionsSucceed = true; + for (int index = 0; index < activeJobs.Count; index++) + { + allConversionsSucceed &= activeJobs[index].State == ConversionState.Done; + } - bool allConversionsSucceed = true; - for (int index = 0; index < this.conversionJobs.Count; index++) - { - allConversionsSucceed &= this.conversionJobs[index].State == ConversionState.Done; + if (this.ConversionJobsTerminated != null) + { + this.ConversionJobsTerminated.Invoke(this, new ConversionJobsTerminatedEventArgs(allConversionsSucceed)); + } } - - if (this.ConversionJobsTerminated != null) + finally { - this.ConversionJobsTerminated.Invoke(this, new ConversionJobsTerminatedEventArgs(allConversionsSucceed)); + lock (this.conversionQueueLock) + { + this.conversionQueueIsRunning = false; + } } } diff --git a/Application/FileConverter/Services/IConversionService.cs b/Application/FileConverter/Services/IConversionService.cs index e5216b09..7f201446 100644 --- a/Application/FileConverter/Services/IConversionService.cs +++ b/Application/FileConverter/Services/IConversionService.cs @@ -8,6 +8,8 @@ namespace FileConverter.Services public interface IConversionService { + event System.EventHandler ConversionJobRegistered; + event System.EventHandler ConversionJobsTerminated; ReadOnlyCollection ConversionJobs @@ -18,5 +20,7 @@ ReadOnlyCollection ConversionJobs void ConvertFilesAsync(); void RegisterConversionJob(ConversionJob conversionJob); + + void RetryConversionJob(ConversionJob conversionJob); } } diff --git a/Application/FileConverter/Services/UpgradeService.cs b/Application/FileConverter/Services/UpgradeService.cs index 1ff083dd..59474afa 100644 --- a/Application/FileConverter/Services/UpgradeService.cs +++ b/Application/FileConverter/Services/UpgradeService.cs @@ -5,7 +5,8 @@ namespace FileConverter.Services using System; using System.IO; using System.Net; - using System.Text.RegularExpressions; + using System.Security.Cryptography; + using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using System.Xml; using System.Xml.Serialization; @@ -18,11 +19,14 @@ namespace FileConverter.Services public class UpgradeService : ObservableObject, IUpgradeService { #if DEBUG - private const string BaseURI = "https://raw.githubusercontent.com/Tichau/FileConverter/integration/"; + private const string BaseURI = "https://raw.githubusercontent.com/ZaidNAlAsali/FileConverter/integration/"; #else - private const string BaseURI = "https://raw.githubusercontent.com/Tichau/FileConverter/master/"; + private const string BaseURI = "https://raw.githubusercontent.com/ZaidNAlAsali/FileConverter/master/"; #endif + private const string ReleaseHost = "github.com"; + private const string ReleasePathPrefix = "/ZaidNAlAsali/FileConverter/releases/download/"; + [NotNull] private readonly WebClient webClient = new WebClient(); @@ -68,6 +72,11 @@ public async Task CheckForUpgrade() Diagnostics.Debug.Log($"Failed to check upgrade: {exception.Message}."); } + if (task == null) + { + return null; + } + UpgradeVersionDescription versionDescription = await task; if (versionDescription == null) @@ -155,7 +164,7 @@ public void CancelUpgrade() private async Task DownloadLatestVersionDescription() { #if BUILD32 - Uri uri = new Uri(Helpers.BaseURI + "version (x86).xml"); + Uri uri = new Uri(UpgradeService.BaseURI + "version (x86).xml"); #else Uri uri = new Uri(UpgradeService.BaseURI + "version.xml"); #endif @@ -185,7 +194,7 @@ private async Task DownloadLatestVersionDescription() } catch (Exception) { - Debug.Log("Error while retrieving change log."); + Debug.Log("Error while retrieving version description."); return null; } @@ -204,24 +213,37 @@ private async Task DownloadInstaller() throw new Exception("The installer download is currently in progress."); } - Uri uri = new Uri(this.UpgradeVersionDescription.InstallerURL); + if (!this.TryCreateTrustedInstallerUri(this.UpgradeVersionDescription.InstallerURL, out Uri uri, out string uriErrorMessage)) + { + Debug.LogError($"Refuse to download upgrade installer. {uriErrorMessage}"); + this.UpgradeVersionDescription.NeedToUpgrade = false; + return; + } - string fileName = "FileConverter-setup.msi"; - Regex retrieveFileNameRegex = new Regex("/([^/]*)"); - MatchCollection matchCollection = retrieveFileNameRegex.Matches(this.UpgradeVersionDescription.InstallerURL); - if (matchCollection.Count > 0) + if (!this.IsValidSha256(this.UpgradeVersionDescription.InstallerSha256)) { - Match match = matchCollection[matchCollection.Count - 1]; - if (match.Groups.Count > 1) - { - fileName = match.Groups[1].Value; - } + Debug.LogError("Refuse to download upgrade installer. The update manifest does not contain a valid SHA-256 hash."); + this.UpgradeVersionDescription.NeedToUpgrade = false; + return; } - string tempPath = System.IO.Path.GetTempPath(); - string installerPath = System.IO.Path.Combine(tempPath, fileName); + string fileName = Uri.UnescapeDataString(Path.GetFileName(uri.LocalPath)); + string tempPath = Path.Combine(Path.GetTempPath(), "ZFileConverter", Guid.NewGuid().ToString("N")); + string installerPath = Path.Combine(tempPath, fileName); + try + { + Directory.CreateDirectory(tempPath); + } + catch (Exception exception) + { + Debug.LogError("Failed to prepare the temporary upgrade folder."); + Debug.Log(exception.ToString()); + this.UpgradeVersionDescription.NeedToUpgrade = false; + return; + } this.UpgradeVersionDescription.InstallerPath = installerPath; + this.UpgradeVersionDescription.InstallerIsVerified = false; this.UpgradeVersionDescription.InstallerDownloadInProgress = true; this.UpgradeVersionDescription.InstallerDownloadProgress = 0; @@ -234,22 +256,144 @@ private async Task DownloadInstaller() { await this.webClient.DownloadFileTaskAsync(uri, installerPath); + this.VerifyDownloadedInstaller(installerPath, this.UpgradeVersionDescription); + this.UpgradeVersionDescription.InstallerDownloadProgress = 100; + this.UpgradeVersionDescription.InstallerIsVerified = true; this.UpgradeVersionDescription.InstallerDownloadInProgress = false; - this.UpgradeVersionDescription = null; } catch (Exception exception) { - Debug.LogError("Failed to download the new File Converter upgrade. You should try again or download it manually."); + Debug.LogError("Failed to download the new ZFileConverter upgrade. You should try again or download it manually."); Debug.Log(exception.ToString()); + this.UpgradeVersionDescription.InstallerDownloadInProgress = false; + this.UpgradeVersionDescription.InstallerDownloadProgress = 0; + this.UpgradeVersionDescription.InstallerIsVerified = false; this.UpgradeVersionDescription.NeedToUpgrade = false; + this.DeleteInstallerIfExists(installerPath); } this.webClient.DownloadProgressChanged -= this.WebClient_DownloadProgressChanged; } + + private bool TryCreateTrustedInstallerUri(string installerUrl, out Uri uri, out string errorMessage) + { + uri = null; + errorMessage = null; + + if (!Uri.TryCreate(installerUrl, UriKind.Absolute, out uri)) + { + errorMessage = "The installer URL is not an absolute URL."; + return false; + } + + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + errorMessage = "The installer URL must use HTTPS."; + return false; + } + + if (!string.Equals(uri.Host, ReleaseHost, StringComparison.OrdinalIgnoreCase)) + { + errorMessage = $"The installer URL host must be {ReleaseHost}."; + return false; + } + + if (uri.AbsolutePath.IndexOf(ReleasePathPrefix, StringComparison.OrdinalIgnoreCase) < 0) + { + errorMessage = "The installer URL must point to a ZFileConverter GitHub release asset."; + return false; + } + + string fileName = Uri.UnescapeDataString(Path.GetFileName(uri.LocalPath)); + if (string.IsNullOrEmpty(fileName) || + !string.Equals(Path.GetExtension(fileName), ".msi", StringComparison.OrdinalIgnoreCase)) + { + errorMessage = "The installer URL must point to an MSI package."; + return false; + } + + return true; + } + + private bool IsValidSha256(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length != 64) + { + return false; + } + + for (int index = 0; index < value.Length; index++) + { + char character = value[index]; + bool isHex = + (character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F'); + + if (!isHex) + { + return false; + } + } + + return true; + } + + private void VerifyDownloadedInstaller(string installerPath, UpgradeVersionDescription description) + { + string expectedSha256 = description.InstallerSha256.Replace(" ", string.Empty).ToUpperInvariant(); + string actualSha256 = this.ComputeSha256(installerPath); + if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"The upgrade installer SHA-256 hash did not match. Expected {expectedSha256}, actual {actualSha256}."); + } + + if (string.IsNullOrWhiteSpace(description.InstallerPublisherSubject)) + { + return; + } + + X509Certificate2 certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(installerPath)); + if (certificate == null || + certificate.Subject.IndexOf(description.InstallerPublisherSubject, StringComparison.OrdinalIgnoreCase) < 0) + { + throw new InvalidOperationException("The upgrade installer publisher did not match the update manifest."); + } + } + + private string ComputeSha256(string filePath) + { + using (FileStream fileStream = File.OpenRead(filePath)) + using (SHA256 sha256 = SHA256.Create()) + { + byte[] hash = sha256.ComputeHash(fileStream); + return BitConverter.ToString(hash).Replace("-", string.Empty); + } + } + + private void DeleteInstallerIfExists(string installerPath) + { + try + { + if (!string.IsNullOrEmpty(installerPath) && File.Exists(installerPath)) + { + File.Delete(installerPath); + } + } + catch (Exception exception) + { + Debug.Log($"Failed to delete invalid installer {installerPath}: {exception.Message}"); + } + } private void WebClient_DownloadProgressChanged(object sender, DownloadProgressChangedEventArgs eventArgs) { + if (this.UpgradeVersionDescription == null) + { + return; + } + this.UpgradeVersionDescription.InstallerDownloadProgress = eventArgs.ProgressPercentage; } } diff --git a/Application/FileConverter/Services/UpgradeVersionDescription.cs b/Application/FileConverter/Services/UpgradeVersionDescription.cs index 1094b4a3..76b25473 100644 --- a/Application/FileConverter/Services/UpgradeVersionDescription.cs +++ b/Application/FileConverter/Services/UpgradeVersionDescription.cs @@ -27,6 +27,20 @@ public string InstallerURL set; } + [XmlElement("SHA256")] + public string InstallerSha256 + { + get; + set; + } + + [XmlElement("PublisherSubject")] + public string InstallerPublisherSubject + { + get; + set; + } + [XmlIgnore] public string ChangeLog { @@ -79,6 +93,13 @@ public int InstallerDownloadProgress [XmlIgnore] public bool InstallerDownloadNotStarted => !this.InstallerDownloadInProgress && this.InstallerDownloadProgress == 0; + [XmlIgnore] + public bool InstallerIsVerified + { + get; + set; + } + [XmlIgnore] public bool NeedToUpgrade { @@ -86,4 +107,4 @@ public bool NeedToUpgrade set; } } -} \ No newline at end of file +} diff --git a/Application/FileConverter/Settings.cs b/Application/FileConverter/Settings.cs index 84e8f1b2..a59840f8 100644 --- a/Application/FileConverter/Settings.cs +++ b/Application/FileConverter/Settings.cs @@ -14,6 +14,7 @@ namespace FileConverter public class Settings : ObservableObject, IXmlSerializable { public const int Version = 4; + private const int MaximumAllowedSimultaneousConversions = 16; private bool exitApplicationWhenConversionsFinished = false; private float durationBetweenEndOfConversionsAndApplicationExit = 3f; @@ -26,13 +27,19 @@ public class Settings : ObservableObject, IXmlSerializable public ConversionPreset GetPresetFromName(string presetName) { - return this.conversionPresets.FirstOrDefault(match => match.FullName == presetName); + return this.conversionPresets.FirstOrDefault(match => match != null && match.FullName == presetName); } public void Clean() { - for (int index = 0; index < this.ConversionPresets.Count; index++) + for (int index = this.ConversionPresets.Count - 1; index >= 0; index--) { + if (this.ConversionPresets[index] == null) + { + this.ConversionPresets.RemoveAt(index); + continue; + } + this.ConversionPresets[index].Clean(); } } @@ -47,7 +54,12 @@ public Settings Merge(Settings settings) for (int index = 0; index < settings.conversionPresets.Count; index++) { ConversionPreset conversionPreset = settings.conversionPresets[index]; - if (this.conversionPresets.Any(match => match.FullName == conversionPreset.FullName)) + if (conversionPreset == null) + { + continue; + } + + if (this.conversionPresets.Any(match => match != null && match.FullName == conversionPreset.FullName)) { continue; } @@ -112,7 +124,15 @@ public string ApplicationLanguageName return; } - this.ApplicationLanguage = CultureInfo.GetCultureInfo(value); + try + { + this.ApplicationLanguage = CultureInfo.GetCultureInfo(value); + } + catch (CultureNotFoundException) + { + Diagnostics.Debug.Log($"Unsupported application language '{value}'. Fallback to default culture."); + this.ApplicationLanguage = null; + } } } @@ -186,8 +206,18 @@ public ConversionPreset[] SerializableConversionPresets set { + if (value == null) + { + return; + } + for (int index = 0; index < value.Length; index++) { + if (value[index] == null) + { + continue; + } + this.ConversionPresets.Add(value[index]); } } @@ -221,8 +251,8 @@ public bool CopyFilesInClipboardAfterConversion this.copyFilesInClipboardAfterConversion = value; this.OnPropertyChanged(); } - } - + } + [XmlElement] public Helpers.HardwareAccelerationMode HardwareAccelerationMode { @@ -240,9 +270,18 @@ public Helpers.HardwareAccelerationMode HardwareAccelerationMode public void OnDeserializationComplete() { this.DurationBetweenEndOfConversionsAndApplicationExit = System.Math.Max(0, System.Math.Min(10, this.DurationBetweenEndOfConversionsAndApplicationExit)); + this.MaximumNumberOfSimultaneousConversions = System.Math.Max( + 0, + System.Math.Min(MaximumAllowedSimultaneousConversions, this.MaximumNumberOfSimultaneousConversions)); - for (int index = 0; index < this.ConversionPresets.Count; index++) + for (int index = this.ConversionPresets.Count - 1; index >= 0; index--) { + if (this.ConversionPresets[index] == null) + { + this.ConversionPresets.RemoveAt(index); + continue; + } + this.ConversionPresets[index].OnDeserializationComplete(); } diff --git a/Application/FileConverter/ValueConverters/ApplicationVersionToApplicationName.cs b/Application/FileConverter/ValueConverters/ApplicationVersionToApplicationName.cs index cc93d8c0..71d709e5 100644 --- a/Application/FileConverter/ValueConverters/ApplicationVersionToApplicationName.cs +++ b/Application/FileConverter/ValueConverters/ApplicationVersionToApplicationName.cs @@ -12,12 +12,12 @@ public object Convert(object value, Type targetType, object parameter, CultureIn { if (!(value is FileConverter.Version)) { - return "File Converter"; + return "ZFileConverter"; } FileConverter.Version version = (FileConverter.Version)value; - return $"File Converter v{version}"; + return $"ZFileConverter v{version}"; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) diff --git a/Application/FileConverter/ValueConverters/FileNameConverter.cs b/Application/FileConverter/ValueConverters/FileNameConverter.cs index d8cb0a87..12a458d2 100644 --- a/Application/FileConverter/ValueConverters/FileNameConverter.cs +++ b/Application/FileConverter/ValueConverters/FileNameConverter.cs @@ -10,9 +10,9 @@ public class FileNameConverter : IMultiValueConverter { public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { - if (values == null || values.Length != 3) + if (values == null || values.Length < 3 || values.Length > 5) { - throw new ArgumentException("The values must contains the input file path, the output file extension and the ouput file template."); + throw new ArgumentException("The values must contain the input file path, the output file extension, the output file template and optional preset names."); } if (!(values[1] is OutputType)) @@ -23,8 +23,10 @@ public object Convert(object[] values, Type targetType, object parameter, Cultur string inputFilePath = values[0] as string; OutputType outputFileExtension = (OutputType)values[1]; string outputFileTemplate = values[2] as string; + string presetName = values.Length > 3 ? values[3] as string : null; + string presetFullName = values.Length > 4 ? values[4] as string : presetName; - return PathHelpers.GenerateFilePathFromTemplate(inputFilePath, outputFileExtension, outputFileTemplate, 1, 3); + return PathHelpers.GenerateFilePathFromTemplate(inputFilePath, outputFileExtension, outputFileTemplate, 1, 3, presetName, presetFullName); } public object[] ConvertBack(object value, Type[] targetType, object parameter, CultureInfo culture) diff --git a/Application/FileConverter/ViewModels/DependencyStatusViewModel.cs b/Application/FileConverter/ViewModels/DependencyStatusViewModel.cs new file mode 100644 index 00000000..122e8f18 --- /dev/null +++ b/Application/FileConverter/ViewModels/DependencyStatusViewModel.cs @@ -0,0 +1,46 @@ +// License: http://www.gnu.org/licenses/gpl.html GPL version 3. + +namespace FileConverter.ViewModels +{ + using CommunityToolkit.Mvvm.ComponentModel; + + public class DependencyStatusViewModel : ObservableObject + { + private string name; + private string status; + private string details; + private bool isHealthy; + + public DependencyStatusViewModel(string name, string status, string details, bool isHealthy) + { + this.Name = name; + this.Status = status; + this.Details = details; + this.IsHealthy = isHealthy; + } + + public string Name + { + get => this.name; + private set => this.SetProperty(ref this.name, value); + } + + public string Status + { + get => this.status; + private set => this.SetProperty(ref this.status, value); + } + + public string Details + { + get => this.details; + private set => this.SetProperty(ref this.details, value); + } + + public bool IsHealthy + { + get => this.isHealthy; + private set => this.SetProperty(ref this.isHealthy, value); + } + } +} diff --git a/Application/FileConverter/ViewModels/DiagnosticsViewModel.cs b/Application/FileConverter/ViewModels/DiagnosticsViewModel.cs index c6552a00..e1022f38 100644 --- a/Application/FileConverter/ViewModels/DiagnosticsViewModel.cs +++ b/Application/FileConverter/ViewModels/DiagnosticsViewModel.cs @@ -3,6 +3,9 @@ namespace FileConverter.ViewModels { using System.ComponentModel; + using System.Diagnostics; + using System.IO; + using System.Windows; using System.Windows.Input; using CommunityToolkit.Mvvm.ComponentModel; @@ -16,7 +19,9 @@ namespace FileConverter.ViewModels /// public class DiagnosticsViewModel : ObservableRecipient { + private RelayCommand copyDiagnosticsCommand; private RelayCommand closeCommand; + private RelayCommand openDiagnosticsFolderCommand; /// /// Initializes a new instance of the DiagnosticsViewModel class. @@ -38,10 +43,67 @@ public ICommand CloseCommand } } + public ICommand CopyDiagnosticsCommand + { + get + { + if (this.copyDiagnosticsCommand == null) + { + this.copyDiagnosticsCommand = new RelayCommand(this.CopyDiagnostics); + } + + return this.copyDiagnosticsCommand; + } + } + + public ICommand OpenDiagnosticsFolderCommand + { + get + { + if (this.openDiagnosticsFolderCommand == null) + { + this.openDiagnosticsFolderCommand = new RelayCommand(this.OpenDiagnosticsFolder); + } + + return this.openDiagnosticsFolderCommand; + } + } + private void Close(CancelEventArgs args) { INavigationService navigationService = Ioc.Default.GetRequiredService(); navigationService.Close(Pages.Diagnostics, args != null); } + + private void CopyDiagnostics() + { + try + { + Clipboard.SetText(Diagnostics.Debug.AllContent); + } + catch (System.Exception exception) + { + Diagnostics.Debug.Log($"Can't copy diagnostics to clipboard: {exception.Message}."); + } + } + + private void OpenDiagnosticsFolder() + { + string diagnosticsFolderPath = Diagnostics.Debug.DiagnosticsFolderPath; + if (string.IsNullOrEmpty(diagnosticsFolderPath) || !Directory.Exists(diagnosticsFolderPath)) + { + Diagnostics.Debug.Log($"Can't open diagnostics folder: {diagnosticsFolderPath}."); + return; + } + + try + { + Process.Start("explorer.exe", $"\"{diagnosticsFolderPath}\""); + } + catch (System.Exception exception) + { + Diagnostics.Debug.Log($"Can't open diagnostics folder: {exception.Message}."); + } + } } } diff --git a/Application/FileConverter/ViewModels/MainViewModel.cs b/Application/FileConverter/ViewModels/MainViewModel.cs index e50a632c..948a0580 100644 --- a/Application/FileConverter/ViewModels/MainViewModel.cs +++ b/Application/FileConverter/ViewModels/MainViewModel.cs @@ -18,6 +18,8 @@ namespace FileConverter.ViewModels /// public class MainViewModel : ObservableRecipient { + private readonly IConversionService conversionService; + private string informationMessage; private ObservableCollection conversionJobs; @@ -30,8 +32,9 @@ public class MainViewModel : ObservableRecipient /// public MainViewModel() { - IConversionService settingsService = Ioc.Default.GetRequiredService(); - this.ConversionJobs = new ObservableCollection(settingsService.ConversionJobs); + this.conversionService = Ioc.Default.GetRequiredService(); + this.conversionService.ConversionJobRegistered += this.ConversionService_ConversionJobRegistered; + this.ConversionJobs = new ObservableCollection(this.conversionService.ConversionJobs); Application application = Application.Current as Application; application.OnApplicationTerminate += this.Application_OnApplicationTerminate; @@ -117,6 +120,21 @@ private void ConversionJob_PropertyChanged(object sender, PropertyChangedEventAr this.OnPropertyChanged(nameof(this.ConversionJobs)); } + private void ConversionService_ConversionJobRegistered(object sender, ConversionJobRegisteredEventArgs eventArgs) + { + if (eventArgs?.ConversionJob == null || this.ConversionJobs.Contains(eventArgs.ConversionJob)) + { + return; + } + + Application.Current.Dispatcher.Invoke(() => + { + this.ConversionJobs.Add(eventArgs.ConversionJob); + eventArgs.ConversionJob.PropertyChanged += this.ConversionJob_PropertyChanged; + this.OnPropertyChanged(nameof(this.ConversionJobs)); + }); + } + private void Application_OnApplicationTerminate(object sender, ApplicationTerminateArgs eventArgs) { if (float.IsNaN(eventArgs.RemainingTimeBeforeTermination)) @@ -142,4 +160,4 @@ private void Application_OnApplicationTerminate(object sender, ApplicationTermin } } } -} \ No newline at end of file +} diff --git a/Application/FileConverter/ViewModels/SettingsViewModel.cs b/Application/FileConverter/ViewModels/SettingsViewModel.cs index 15d33c87..ad83221c 100644 --- a/Application/FileConverter/ViewModels/SettingsViewModel.cs +++ b/Application/FileConverter/ViewModels/SettingsViewModel.cs @@ -5,10 +5,14 @@ namespace FileConverter.ViewModels using System; using System.IO; using System.Collections.Generic; + using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.Linq; + using System.Reflection; + using System.Text; + using System.Windows; using System.Windows.Data; using System.Windows.Input; @@ -19,6 +23,7 @@ namespace FileConverter.ViewModels using CommunityToolkit.Mvvm.Input; using FileConverter.Annotations; + using FileConverter.ConversionJobs; using FileConverter.Services; using FileConverter.Views; @@ -42,9 +47,14 @@ public class SettingsViewModel : ObservableRecipient, IDataErrorInfo private RelayCommand importPresetCommand; private RelayCommand exportPresetCommand; private RelayCommand removePresetCommand; + private RelayCommand refreshDependencyHealthCommand; + private RelayCommand repairShellExtensionCommand; private RelayCommand saveCommand; private RelayCommand closeCommand; + private ObservableCollection dependencyStatuses = new ObservableCollection(); + private string shellExtensionRepairStatus = string.Empty; + private ListCollectionView outputTypes; private CultureInfo[] supportedCultures; private Helpers.HardwareAccelerationMode[] hardwareAccelerationModes = { Helpers.HardwareAccelerationMode.Off, Helpers.HardwareAccelerationMode.CUDA, Helpers.HardwareAccelerationMode.AMF }; @@ -58,13 +68,15 @@ public class SettingsViewModel : ObservableRecipient, IDataErrorInfo public SettingsViewModel() { this.getChangeLogContentCommand = new RelayCommand(this.DownloadChangeLogAction); - this.openUrlCommand = new RelayCommand((url) => Process.Start(url)); + this.openUrlCommand = new RelayCommand(this.OpenUrl); this.createFolderCommand = new RelayCommand(this.CreateFolder); this.newPresetCommand = new RelayCommand(() => this.AddNewPreset(false)); this.duplicatePresetCommand = new RelayCommand(() => this.AddNewPreset(true), this.CanDuplicateSelectedPreset); this.importPresetCommand = new RelayCommand(this.ImportPreset); this.exportPresetCommand = new RelayCommand(this.ExportSelectedPreset, this.CanExportSelectedPreset); this.removePresetCommand = new RelayCommand(this.RemoveSelectedPreset, this.CanRemoveSelectedPreset); + this.refreshDependencyHealthCommand = new RelayCommand(this.RefreshDependencyHealth); + this.repairShellExtensionCommand = new RelayCommand(this.RepairShellExtension); this.saveCommand = new RelayCommand(this.SaveSettings, this.CanSaveSettings); this.closeCommand = new RelayCommand(this.CloseSettings); @@ -84,6 +96,7 @@ public SettingsViewModel() outputTypeViewModels.Add(new OutputTypeViewModel(OutputType.Avi)); outputTypeViewModels.Add(new OutputTypeViewModel(OutputType.Png)); outputTypeViewModels.Add(new OutputTypeViewModel(OutputType.Jpg)); + outputTypeViewModels.Add(new OutputTypeViewModel(OutputType.Avif)); outputTypeViewModels.Add(new OutputTypeViewModel(OutputType.Webp)); outputTypeViewModels.Add(new OutputTypeViewModel(OutputType.Ico)); outputTypeViewModels.Add(new OutputTypeViewModel(OutputType.Gif)); @@ -95,6 +108,7 @@ public SettingsViewModel() this.InitializeCompatibleInputExtensions(); this.InitializePresetFolders(); + this.RefreshDependencyHealth(); } public IEnumerable InputCategories @@ -233,14 +247,14 @@ public CultureInfo[] SupportedCultures } } - public Helpers.HardwareAccelerationMode[] HardwareAccelerationModes - { - get => this.hardwareAccelerationModes; - set - { - this.hardwareAccelerationModes = value; - this.OnPropertyChanged(); - } + public Helpers.HardwareAccelerationMode[] HardwareAccelerationModes + { + get => this.hardwareAccelerationModes; + set + { + this.hardwareAccelerationModes = value; + this.OnPropertyChanged(); + } } public ListCollectionView OutputTypes @@ -284,10 +298,36 @@ private set public ICommand RemoveSelectedPresetCommand => this.removePresetCommand; + public ICommand RefreshDependencyHealthCommand => this.refreshDependencyHealthCommand; + + public ICommand RepairShellExtensionCommand => this.repairShellExtensionCommand; + public ICommand SaveCommand => this.saveCommand; public ICommand CloseCommand => this.closeCommand; + public ObservableCollection DependencyStatuses + { + get => this.dependencyStatuses; + + private set + { + this.dependencyStatuses = value; + this.OnPropertyChanged(); + } + } + + public string ShellExtensionRepairStatus + { + get => this.shellExtensionRepairStatus; + + private set + { + this.shellExtensionRepairStatus = value; + this.OnPropertyChanged(); + } + } + public TreeViewSelectionBehavior.IsChildOfPredicate PresetsHierarchyPredicate => (object nodeA, object nodeB) => { if (nodeA is PresetNode) @@ -387,6 +427,161 @@ private void DownloadChangeLogAction() this.DisplaySeeChangeLogLink = false; } + private void OpenUrl(string url) + { + if (string.IsNullOrEmpty(url)) + { + return; + } + + try + { + Process.Start(url); + } + catch (Exception exception) + { + Diagnostics.Debug.Log($"Failed to open URL '{url}': {exception.Message}."); + } + } + + private void RefreshDependencyHealth() + { + ObservableCollection statuses = new ObservableCollection(); + + string shellExtensionPath = Helpers.GetDefaultShellExtensionPath(); + string defaultSettingsPath = FileConverterExtension.PathHelpers.DefaultSettingsFilePath; + string userSettingsPath = FileConverterExtension.PathHelpers.UserSettingsFilePath; + + this.AddFileStatus(statuses, "FFmpeg", GetApplicationFilePath("ffmpeg.exe"), "Required for audio and video conversions."); + this.AddFileStatus(statuses, "ImageMagick", GetApplicationFilePath("Magick.NET-Q16-AnyCPU.dll"), "Required for image, AVIF, PDF image, and WebP workflows."); + this.AddFileStatus(statuses, "ImageMagick native", GetApplicationFilePath("Magick.Native-Q16-x64.dll"), "Required native image processing runtime."); + this.AddFileStatus(statuses, "Ghostscript", GetApplicationFilePath("gswin64c.exe"), "Required for PDF rendering."); + this.AddFileStatus(statuses, "Ghostscript DLL", GetApplicationFilePath("gsdll64.dll"), "Required by ImageMagick PDF rendering."); + this.AddFileStatus(statuses, "Explorer extension DLL", shellExtensionPath, "Required for Windows Explorer right-click commands."); + this.AddFileStatus(statuses, "Default presets", defaultSettingsPath, "Required when creating or repairing user settings."); + + if (File.Exists(userSettingsPath)) + { + statuses.Add(new DependencyStatusViewModel("User settings", "Ready", userSettingsPath, true)); + } + else + { + statuses.Add(new DependencyStatusViewModel("User settings", "Will be created", userSettingsPath, true)); + } + + this.AddOfficeStatus(statuses, "Microsoft Word", ConversionJob_Office.ApplicationName.Word, "Required for Word document conversion."); + this.AddOfficeStatus(statuses, "Microsoft Excel", ConversionJob_Office.ApplicationName.Excel, "Required for spreadsheet conversion."); + this.AddOfficeStatus(statuses, "Microsoft PowerPoint", ConversionJob_Office.ApplicationName.PowerPoint, "Required for presentation conversion."); + this.AddShellRegistrationStatus(statuses); + + this.DependencyStatuses = statuses; + } + + private void RepairShellExtension() + { + string shellExtensionPath = Helpers.GetDefaultShellExtensionPath(); + if (!File.Exists(shellExtensionPath)) + { + this.ShellExtensionRepairStatus = $"Can't repair Explorer integration because {shellExtensionPath} is missing."; + this.RefreshDependencyHealth(); + return; + } + + string executablePath = Assembly.GetExecutingAssembly().Location; + ProcessStartInfo startInfo = new ProcessStartInfo(executablePath) + { + Arguments = $"--repair-shell-extension {QuoteArgument(shellExtensionPath)}", + UseShellExecute = true, + Verb = "runas", + }; + + try + { + Process.Start(startInfo); + this.ShellExtensionRepairStatus = "Repair launched with administrator privileges. Reopen Explorer or retry the context menu after it finishes."; + } + catch (Win32Exception exception) + { + if (exception.NativeErrorCode == 1223) + { + this.ShellExtensionRepairStatus = "Repair canceled by user."; + } + else + { + this.ShellExtensionRepairStatus = $"Repair failed to start: {exception.Message}"; + } + } + catch (Exception exception) + { + this.ShellExtensionRepairStatus = $"Repair failed to start: {exception.Message}"; + } + + this.RefreshDependencyHealth(); + } + + private void AddFileStatus(ObservableCollection statuses, string name, string path, string purpose) + { + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + { + statuses.Add(new DependencyStatusViewModel(name, "Ready", $"{purpose} Found at {path}", true)); + } + else + { + statuses.Add(new DependencyStatusViewModel(name, "Missing", $"{purpose} Expected at {path ?? "(unknown path)"}", false)); + } + } + + private void AddOfficeStatus(ObservableCollection statuses, string name, ConversionJob_Office.ApplicationName applicationName, string purpose) + { + bool isAvailable = Helpers.IsMicrosoftOfficeApplicationAvailable(applicationName); + statuses.Add(new DependencyStatusViewModel( + name, + isAvailable ? "Available" : "Optional missing", + purpose, + true)); + } + + private void AddShellRegistrationStatus(ObservableCollection statuses) + { + string registeredPath = FileConverterExtension.PathHelpers.FileConverterPath; + string executablePath = Assembly.GetExecutingAssembly().Location; + + if (string.IsNullOrEmpty(registeredPath)) + { + statuses.Add(new DependencyStatusViewModel("Explorer registration", "Needs repair", "No executable path is registered in HKCU\\Software\\FileConverter.", false)); + return; + } + + if (!File.Exists(registeredPath)) + { + statuses.Add(new DependencyStatusViewModel("Explorer registration", "Needs repair", $"Registered executable is missing: {registeredPath}", false)); + return; + } + + bool matchesCurrentExecutable = string.Equals(registeredPath, executablePath, StringComparison.OrdinalIgnoreCase); + statuses.Add(new DependencyStatusViewModel( + "Explorer registration", + matchesCurrentExecutable ? "Ready" : "Different install", + matchesCurrentExecutable ? registeredPath : $"Registered path: {registeredPath}; current path: {executablePath}", + matchesCurrentExecutable)); + } + + private static string QuoteArgument(string value) + { + return $"\"{value.Replace("\"", "\\\"")}\""; + } + + private static string GetApplicationFilePath(string fileName) + { + string applicationFolder = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + if (string.IsNullOrEmpty(applicationFolder)) + { + return fileName; + } + + return Path.Combine(applicationFolder, fileName); + } + private void InitializeCompatibleInputExtensions() { List categories = new List(); @@ -526,7 +721,7 @@ private void CreateFolder() this.saveCommand.NotifyCanExecuteChanged(); - this.OnFolderCreated(); + this.OnFolderCreated?.Invoke(); } private bool CanDuplicateSelectedPreset() @@ -584,7 +779,7 @@ private void AddNewPreset(bool duplicate) this.SelectedItem = node; - this.OnPresetCreated.Invoke(); + this.OnPresetCreated?.Invoke(); this.removePresetCommand.NotifyCanExecuteChanged(); this.saveCommand.NotifyCanExecuteChanged(); @@ -604,6 +799,7 @@ private void ImportPreset() if (!File.Exists(openFileDialog.FileName)) { Diagnostics.Debug.LogError("File does not exists."); + return; } string directoryPath = Path.GetDirectoryName(openFileDialog.FileName); @@ -613,7 +809,20 @@ private void ImportPreset() } List presetsToImport = new List(); - XmlHelpers.LoadFromFile("Presets", openFileDialog.FileName, out presetsToImport); + try + { + XmlHelpers.LoadFromFile("Presets", openFileDialog.FileName, out presetsToImport); + } + catch (Exception exception) + { + Diagnostics.Debug.LogError($"Failed to import presets. {exception.Message}"); + return; + } + + if (!this.ReviewImportedPresets(presetsToImport)) + { + return; + } // Add imported preset to preset tree. bool itemSelected = false; @@ -644,7 +853,223 @@ private void ImportPreset() itemSelected = true; } } + + this.saveCommand.NotifyCanExecuteChanged(); + } + } + + private bool ReviewImportedPresets(List presetsToImport) + { + if (presetsToImport == null || presetsToImport.Count == 0) + { + return true; + } + + List warnings = new List(); + foreach (ConversionPreset conversionPreset in presetsToImport) + { + warnings.AddRange(this.GetPresetImportWarnings(conversionPreset)); + } + + if (warnings.Count == 0) + { + return true; + } + + StringBuilder message = new StringBuilder(); + message.AppendLine("This preset file contains advanced settings that can affect conversion commands or output locations."); + message.AppendLine(); + message.AppendLine("Choose Yes to import with risky settings disabled, No to import as-is only if you trust this file, or Cancel to stop importing."); + message.AppendLine(); + + int warningsToDisplay = Math.Min(warnings.Count, 8); + for (int index = 0; index < warningsToDisplay; index++) + { + message.AppendLine("- " + warnings[index]); + } + + if (warnings.Count > warningsToDisplay) + { + message.AppendLine($"- {warnings.Count - warningsToDisplay} more warning(s)."); + } + + MessageBoxResult result = MessageBox.Show( + message.ToString(), + "Review imported presets", + MessageBoxButton.YesNoCancel, + MessageBoxImage.Warning, + MessageBoxResult.Yes); + + if (result == MessageBoxResult.Cancel) + { + return false; + } + + if (result == MessageBoxResult.Yes) + { + foreach (ConversionPreset conversionPreset in presetsToImport) + { + this.NeutralizeRiskyImportedPresetSettings(conversionPreset); + } + } + + return true; + } + + private IEnumerable GetPresetImportWarnings(ConversionPreset conversionPreset) + { + if (conversionPreset == null) + { + yield break; + } + + string presetName = string.IsNullOrWhiteSpace(conversionPreset.FullName) ? "Unnamed preset" : conversionPreset.FullName; + + if (this.HasUnsafePresetName(conversionPreset)) + { + yield return $"Preset '{presetName}' has a name that is unsafe for Explorer launch or folder creation."; + } + + if (this.HasEnabledCustomFFmpegCommand(conversionPreset)) + { + yield return $"Preset '{presetName}' enables a raw FFmpeg command."; + } + + if (this.HasSuspiciousOutputTemplate(conversionPreset)) + { + yield return $"Preset '{presetName}' writes to a non-standard output location."; + } + } + + private void NeutralizeRiskyImportedPresetSettings(ConversionPreset conversionPreset) + { + if (conversionPreset == null) + { + return; + } + + if (this.HasUnsafePresetName(conversionPreset)) + { + conversionPreset.FullName = this.SanitizePresetFullName(conversionPreset.FullName); + } + + if (this.HasEnabledCustomFFmpegCommand(conversionPreset)) + { + conversionPreset.SetSettingsValue(ConversionPreset.ConversionSettingKeys.EnableFFMPEGCustomCommand, "False"); + conversionPreset.SetSettingsValue(ConversionPreset.ConversionSettingKeys.FFMPEGCustomCommand, string.Empty); + } + + if (this.HasSuspiciousOutputTemplate(conversionPreset)) + { + conversionPreset.OutputFileNameTemplate = "(p)(f)"; + } + } + + private bool HasEnabledCustomFFmpegCommand(ConversionPreset conversionPreset) + { + bool customCommandEnabled; + bool.TryParse( + conversionPreset.GetSettingsValue(ConversionPreset.ConversionSettingKeys.EnableFFMPEGCustomCommand), + out customCommandEnabled); + + return customCommandEnabled && + !string.IsNullOrWhiteSpace(conversionPreset.GetSettingsValue(ConversionPreset.ConversionSettingKeys.FFMPEGCustomCommand)); + } + + private bool HasSuspiciousOutputTemplate(ConversionPreset conversionPreset) + { + string template = conversionPreset.OutputFileNameTemplate; + if (string.IsNullOrWhiteSpace(template)) + { + return false; + } + + if (Path.IsPathRooted(template)) + { + return true; + } + + string[] segments = template.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); + for (int index = 0; index < segments.Length; index++) + { + if (segments[index] == "." || segments[index] == "..") + { + return true; + } + } + + string lowerTemplate = template.ToLowerInvariant(); + return + lowerTemplate.Contains("(p:d)") || + lowerTemplate.Contains("(p:documents)") || + lowerTemplate.Contains("(p:m)") || + lowerTemplate.Contains("(p:music)") || + lowerTemplate.Contains("(p:v)") || + lowerTemplate.Contains("(p:videos)") || + lowerTemplate.Contains("(p:p)") || + lowerTemplate.Contains("(p:pictures)"); + } + + private bool HasUnsafePresetName(ConversionPreset conversionPreset) + { + return this.SanitizePresetFullName(conversionPreset.FullName) != conversionPreset.FullName; + } + + private string SanitizePresetFullName(string fullName) + { + if (string.IsNullOrWhiteSpace(fullName)) + { + return "Imported preset"; + } + + string[] segments = fullName.Split('/'); + for (int index = 0; index < segments.Length; index++) + { + segments[index] = this.SanitizePresetNameSegment(segments[index], index == segments.Length - 1); } + + return string.Join("/", segments); + } + + private string SanitizePresetNameSegment(string segment, bool isPresetName) + { + if (string.IsNullOrWhiteSpace(segment)) + { + return isPresetName ? "Imported preset" : "Imported"; + } + + char[] invalidFileNameChars = Path.GetInvalidFileNameChars(); + StringBuilder builder = new StringBuilder(segment.Length); + for (int index = 0; index < segment.Length; index++) + { + char character = segment[index]; + if (char.IsControl(character) || + character == '"' || + character == '/' || + character == '\\' || + Array.IndexOf(invalidFileNameChars, character) >= 0) + { + builder.Append('_'); + continue; + } + + builder.Append(character); + } + + string sanitizedSegment = builder.ToString().Trim(); + if (string.IsNullOrEmpty(sanitizedSegment) || + sanitizedSegment == "." || + sanitizedSegment == "..") + { + return isPresetName ? "Imported preset" : "Imported"; + } + + if (sanitizedSegment.StartsWith("-", StringComparison.Ordinal)) + { + sanitizedSegment = "_" + sanitizedSegment; + } + + return sanitizedSegment; } private void ExportSelectedPreset() @@ -699,7 +1124,7 @@ private void RemoveSelectedPreset() private bool CanRemoveSelectedPreset() { - return this.SelectedItem != null; + return this.SelectedItem != null && this.SelectedItem.Parent != null; } protected override void OnDeactivated() diff --git a/Application/FileConverter/Views/DiagnosticsWindow.xaml b/Application/FileConverter/Views/DiagnosticsWindow.xaml index d0eb5eba..f5661838 100644 --- a/Application/FileConverter/Views/DiagnosticsWindow.xaml +++ b/Application/FileConverter/Views/DiagnosticsWindow.xaml @@ -28,10 +28,14 @@ + +