From 50fde66a4c5721306a61ebc82bcb259bc1e43074 Mon Sep 17 00:00:00 2001 From: rmcrackan Date: Tue, 12 May 2026 08:55:12 -0400 Subject: [PATCH] #1804 - Fix Windows folder picker crash on custom paths. Normalize stored paths before opening the OS folder dialog (`\\?\` / UNC handling, existence check) in shared `FolderPickerInitialPath`, use it from WinForms and Avalonia folder pickers, and retry once if the dialog still throws. Adds small `FileManager` tests. --- Source/FileManager/FolderPickerInitialPath.cs | 87 +++++++++++++++++++ .../DirectoryOrCustomSelectControl.axaml.cs | 25 +++++- .../Dialogs/LocateAudiobooksDialog.axaml.cs | 24 ++++- .../Dialogs/DirectoryOrCustomSelectControl.cs | 20 ++++- .../Dialogs/LocateAudiobooksDialog.cs | 17 +++- .../FolderPickerInitialPathTests.cs | 40 +++++++++ 6 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 Source/FileManager/FolderPickerInitialPath.cs create mode 100644 Source/_Tests/FileManager.Tests/FolderPickerInitialPathTests.cs diff --git a/Source/FileManager/FolderPickerInitialPath.cs b/Source/FileManager/FolderPickerInitialPath.cs new file mode 100644 index 00000000..6ad8a9fe --- /dev/null +++ b/Source/FileManager/FolderPickerInitialPath.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace FileManager; + +/// +/// Normalizes stored paths for OS folder picker APIs that are stricter than (for example WinForms FolderBrowserDialog / shell SHCreateItemFromParsingName). +/// +public static class FolderPickerInitialPath +{ + private const string WinLongPathPrefix = @"\\?\"; + private const string WinLongUncPrefix = @"\\?\UNC\"; + + /// + /// Returns a directory path suitable as a folder picker's starting location, or null to let the OS use its default. + /// Verifies the directory exists using the same path rules as before returning a shell-oriented string. + /// + public static string? GetExistingDirectoryOrNull(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + + path = path.Trim(); + try + { + LongPath longPath = path; + if (!Directory.Exists(longPath)) + return null; + + var forPicker = ToOsFolderPickerPath(longPath.Path); + if (string.IsNullOrWhiteSpace(forPicker)) + return null; + + if (!Directory.Exists(forPicker)) + return null; + + try + { + var full = Path.GetFullPath(forPicker); + if (Directory.Exists(full)) + return full; + } + catch (ArgumentException) + { + // Path.GetFullPath rejects some edge cases; fall through + } + catch (NotSupportedException) + { + } + catch (PathTooLongException) + { + } + + return forPicker; + } + catch (ArgumentException) + { + return null; + } + catch (NotSupportedException) + { + return null; + } + catch (PathTooLongException) + { + return null; + } + } + + private static string? ToOsFolderPickerPath(string absolutePathFromLongPath) + { + if (string.IsNullOrWhiteSpace(absolutePathFromLongPath)) + return null; + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return absolutePathFromLongPath; + + if (absolutePathFromLongPath.StartsWith(WinLongUncPrefix, StringComparison.OrdinalIgnoreCase)) + return @"\\" + absolutePathFromLongPath.Substring(WinLongUncPrefix.Length); + + if (absolutePathFromLongPath.StartsWith(WinLongPathPrefix, StringComparison.Ordinal)) + return absolutePathFromLongPath.Substring(WinLongPathPrefix.Length); + + return absolutePathFromLongPath; + } +} diff --git a/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs b/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs index 042bf76c..ebf1f342 100644 --- a/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs @@ -3,6 +3,7 @@ using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Platform.Storage; using Dinah.Core; +using FileManager; using LibationFileManager; using ReactiveUI; using System.Collections.Generic; @@ -124,12 +125,34 @@ public partial class DirectoryOrCustomSelectControl : UserControl public async void Browse_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { + var window = this.GetParentWindow(); + if (window is null) + return; + var options = new FolderPickerOpenOptions { AllowMultiple = false }; - var selectedFolders = await this.GetParentWindow().StorageProvider.OpenFolderPickerAsync(options); + var start = FolderPickerInitialPath.GetExistingDirectoryOrNull(tboxCustomDirPath.Text); + if (start is not null) + { + var loc = await window.StorageProvider.TryGetFolderFromPathAsync(start); + if (loc is not null) + options.SuggestedStartLocation = loc; + } + + IReadOnlyList selectedFolders; + try + { + selectedFolders = await window.StorageProvider.OpenFolderPickerAsync(options); + } + catch + { + options.SuggestedStartLocation = null; + selectedFolders = await window.StorageProvider.OpenFolderPickerAsync(options); + } + Directory = selectedFolders.SingleOrDefault()?.TryGetLocalPath() ?? Directory; } } diff --git a/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml.cs index 147e30bf..a4d46bbb 100644 --- a/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml.cs @@ -3,9 +3,11 @@ using Avalonia.Controls; using Avalonia.Platform.Storage; using DataLayer; using Dinah.Core; +using FileManager; using LibationFileManager; using LibationUiBase; using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reactive.Linq; @@ -53,10 +55,28 @@ public partial class LocateAudiobooksDialog : DialogWindow { Title = "Select the folder to search for audiobooks", AllowMultiple = false, - SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books?.PathWithoutPrefix ?? "") }; - var selectedFolder = (await StorageProvider.OpenFolderPickerAsync(folderPicker))?.SingleOrDefault()?.TryGetLocalPath(); + var start = FolderPickerInitialPath.GetExistingDirectoryOrNull(Configuration.Instance.Books?.Path ?? ""); + if (start is not null) + { + var loc = await StorageProvider.TryGetFolderFromPathAsync(start); + if (loc is not null) + folderPicker.SuggestedStartLocation = loc; + } + + IReadOnlyList picked; + try + { + picked = await StorageProvider.OpenFolderPickerAsync(folderPicker); + } + catch + { + folderPicker.SuggestedStartLocation = null; + picked = await StorageProvider.OpenFolderPickerAsync(folderPicker); + } + + var selectedFolder = picked?.SingleOrDefault()?.TryGetLocalPath(); if (selectedFolder is null || !Directory.Exists(selectedFolder)) { diff --git a/Source/LibationWinForms/Dialogs/DirectoryOrCustomSelectControl.cs b/Source/LibationWinForms/Dialogs/DirectoryOrCustomSelectControl.cs index 1dfaea90..28098f10 100644 --- a/Source/LibationWinForms/Dialogs/DirectoryOrCustomSelectControl.cs +++ b/Source/LibationWinForms/Dialogs/DirectoryOrCustomSelectControl.cs @@ -1,6 +1,8 @@ -using LibationFileManager; +using FileManager; +using LibationFileManager; using System; using System.Collections.Generic; +using System.ComponentModel; using System.Windows.Forms; namespace LibationWinForms.Dialogs; @@ -81,9 +83,21 @@ public partial class DirectoryOrCustomSelectControl : UserControl using var dialog = new FolderBrowserDialog { Description = string.IsNullOrWhiteSpace(dirSearchTitle) ? "Search" : $"Search for {dirSearchTitle}", - SelectedPath = this.customTb.Text }; - dialog.ShowDialog(); + var initial = FolderPickerInitialPath.GetExistingDirectoryOrNull(this.customTb.Text); + if (initial is not null) + dialog.SelectedPath = initial; + + try + { + dialog.ShowDialog(); + } + catch (Win32Exception) + { + dialog.SelectedPath = string.Empty; + dialog.ShowDialog(); + } + if (!string.IsNullOrWhiteSpace(dialog.SelectedPath)) this.customTb.Text = dialog.SelectedPath; } diff --git a/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.cs b/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.cs index 16eb59bb..cef18693 100644 --- a/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.cs +++ b/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.cs @@ -1,8 +1,10 @@ using DataLayer; using Dinah.Core; +using FileManager; using LibationFileManager; using LibationUiBase; using System; +using System.ComponentModel; using System.IO; using System.Threading; using System.Windows.Forms; @@ -44,10 +46,21 @@ public partial class LocateAudiobooksDialog : Form { Description = "Select the folder to search for audiobooks", UseDescriptionForTitle = true, - InitialDirectory = Configuration.Instance.Books?.Path ?? string.Empty }; + var initial = FolderPickerInitialPath.GetExistingDirectoryOrNull(Configuration.Instance.Books?.Path ?? ""); + if (initial is not null) + fbd.InitialDirectory = initial; - var result = fbd.ShowDialog(this); + DialogResult result; + try + { + result = fbd.ShowDialog(this); + } + catch (Win32Exception) + { + fbd.InitialDirectory = string.Empty; + result = fbd.ShowDialog(this); + } if (result != DialogResult.OK || !Directory.Exists(fbd.SelectedPath)) { DialogResult = result; diff --git a/Source/_Tests/FileManager.Tests/FolderPickerInitialPathTests.cs b/Source/_Tests/FileManager.Tests/FolderPickerInitialPathTests.cs new file mode 100644 index 00000000..d156fd5f --- /dev/null +++ b/Source/_Tests/FileManager.Tests/FolderPickerInitialPathTests.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using FileManager; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FileManager.Tests; + +[TestClass] +public class FolderPickerInitialPathTests +{ + [TestMethod] + public void GetExistingDirectoryOrNull_NullOrEmpty_ReturnsNull() + { + Assert.IsNull(FolderPickerInitialPath.GetExistingDirectoryOrNull(null)); + Assert.IsNull(FolderPickerInitialPath.GetExistingDirectoryOrNull("")); + Assert.IsNull(FolderPickerInitialPath.GetExistingDirectoryOrNull(" ")); + } + + [TestMethod] + public void GetExistingDirectoryOrNull_MissingPath_ReturnsNull() + { + Assert.IsNull(FolderPickerInitialPath.GetExistingDirectoryOrNull(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "nope"))); + } + + [TestMethod] + public void GetExistingDirectoryOrNull_ExistingTempDir_ReturnsFullPath() + { + var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "LibationTest_" + Guid.NewGuid().ToString("N"))).FullName; + try + { + var result = FolderPickerInitialPath.GetExistingDirectoryOrNull(dir); + Assert.IsNotNull(result); + Assert.IsTrue(Directory.Exists(result)); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { /* ignore */ } + } + } +}