Browse Source

Refactor file associations #157

Ruben 7 months ago
parent
commit
c1d7ee2ae6

+ 1 - 1
src/PicView.Avalonia/StartUp/StartUpHelper.cs

@@ -50,7 +50,7 @@ public static class StartUpHelper
                         {
                             vm.PlatformService.InitiateFileAssociationService();
                             Debug.WriteLine($"Processing file association argument: {arg}");
-                            await FileTypeHelper.ProcessFileAssociationArguments(arg);
+                            await FileAssociationProcessor.ProcessFileAssociationArguments(arg);
 
                             // Exit if this was just a file association command
                             // and no other files were specified to be opened

+ 266 - 0
src/PicView.Core/FileAssociations/FileAssociationProcessor.cs

@@ -0,0 +1,266 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using PicView.Core.FileHandling;
+using PicView.Core.ProcessHandling;
+
+namespace PicView.Core.FileAssociations;
+
+/// <summary>
+/// Handles the processing of file association operations, including elevation for Windows and handling command-line arguments.
+/// </summary>
+public static class FileAssociationProcessor
+{
+    /// <summary>
+    /// Sets file associations for a collection of file type groups.
+    /// On Windows, elevates permissions if necessary.
+    /// </summary>
+    /// <param name="groups">Collection of file type groups to process</param>
+    /// <returns>True if successful, false otherwise</returns>
+    public static async Task<bool> SetFileAssociations(ReadOnlyObservableCollection<FileTypeGroup> groups)
+    {
+        try
+        {
+            // If we're on Windows, check for admin permissions
+            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !IsAdministrator())
+            {
+                return await HandleNonAdminWindowsAssociations(groups);
+            }
+            
+            // Standard processing path (non-Windows or already has admin rights)
+            return await HandleDirectAssociations(groups);
+        }
+        catch (Exception ex)
+        {
+            // Log the exception or handle it appropriately
+            Debug.WriteLine($"Error in SetFileAssociations: {ex}");
+            return false;
+        }
+    }
+
+    /// <summary>
+    /// Processes command-line arguments for file associations.
+    /// </summary>
+    /// <param name="arg">The command-line argument to process</param>
+    public static async Task ProcessFileAssociationArguments(string arg)
+    {
+        try
+        {
+            if (arg.StartsWith("associate:", StringComparison.OrdinalIgnoreCase))
+            {
+                await ProcessAssociationArgument(arg);
+            }
+            else if (arg.StartsWith("unassociate:", StringComparison.OrdinalIgnoreCase))
+            {
+                await ProcessUnassociationArgument(arg);
+            }
+        }
+        catch (Exception ex)
+        {
+            Debug.WriteLine($"Error processing file association arguments: {ex.Message}");
+            Debug.WriteLine($"Argument was: {arg}");
+            Debug.WriteLine($"Stack trace: {ex.StackTrace}");
+        }
+    }
+
+    #region Private Helper Methods
+
+    private static async Task<bool> HandleNonAdminWindowsAssociations(ReadOnlyObservableCollection<FileTypeGroup> groups)
+    {
+        // Build list of extensions to associate with descriptions
+        var extensionsToAssociate = new List<string>();
+        var extensionsToUnassociate = new List<string>();
+
+        foreach (var group in groups)
+        {
+            foreach (var fileType in group.FileTypes)
+            {
+                if (!fileType.IsSelected.HasValue)
+                {
+                    continue; // Skip null selections
+                }
+
+                foreach (var extension in fileType.Extensions)
+                {
+                    // Make sure to properly handle extensions that contain commas
+                    var individualExtensions =
+                        extension.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries);
+
+                    foreach (var ext in individualExtensions)
+                    {
+                        var cleanExt = ext.Trim();
+                        if (!cleanExt.StartsWith('.'))
+                        {
+                            cleanExt = "." + cleanExt;
+                        }
+
+                        if (fileType.IsSelected.Value)
+                        {
+                            // Add to association list
+                            extensionsToAssociate.Add($"{cleanExt}|{fileType.Description}");
+                        }
+                        else
+                        {
+                            // Add to unassociation list
+                            extensionsToUnassociate.Add($"{cleanExt}");
+                        }
+                    }
+                }
+            }
+        }
+
+        // Build arguments for the elevated process
+        var args = new List<string>();
+
+        if (extensionsToAssociate.Count > 0)
+        {
+            // Create command arguments for associations
+            args.Add("associate:" + string.Join(";", extensionsToAssociate));
+        }
+
+        if (extensionsToUnassociate.Count > 0)
+        {
+            // Create command arguments for unassociations
+            args.Add("unassociate:" + string.Join(";", extensionsToUnassociate));
+        }
+
+        if (args.Count == 0)
+        {
+            return true; // Nothing to do
+        }
+
+        // Start new process with elevated permissions
+        return await ProcessHelper.StartProcessWithElevatedPermissionAsync(string.Join(" ", args));
+    }
+
+    private static async Task<bool> HandleDirectAssociations(ReadOnlyObservableCollection<FileTypeGroup> groups)
+    {
+        foreach (var group in groups)
+        {
+            foreach (var fileType in group.FileTypes)
+            {
+                if (!fileType.IsSelected.HasValue)
+                {
+                    continue;
+                }
+
+                foreach (var extension in fileType.Extensions)
+                {
+                    var individualExtensions = extension.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries);
+
+                    foreach (var ext in individualExtensions)
+                    {
+                        var cleanExt = ext.Trim();
+                        if (!cleanExt.StartsWith('.'))
+                        {
+                            cleanExt = "." + cleanExt;
+                        }
+
+                        if (fileType.IsSelected.Value)
+                        {
+                            await FileAssociationManager.AssociateFile(cleanExt, fileType.Description);
+                        }
+                        else
+                        {
+                            await FileAssociationManager.UnassociateFile(cleanExt);
+                        }
+                    }
+                }
+            }
+        }
+
+        return true;
+    }
+
+    private static async Task ProcessAssociationArgument(string arg)
+    {
+        var extensionsString = arg["associate:".Length..];
+        if (string.IsNullOrWhiteSpace(extensionsString))
+        {
+            Debug.WriteLine("No extensions to associate found in arguments.");
+            return;
+        }
+
+        // Split by semicolons for different extensions
+        var extensions = extensionsString
+            .Split(';', StringSplitOptions.RemoveEmptyEntries)
+            .Select(ext => ext.Trim())
+            .ToArray();
+
+        Debug.WriteLine($"Found {extensions.Length} extensions to associate");
+
+        foreach (var extension in extensions)
+        {
+            try
+            {
+                // Each extension may have a description after a pipe |
+                var parts = extension.Split('|', 2);
+                var ext = parts[0].Trim();
+
+                // Get description if available
+                string? description = null;
+                if (parts.Length > 1)
+                {
+                    description = parts[1].Trim();
+                }
+
+                Debug.WriteLine($"Associating {ext} with description '{description}'");
+                await FileAssociationManager.AssociateFile(ext, description);
+            }
+            catch (Exception extEx)
+            {
+                Debug.WriteLine($"Error processing extension '{extension}': {extEx.Message}");
+            }
+        }
+    }
+
+    private static async Task ProcessUnassociationArgument(string arg)
+    {
+        var extensionsString = arg["unassociate:".Length..];
+        if (string.IsNullOrWhiteSpace(extensionsString))
+        {
+            Debug.WriteLine("No extensions to unassociate found in arguments.");
+            return;
+        }
+
+        // Split by semicolons for different extensions
+        var extensions = extensionsString
+            .Split(';', StringSplitOptions.RemoveEmptyEntries)
+            .Select(ext => ext.Trim())
+            .ToArray();
+
+        Debug.WriteLine($"Found {extensions.Length} extensions to unassociate");
+
+        foreach (var extension in extensions)
+        {
+            try
+            {
+                // For unassociate, we just need the extension (ignore any description)
+                var ext = extension.Split('|')[0].Trim();
+
+                Debug.WriteLine($"Unassociating {ext}");
+                await FileAssociationManager.UnassociateFile(ext);
+            }
+            catch (Exception extEx)
+            {
+                Debug.WriteLine($"Error unassociating extension '{extension}': {extEx.Message}");
+            }
+        }
+    }
+
+    private static bool IsAdministrator()
+    {
+        if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+        {
+            return false;
+        }
+
+        // Check if running as administrator
+        using var identity = WindowsIdentity.GetCurrent();
+        var principal = new WindowsPrincipal(identity);
+        return principal.IsInRole(WindowsBuiltInRole.Administrator);
+    }
+    
+    #endregion
+}

+ 1 - 228
src/PicView.Core/FileAssociations/FileTypeHelper.cs

@@ -1,10 +1,4 @@
-using System.Collections.ObjectModel;
-using System.Diagnostics;
-using System.Runtime.InteropServices;
-using System.Security.Principal;
-using PicView.Core.FileHandling;
-using PicView.Core.Localization;
-using PicView.Core.ProcessHandling;
+using PicView.Core.Localization;
 
 namespace PicView.Core.FileAssociations;
 
@@ -88,225 +82,4 @@ public static class FileTypeHelper
 
         return groups;
     }
-
-
-    public static async Task<bool> SetFileAssociations(ReadOnlyObservableCollection<FileTypeGroup> groups)
-    {
-        try
-        {
-            // If we're on Windows, check for admin permissions
-            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !IsAdministrator())
-            {
-                // Build list of extensions to associate with descriptions
-                var extensionsToAssociate = new List<string>();
-                var extensionsToUnassociate = new List<string>();
-
-                foreach (var group in groups)
-                {
-                    foreach (var fileType in group.FileTypes)
-                    {
-                        if (!fileType.IsSelected.HasValue)
-                        {
-                            continue; // Skip null selections
-                        }
-
-                        foreach (var extension in fileType.Extensions)
-                        {
-                            // Make sure to properly handle extensions that contain commas
-                            var individualExtensions =
-                                extension.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries);
-
-                            foreach (var ext in individualExtensions)
-                            {
-                                var cleanExt = ext.Trim();
-                                if (!cleanExt.StartsWith('.'))
-                                {
-                                    cleanExt = "." + cleanExt;
-                                }
-
-                                if (fileType.IsSelected.Value)
-                                {
-                                    // Add to association list
-                                    extensionsToAssociate.Add($"{cleanExt}|{fileType.Description}");
-                                }
-                                else
-                                {
-                                    // Add to unassociation list
-                                    extensionsToUnassociate.Add($"{cleanExt}");
-                                }
-                            }
-                        }
-                    }
-                }
-
-                // Build arguments for the elevated process
-                var args = new List<string>();
-
-                if (extensionsToAssociate.Count > 0)
-                {
-                    // Create command arguments for associations
-                    args.Add("associate:" + string.Join(";", extensionsToAssociate));
-                }
-
-                if (extensionsToUnassociate.Count > 0)
-                {
-                    // Create command arguments for unassociations
-                    args.Add("unassociate:" + string.Join(";", extensionsToUnassociate));
-                }
-
-                if (args.Count == 0)
-                {
-                    return true; // Nothing to do
-                }
-
-                // Start new process with elevated permissions
-                return ProcessHelper.StartProcessWithElevatedPermission(string.Join(" ", args));
-            }
-
-            // Standard processing path (non-Windows or already has admin rights)
-            foreach (var group in groups)
-            {
-                foreach (var fileType in group.FileTypes)
-                {
-                    if (!fileType.IsSelected.HasValue)
-                    {
-                        continue;
-                    }
-
-                    foreach (var extension in fileType.Extensions)
-                    {
-                        var individualExtensions = extension.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries);
-
-                        foreach (var ext in individualExtensions)
-                        {
-                            var cleanExt = ext.Trim();
-                            if (!cleanExt.StartsWith('.'))
-                            {
-                                cleanExt = "." + cleanExt;
-                            }
-
-                            if (fileType.IsSelected.Value)
-                            {
-                                await FileAssociationManager.AssociateFile(cleanExt, fileType.Description);
-                            }
-                            else
-                            {
-                                await FileAssociationManager.UnassociateFile(cleanExt);
-                            }
-                        }
-                    }
-                }
-            }
-
-            return true;
-        }
-        catch (Exception ex)
-        {
-            // Log the exception or handle it appropriately
-            Debug.WriteLine($"Error in SetFileAssociations: {ex}");
-            return false;
-        }
-    }
-
-    private static bool IsAdministrator()
-    {
-        if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
-        {
-            return false;
-        }
-
-        // Check if running as administrator
-        using var identity = WindowsIdentity.GetCurrent();
-        var principal = new WindowsPrincipal(identity);
-        return principal.IsInRole(WindowsBuiltInRole.Administrator);
-    }
-
-    public static async Task ProcessFileAssociationArguments(string arg)
-    {
-        try
-        {
-            if (arg.StartsWith("associate:", StringComparison.OrdinalIgnoreCase))
-            {
-                // Process associations
-                var extensionsString = arg["associate:".Length..];
-                if (string.IsNullOrWhiteSpace(extensionsString))
-                {
-                    Debug.WriteLine("No extensions to associate found in arguments.");
-                    return;
-                }
-
-                // Split by semicolons for different extensions
-                var extensions = extensionsString
-                    .Split(';', StringSplitOptions.RemoveEmptyEntries)
-                    .Select(ext => ext.Trim())
-                    .ToArray();
-
-                Debug.WriteLine($"Found {extensions.Length} extensions to associate");
-
-                foreach (var extension in extensions)
-                {
-                    try
-                    {
-                        // Each extension may have a description after a pipe |
-                        var parts = extension.Split('|', 2);
-                        var ext = parts[0].Trim();
-
-                        // Get description if available
-                        string? description = null;
-                        if (parts.Length > 1)
-                        {
-                            description = parts[1].Trim();
-                        }
-
-                        Debug.WriteLine($"Associating {ext} with description '{description}'");
-                        await FileAssociationManager.AssociateFile(ext, description);
-                    }
-                    catch (Exception extEx)
-                    {
-                        Debug.WriteLine($"Error processing extension '{extension}': {extEx.Message}");
-                    }
-                }
-            }
-            else if (arg.StartsWith("unassociate:", StringComparison.OrdinalIgnoreCase))
-            {
-                // Process unassociations
-                var extensionsString = arg["unassociate:".Length..];
-                if (string.IsNullOrWhiteSpace(extensionsString))
-                {
-                    Debug.WriteLine("No extensions to unassociate found in arguments.");
-                    return;
-                }
-
-                // Split by semicolons for different extensions
-                var extensions = extensionsString
-                    .Split(';', StringSplitOptions.RemoveEmptyEntries)
-                    .Select(ext => ext.Trim())
-                    .ToArray();
-
-                Debug.WriteLine($"Found {extensions.Length} extensions to unassociate");
-
-                foreach (var extension in extensions)
-                {
-                    try
-                    {
-                        // For unassociate, we just need the extension (ignore any description)
-                        var ext = extension.Split('|')[0].Trim();
-
-                        Debug.WriteLine($"Unassociating {ext}");
-                        await FileAssociationManager.UnassociateFile(ext);
-                    }
-                    catch (Exception extEx)
-                    {
-                        Debug.WriteLine($"Error unassociating extension '{extension}': {extEx.Message}");
-                    }
-                }
-            }
-        }
-        catch (Exception ex)
-        {
-            Debug.WriteLine($"Error processing file association arguments: {ex.Message}");
-            Debug.WriteLine($"Argument was: {arg}");
-            Debug.WriteLine($"Stack trace: {ex.StackTrace}");
-        }
-    }
 }

+ 25 - 0
src/PicView.Core/ProcessHandling/ProcessHelper.cs

@@ -55,6 +55,31 @@ public static class ProcessHelper
             return false;
         }
     }
+    
+    public static async Task<bool> StartProcessWithElevatedPermissionAsync(string arguments)
+    {
+        try
+        {
+            using var process = new Process();
+            process.StartInfo = new ProcessStartInfo
+            {
+                FileName = Process.GetCurrentProcess().MainModule?.FileName,
+                Arguments = arguments,
+                UseShellExecute = true,
+                Verb = "runas"
+            };
+
+            process.Start();
+            await process.WaitForExitAsync();
+            return true;
+        }
+        catch (Exception ex)
+        {
+            // User declined the UAC prompt or other error
+            Debug.WriteLine($"Failed to start elevated process: {ex.Message}");
+            return false;
+        }
+    }
 
     /// <summary>
     ///     Restarts the current application.

+ 24 - 17
src/PicView.Core/ViewModels/FileAssociationsViewModel.cs

@@ -338,37 +338,44 @@ public class FileAssociationsViewModel : ReactiveObject
 
     /// <summary>
     /// Toggles selection state of all visible file types between indeterminate and unselected.
+    /// If the number of indeterminate checkboxes equals or is greater than the number of non-indeterminate ones,
+    /// all visible checkboxes will be set to unchecked. Otherwise, all will be set to indeterminate.
     /// </summary>
     /// <remarks>
     /// This method uses snapshots of collections to avoid enumeration modification exceptions.
-    /// If all visible items are already in indeterminate state, they will be set to unselected.
-    /// Otherwise, all visible items will be set to indeterminate.
     /// </remarks>
     private void UnselectAllFileTypes()
     {
         // Make a copy of the current groups to avoid enumeration issues
         var currentGroups = FileTypeGroups.ToArray();
     
-        // Update selection states to true for all items
+        // Count the total number of visible checkboxes and indeterminate ones
+        var totalVisible = 0;
+        var indeterminateCount = 0;
+
         foreach (var group in currentGroups)
         {
-            // Use snapshot of file types to avoid enumeration issues
-            var fileTypes = group.FileTypes.ToArray();
-            
-            // Toggle between indeterminate and false for visible items
-            if (fileTypes.All(x => x.IsSelected == null && x.IsVisible))
+            foreach (var fileType in group.FileTypes.Where(ft => ft.IsVisible))
             {
-                foreach (var checkBox in fileTypes)
+                totalVisible++;
+                if (fileType.IsSelected == null)
                 {
-                    checkBox.IsSelected = false;
+                    indeterminateCount++;
                 }
             }
-            else
+        }
+
+        // Determine which state to set based on the counts
+        // If indeterminate count is equal to or greater than non-indeterminate count, 
+        // set all to unchecked, otherwise set all to indeterminate
+        var setToUnchecked = indeterminateCount >= totalVisible - indeterminateCount;
+
+        // Apply the chosen state to all visible checkboxes
+        foreach (var group in currentGroups)
+        {
+            foreach (var fileType in group.FileTypes.Where(ft => ft.IsVisible))
             {
-                foreach (var checkBox in fileTypes.Where(x => x.IsVisible))
-                {
-                    checkBox.IsSelected = null;
-                }
+                fileType.IsSelected = setToUnchecked ? false : null;
             }
         }
     }
@@ -394,7 +401,7 @@ public class FileAssociationsViewModel : ReactiveObject
             UpdateSelection();
             
             // Now process the associations
-            return await FileTypeHelper.SetFileAssociations(FileTypeGroups);
+            return await FileAssociationProcessor.SetFileAssociations(FileTypeGroups);
         }
         finally
         {
@@ -416,7 +423,7 @@ public class FileAssociationsViewModel : ReactiveObject
         {
             IsProcessing = true;
             UnselectFileTypes();
-            await FileTypeHelper.SetFileAssociations(FileTypeGroups);
+            await FileAssociationProcessor.SetFileAssociations(FileTypeGroups);
         }
         finally
         {