From f82fda523d3cf56dcda6e57cf56bff376224768b Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:03:25 +0200 Subject: [PATCH 01/55] Added the option to increase WebDAV size limit --- .../WebDavWrapper.cs | 50 ++++++++-- .../Helpers/WebDavRedirectorHelpers.cs | 92 +++++++++++++++++++ .../Settings/PreferencesSettingsPage.xaml | 7 ++ .../Settings/PreferencesSettingsPage.xaml.cs | 41 +++++++++ 4 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs index 267175b94..9866dd57d 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs @@ -1,7 +1,7 @@ -using NWebDav.Server.Dispatching; +using Microsoft.Extensions.Logging; +using NWebDav.Server.Dispatching; using SecureFolderFS.Core.WebDav.Helpers; using System; -using System.Diagnostics; using System.Net; using System.Threading; using System.Threading.Tasks; @@ -10,6 +10,8 @@ namespace SecureFolderFS.Core.WebDav { public sealed class WebDavWrapper { + private const int ERROR_OPERATION_ABORTED = 995; + private Thread? _fsThead; private readonly HttpListener _httpListener; private readonly IRequestDispatcher _requestDispatcher; @@ -36,17 +38,45 @@ private async Task EnsureFileSystemAsync() try { _httpListener.Start(); - while (!_fileSystemCts.IsCancellationRequested && (await _httpListener.GetContextAsync() is var httpListenerContext)) - { - if (httpListenerContext.Request.IsAuthenticated) - Debugger.Break(); - - await _requestDispatcher.DispatchRequestAsync(httpListenerContext, _fileSystemCts.Token); - } } catch (Exception ex) { - _ = ex; + _requestDispatcher.Logger?.LogError(ex, "Failed to start the WebDAV HTTP listener."); + return; + } + + while (!_fileSystemCts.IsCancellationRequested) + { + HttpListenerContext httpListenerContext; + try + { + httpListenerContext = await _httpListener.GetContextAsync(); + } + catch (Exception ex) when (ex is ObjectDisposedException or HttpListenerException { ErrorCode: ERROR_OPERATION_ABORTED }) + { + // The listener was closed, stop accepting requests + break; + } + catch (Exception ex) + { + // A transient error while accepting a request must not kill the accept loop + _requestDispatcher.Logger?.LogError(ex, "Failed to accept an incoming WebDAV request."); + continue; + } + + // Dispatch each request concurrently. The Windows WebDAV redirector interleaves requests + // (PROPFIND, LOCK refreshes) with long-running transfers and times out when they stall. + _ = Task.Run(async () => + { + try + { + await _requestDispatcher.DispatchRequestAsync(httpListenerContext, _fileSystemCts.Token); + } + catch (Exception ex) + { + _requestDispatcher.Logger?.LogError(ex, "Unhandled exception while dispatching a WebDAV request."); + } + }, _fileSystemCts.Token); } } diff --git a/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs b/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs new file mode 100644 index 000000000..c0d15dfab --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs @@ -0,0 +1,92 @@ +using System; +using System.Threading.Tasks; +#if WINDOWS +using System.Diagnostics; +using Microsoft.Win32; +#endif + +namespace SecureFolderFS.Uno.Helpers +{ + /// + /// Helpers for interacting with the Windows WebDAV redirector (WebClient service) configuration. + /// + public static class WebDavRedirectorHelpers + { + /// + /// The maximum value accepted by the WebDAV redirector for the file size limit (about 4GB). + /// + public const uint MAX_FILE_SIZE_LIMIT = uint.MaxValue; + +#if WINDOWS + private const uint DEFAULT_FILE_SIZE_LIMIT = 50_000_000u; + private const string WEBCLIENT_PARAMETERS_KEY = @"SYSTEM\CurrentControlSet\Services\WebClient\Parameters"; + private const string FILE_SIZE_LIMIT_VALUE = "FileSizeLimitInBytes"; +#endif + + /// + /// Gets the maximum size in bytes that the WebDAV redirector allows for file transfers. + /// + /// The configured limit in bytes, or null if it could not be determined. + public static uint? GetFileSizeLimit() + { +#if WINDOWS + try + { + using var parametersKey = Registry.LocalMachine.OpenSubKey(WEBCLIENT_PARAMETERS_KEY); + return parametersKey?.GetValue(FILE_SIZE_LIMIT_VALUE) switch + { + int value => unchecked((uint)value), + + // The WebDAV redirector falls back to the default limit when the value is not present + null => DEFAULT_FILE_SIZE_LIMIT, + _ => null + }; + } + catch (Exception) + { + return null; + } +#else + return null; +#endif + } + + /// + /// Attempts to raise the WebDAV redirector file size limit to the maximum allowed value. + /// + /// + /// Modifying the limit requires elevation - the user is presented with a UAC prompt. + /// The new limit takes effect once the WebClient service is restarted. + /// + /// A that represents the asynchronous operation. Value is true if the limit was applied, otherwise false. + public static async Task TrySetMaxFileSizeLimitAsync() + { +#if WINDOWS + try + { + using var process = Process.Start(new ProcessStartInfo() + { + FileName = "reg.exe", + Arguments = $@"add HKLM\{WEBCLIENT_PARAMETERS_KEY} /v {FILE_SIZE_LIMIT_VALUE} /t REG_DWORD /d {MAX_FILE_SIZE_LIMIT} /f", + UseShellExecute = true, + Verb = "runas", + WindowStyle = ProcessWindowStyle.Hidden + }); + + if (process is null) + return false; + + await process.WaitForExitAsync(); + return process.ExitCode == 0; + } + catch (Exception) + { + // The user most likely declined the elevation prompt + return false; + } +#else + return await Task.FromResult(false); +#endif + } + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml index 48c7fb3ec..0ed0e7867 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml @@ -54,6 +54,13 @@ Message="{x:Bind ViewModel.BannerViewModel.FileSystemInfoBar.Message, Mode=OneWay}" Severity="{x:Bind ViewModel.BannerViewModel.FileSystemInfoBar.Severity, Mode=OneWay, Converter={StaticResource GenericEnumConverter}}" Visibility="{x:Bind ViewModel.BannerViewModel.FileSystemInfoBar.IsOpen, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + + + + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml.cs index a93654499..194969967 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml.cs @@ -28,5 +28,13 @@ public ICommand? PasteRecoveryKeyCommand } public static readonly DependencyProperty PasteRecoveryKeyCommandProperty = DependencyProperty.Register(nameof(PasteRecoveryKeyCommand), typeof(ICommand), typeof(RecoveryControl), new PropertyMetadata(null)); + + public string? ErrorMessage + { + get => (string?)GetValue(ErrorMessageProperty); + set => SetValue(ErrorMessageProperty, value); + } + public static readonly DependencyProperty ErrorMessageProperty = + DependencyProperty.Register(nameof(ErrorMessage), typeof(string), typeof(RecoveryControl), new PropertyMetadata(null)); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs index 1ad629323..176b7efb9 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs @@ -36,6 +36,7 @@ public RecoveryOverlayViewModel(IFolder vaultFolder) public async Task RecoverAsync(CancellationToken cancellationToken) { + ErrorMessage = null; try { var unlockContract = await VaultManagerService.RecoverAsync(_vaultFolder, RecoveryKey ?? string.Empty, cancellationToken); From b924a7110f6ce71040abc8f374076c91ff1dfeec Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:55:45 +0200 Subject: [PATCH 39/55] Surface errors in vault wizard summary pages --- .../Modals/Wizard/SummaryWizardPage.xaml | 23 +++++++++++ .../Views/VaultWizard/SummaryWizardPage.xaml | 38 +++++++++++++------ .../Views/Wizard/SummaryWizardViewModel.cs | 14 ++++--- .../Models/Result.Message.cs | 13 +++++++ 4 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/SummaryWizardPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/SummaryWizardPage.xaml index 76b016eb9..43097aed1 100644 --- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/SummaryWizardPage.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/SummaryWizardPage.xaml @@ -33,12 +33,14 @@ + + + + + diff --git a/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml index 07acd2593..e5dc9d177 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml @@ -22,18 +22,34 @@ Orientation="Vertical" Spacing="8"> - - - - - - - + + + + + + + + + + + + + - + + /// Creates an instance of by combining the given base result and an additional message. + /// + /// The base result, which contains the success state or an exception. + /// The additional message to include in the result. + /// A new instance of containing the provided base result and message. + public static IResultWithMessage WithMessage(IResult baseResult, string message) + { + return baseResult.Exception is not null + ? new MessageResult(baseResult.Exception, message) + : new MessageResult(baseResult.Successful, message); + } } /// From 108d58e1726e6f4e5b15d09be4805175758d123e Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:06:50 +0200 Subject: [PATCH 40/55] Improved error handling for customizing vault icons --- .../Views/VaultWizard/SummaryWizardPage.xaml | 2 +- .../VaultList/VaultListItemViewModel.cs | 41 +++++++++++-------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml index e5dc9d177..96c98cd9e 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/SummaryWizardPage.xaml @@ -23,7 +23,7 @@ Spacing="8"> - + ()?.LogError(ex, "Failed to set the vault folder icon."); + } break; } @@ -155,7 +162,7 @@ private async Task RevealFolderAsync(CancellationToken cancellationToken) [RelayCommand] private async Task CreateShortcutAsync(CancellationToken cancellationToken) { - if (VaultViewModel.VaultModel.VaultFolder is not { } vaultFolder) + if (VaultViewModel.VaultModel.VaultFolder is null) return; // Check Iap Plus requirement @@ -172,7 +179,7 @@ private async Task CreateShortcutAsync(CancellationToken cancellationToken) var filter = new Dictionary { - { "SecureFolderFS Vault", VaultService.ShortcutFileExtension } + { $"{"Vault".ToLocalized()} ({nameof(SecureFolderFS)})", VaultService.ShortcutFileExtension } }; await FileExplorerService.SaveFileAsync(suggestedName, dataStream, filter, cancellationToken); From 4b0e70c35cdd92c5476031e867d80cfc6fd23f55 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:33:38 +0200 Subject: [PATCH 41/55] Replaced background Thread with loop Task in WebDavWrapper --- .../Helpers/PortHelpers.cs | 76 +++++++++++++++++-- .../WebDavWrapper.cs | 32 ++++---- 2 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs b/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs index 4654f3639..6e2f04cb4 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs @@ -1,36 +1,98 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.NetworkInformation; +using System.Net.Sockets; namespace SecureFolderFS.Core.WebDav.Helpers { internal static class PortHelpers { + /// + /// Checks if the specified is available for binding by attempting + /// to listen on it or verifying it is not in use. + /// + /// The port number to check for availability. + /// + /// true if the port is available for use; otherwise, false. + /// public static bool IsPortAvailable(int port) { - return !GetUnavailablePorts().Contains(port); + if (OperatingSystem.IsAndroid()) + return CanBindPort(port); + + try + { + return !GetUnavailablePorts().Contains(port); + } + catch (Exception) + { + // Enumeration may be unsupported on this platform + return CanBindPort(port); + } } + /// + /// Finds and returns the next available network port starting from the specified . + /// + /// The port number from which to start searching for an available port. + /// The next available port number. public static int GetNextAvailablePort(int startingPort) { - var unavailablePorts = GetUnavailablePorts().ToList(); + if (!OperatingSystem.IsAndroid()) + { + try + { + var unavailablePorts = GetUnavailablePorts().ToHashSet(); + for (var i = startingPort; i <= ushort.MaxValue; i++) + { + if (!unavailablePorts.Contains(i)) + return i; + } + + throw new InvalidOperationException("No available ports."); + } + catch (Exception ex) when (ex is not InvalidOperationException) + { + // Enumeration is unsupported on this platform + } + } + for (var i = startingPort; i <= ushort.MaxValue; i++) - if (!unavailablePorts.Contains(i)) + { + if (CanBindPort(i)) return i; + } throw new InvalidOperationException("No available ports."); } - private static IEnumerable GetUnavailablePorts() + /// + /// Determines whether can be bound by attempting to listen on it. + /// Works on every platform, including those where enumeration is unavailable (e.g. Android). + /// + private static bool CanBindPort(int port) { - if (OperatingSystem.IsAndroid()) - return Enumerable.Empty(); // TODO(u android) + try + { + using var listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + listener.Stop(); + return true; + } + catch (SocketException) + { + return false; + } + } + private static IEnumerable GetUnavailablePorts() + { var properties = IPGlobalProperties.GetIPGlobalProperties(); return properties.GetActiveTcpConnections().Select(x => x.LocalEndPoint.Port) .Concat(properties.GetActiveTcpListeners().Select(x => x.Port)) .Concat(properties.GetActiveUdpListeners().Select(x => x.Port)); } } -} \ No newline at end of file +} diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs index 81169e708..e1df89d6f 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs @@ -12,7 +12,7 @@ public sealed class WebDavWrapper { private const int ERROR_OPERATION_ABORTED = 995; - private Thread? _fsThead; + private Task? _acceptLoopTask; private readonly HttpListener _httpListener; private readonly IRequestDispatcher _requestDispatcher; private readonly CancellationTokenSource _fileSystemCts; @@ -28,23 +28,14 @@ public WebDavWrapper(HttpListener httpListener, IRequestDispatcher requestDispat public void StartFileSystem() { - var ts = new ThreadStart(async () => await EnsureFileSystemAsync()); - _fsThead = new Thread(ts); - _fsThead.Start(); + // The accept loop is fully asynchronous and I/O-bound (each request is dispatched onto the thread pool). + // Running it as a background task keeps the UI responsive without occupying a thread + _acceptLoopTask = Task.Run(EnsureFileSystemAsync); } private async Task EnsureFileSystemAsync() { - try - { - _httpListener.Start(); - } - catch (Exception ex) - { - _requestDispatcher.Logger?.LogError(ex, "Failed to start the WebDAV HTTP listener."); - return; - } - + // The caller starts the listener before mounting, so a bind failure has already surfaced while (!_fileSystemCts.IsCancellationRequested) { HttpListenerContext httpListenerContext; @@ -85,6 +76,19 @@ public async Task CloseFileSystemAsync() _httpListener.Close(); await _fileSystemCts.CancelAsync(); + // Wait for the accept loop to observe the closed listener and unwind + if (_acceptLoopTask is not null) + { + try + { + await _acceptLoopTask; + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + // Expected during teardown + } + } + if (_mountPath is not null) DriveMappingHelpers.DisconnectNetworkDrive(_mountPath, true); From ca625e79027dea040b790783881b34229dc11d7b Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:40:33 +0200 Subject: [PATCH 42/55] Added safety restraints for loading types from disk --- .../AppModels/Database/BatchDatabaseModel.cs | 29 ++++++---- .../AppModels/Database/SafeTypeResolver.cs | 55 +++++++++++++++++++ .../Database/SingleFileDatabaseModel.cs | 6 +- 3 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SafeTypeResolver.cs diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs index 4bbc97d4d..dd19ab47a 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using OwlCore.Storage; +using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Storage.Extensions; @@ -86,9 +87,10 @@ public override async Task WipeAsync(CancellationToken cancellationToken = defau /// public override async Task InitAsync(CancellationToken cancellationToken = default) { + // Acquire before entering the try so a canceled wait does not release a permit we never obtained + await storageSemaphore.WaitAsync(cancellationToken); try { - await storageSemaphore.WaitAsync(cancellationToken); await EnsureSettingsFolderAsync(cancellationToken); _ = _databaseFolder ?? throw new InvalidOperationException("The database folder was not properly initialized."); @@ -110,8 +112,8 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau if (string.IsNullOrEmpty(typeString)) continue; - // Get original type - var originalType = Type.GetType(typeString); + // Get original type, restricting resolution to trusted assemblies + var originalType = SafeTypeResolver.Resolve(typeString); if (originalType is null) continue; @@ -124,9 +126,8 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau } catch (Exception ex) { - // TODO: Re-throw exceptions in some cases? - _ = ex; - Debugger.Break(); + // A single corrupt setting must not prevent the remaining ones from loading + DI.OptionalService()?.LogWarning(ex, "Failed to load the setting '{SettingName}'.", dataFile.Name); } } } @@ -139,9 +140,11 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau /// public override async Task SaveAsync(CancellationToken cancellationToken = default) { + // Acquire before entering the try so a canceled wait does not release a permit we never obtained + await storageSemaphore.WaitAsync(cancellationToken); + List? failures = null; try { - await storageSemaphore.WaitAsync(cancellationToken); await EnsureSettingsFolderAsync(cancellationToken); _ = _databaseFolder ?? throw new InvalidOperationException("The database folder was not properly initialized."); @@ -192,9 +195,10 @@ public override async Task SaveAsync(CancellationToken cancellationToken = defau } catch (Exception ex) { - // TODO: Re-throw exceptions in some cases? - _ = ex; - Debugger.Break(); + // Continue persisting the remaining settings, but remember the failure so the + // caller can observe that the save did not fully succeed + DI.OptionalService()?.LogError(ex, "Failed to save the setting '{SettingName}'.", item.Key); + (failures ??= new()).Add(ex); } } } @@ -202,6 +206,9 @@ public override async Task SaveAsync(CancellationToken cancellationToken = defau { _ = storageSemaphore.Release(); } + + if (failures is not null) + throw new AggregateException("One or more settings could not be saved.", failures); } private async Task EnsureSettingsFolderAsync(CancellationToken cancellationToken) diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SafeTypeResolver.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SafeTypeResolver.cs new file mode 100644 index 000000000..6f52d5e05 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SafeTypeResolver.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; + +namespace SecureFolderFS.Sdk.AppModels.Database +{ + /// + /// Resolves instances from persisted type strings while restricting resolution + /// to a set of trusted assemblies. + /// + /// + /// The type name originates from an on-disk settings file. Resolving arbitrary assembly-qualified names would + /// let anyone able to write to the settings folder coerce the app into loading unexpected types. Only the + /// application's own assemblies and the core framework are considered safe. + /// + internal static class SafeTypeResolver + { + private static readonly string[] AllowedAssemblyPrefixes = + [ + nameof(SecureFolderFS), + nameof(System), + "mscorlib", + "netstandard" + ]; + + /// + /// Resolves the described by if it belongs to a trusted assembly. + /// + /// The assembly-qualified or namespace-qualified type name. + /// The resolved , or null if the type is unknown or resides in an untrusted assembly. + public static Type? Resolve(string typeName) + { + if (string.IsNullOrEmpty(typeName)) + return null; + + var type = Type.GetType(typeName, throwOnError: false); + if (type is null) + return null; + + return IsTrusted(type) ? type : null; + } + + private static bool IsTrusted(Type type) + { + // Validate the type itself and every generic argument + var assemblyName = type.Assembly.GetName().Name; + if (assemblyName is null || !AllowedAssemblyPrefixes.Any(prefix => assemblyName.StartsWith(prefix, StringComparison.Ordinal))) + return false; + + if (type.IsGenericType) + return type.GetGenericArguments().All(IsTrusted); + + return true; + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SingleFileDatabaseModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SingleFileDatabaseModel.cs index 851ae9038..40ebbd4a8 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SingleFileDatabaseModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/SingleFileDatabaseModel.cs @@ -81,9 +81,10 @@ public override async Task WipeAsync(CancellationToken cancellationToken = defau /// public override async Task InitAsync(CancellationToken cancellationToken = default) { + // Acquire before entering the try so a canceled wait does not release a permit we never obtained + await storageSemaphore.WaitAsync(cancellationToken); try { - await storageSemaphore.WaitAsync(cancellationToken); await EnsureSettingsFileAsync(cancellationToken); _ = _databaseFile ?? throw new InvalidOperationException("The database file was not properly initialized."); @@ -117,9 +118,10 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau /// public override async Task SaveAsync(CancellationToken cancellationToken = default) { + // Acquire before entering the try so a canceled wait does not release a permit we never obtained + await storageSemaphore.WaitAsync(cancellationToken); try { - await storageSemaphore.WaitAsync(cancellationToken); await EnsureSettingsFileAsync(cancellationToken); _ = _databaseFile ?? throw new InvalidOperationException("The database file was not properly initialized."); From a1bcf93c7a980c569eb7daef1ef6f2a2a88af03e Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:47:04 +0200 Subject: [PATCH 43/55] Avoid starting a dead WebDav server edge-case --- .../WebDavFileSystem.cs | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs index e335aae6a..cd982fc1d 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs @@ -22,6 +22,8 @@ namespace SecureFolderFS.Core.WebDav /// public abstract class WebDavFileSystem : IFileSystemInfo { + private const int MAX_LISTENER_START_ATTEMPTS = 10; + /// public string Id { get; } = Constants.FileSystem.FS_ID; @@ -49,13 +51,12 @@ public virtual async Task MountAsync(IFolder folder, IDisposable unloc if (!PortHelpers.IsPortAvailable(webDavOptions.Port)) webDavOptions.SetPortInternal(PortHelpers.GetNextAvailablePort(webDavOptions.Port)); - var prefix = $"{webDavOptions.Protocol}://{webDavOptions.Domain}:{webDavOptions.Port}/"; - var httpListener = new HttpListener(); - - httpListener.Prefixes.Add(prefix); - httpListener.AuthenticationSchemes = AuthenticationSchemes.Anonymous; + // Start the listener up-front (self-healing onto another port if the bind fails) so a genuine + // failure surfaces to the caller and fails the unlocking operation, instead of leaving a silently-dead server. + // Starting here also fixes webDavOptions.Port to the actually bound port before the platform + // implementation derives the mount URL from it. + var httpListener = StartListener(webDavOptions); - //var store = new EncryptingDiskStore(specifics.ContentFolder.Id, specifics, !specifics.Options.IsReadOnly); var rootFolder = new CryptoFolder(specifics.ContentFolder.Id, specifics.ContentFolder, specifics); var store = new BackedDavStore(rootFolder, !specifics.Options.IsReadOnly); var dispatcher = new WebDavDispatcher(new RootDiskStore(specifics.Options.VolumeName, store), new RequestHandlerProvider(), null); @@ -68,6 +69,38 @@ public virtual async Task MountAsync(IFolder folder, IDisposable unloc cancellationToken); } + /// + /// Creates and starts an for the configured WebDAV endpoint, with a fallback to + /// the next available port if the bind fails (e.g., the port was taken between the availability check and the bind). + /// + /// The WebDAV options. The is updated to the actually bound port. + /// A started bound to the resolved port. + private static HttpListener StartListener(WebDavOptions options) + { + HttpListenerException? lastException = null; + for (var attempt = 0; attempt < MAX_LISTENER_START_ATTEMPTS; attempt++) + { + var listener = new HttpListener(); + listener.Prefixes.Add($"{options.Protocol}://{options.Domain}:{options.Port}/"); + listener.AuthenticationSchemes = AuthenticationSchemes.Anonymous; + + try + { + listener.Start(); + return listener; + } + catch (HttpListenerException ex) + { + // The port became unavailable between the check and the bind, proceed with self-heal onto the next free port + lastException = ex; + listener.Close(); + options.SetPortInternal(PortHelpers.GetNextAvailablePort(options.Port + 1)); + } + } + + throw lastException ?? new HttpListenerException(); + } + /// public abstract Task GetVolumeNameAsync(string candidateName, CancellationToken cancellationToken = default); From 14dc8477bbcbb9310c9a57caaa92ddf1eb099a3c Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:51:04 +0200 Subject: [PATCH 44/55] Support error reporting in TransferControl --- .../Browser/BrowserControl.DragDrop.xaml.cs | 26 +++--- .../UserControls/Browser/TransferControl.xaml | 30 +++++-- .../Browser/TransferControl.xaml.cs | 25 ++++++ .../ViewModels/GDriveAccountViewModel.cs | 21 +++-- .../AppModels/VaultCollectionModel.cs | 5 +- .../Extensions/TransferExtensions.cs | 57 ++++++++++--- .../Controls/Transfer/TransferViewModel.cs | 81 +++++++++++++++++++ 7 files changed, 212 insertions(+), 33 deletions(-) diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs index 66e262f8f..5750951b8 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs @@ -209,9 +209,9 @@ private static async Task MoveItemToFolderAsync(BrowserItemViewModel draggedItem transferViewModel.TransferType = TransferType.Move; using var cts = transferViewModel.GetCancellation(); - // Ensure the destination has content already loaded + // Ensure the destination has content already loaded before collision checks if (destinationViewModel.Items.IsEmpty()) - _ = destinationViewModel.ListContentsAsync(cts.Token); + await destinationViewModel.ListContentsAsync(cts.Token); await transferViewModel.TransferAsync([ itemToMove ], async (item, reporter, token) => { @@ -233,10 +233,13 @@ await transferViewModel.TransferAsync([ itemToMove ], async (item, reporter, tok }, browserViewModel.Layouts.GetSorter()); }, cts.Token); } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + catch (OperationCanceledException) { - _ = ex; - // TODO: Report error + // Cancellation is user intent - nothing to report + } + catch (Exception) + { + await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); } finally { @@ -426,9 +429,9 @@ private static async Task CopyExternalFilesToFolderAsync(DropEventArgs dropEvent transferViewModel.TransferType = TransferType.Copy; using var cts = transferViewModel.GetCancellation(); - // Ensure the destination has content already loaded + // Ensure the destination has content already loaded before collision checks if (destinationViewModel.Items.IsEmpty()) - _ = destinationViewModel.ListContentsAsync(cts.Token); + await destinationViewModel.ListContentsAsync(cts.Token); await transferViewModel.TransferAsync(itemsToProcess, async (item, reporter, token) => { @@ -463,10 +466,13 @@ await transferViewModel.TransferAsync(itemsToProcess, async (item, reporter, tok } }, x => x.Name, cts.Token); } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + catch (OperationCanceledException) + { + // Cancellation is user intent - nothing to report + } + catch (Exception) { - _ = ex; - // TODO: Report error + await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); } finally { diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml index 56d597636..2fd8d2b34 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml @@ -5,6 +5,9 @@ xmlns:av="clr-namespace:Xe.AcrylicView;assembly=Xe.AcrylicView" xmlns:av2="clr-namespace:Xe.AcrylicView.Controls;assembly=Xe.AcrylicView" xmlns:local="clr-namespace:SecureFolderFS.Maui.UserControls.Browser" + xmlns:mi="http://www.aathifmahir.com/dotnet/2022/maui/icons" + xmlns:mi_cupertino="clr-namespace:MauiIcons.Cupertino;assembly=MauiIcons.Cupertino" + xmlns:mi_material="clr-namespace:MauiIcons.Material;assembly=MauiIcons.Material" xmlns:uc="clr-namespace:SecureFolderFS.Maui.UserControls" x:Name="ThisControl"> @@ -54,11 +57,26 @@ WidthRequest="32" /> - @@ -67,7 +85,7 @@ Padding="16,8" Command="{Binding PrimaryCommand, Mode=OneWay}" CornerRadius="10" - IsVisible="{Binding IsProgressing, Mode=OneWay, Converter={StaticResource BoolInvertConverter}}" + IsVisible="{Binding IsConfirmShown, Mode=OneWay}" Style="{StaticResource AccentButtonStyle}" Text="{Binding PrimaryButtonText, Mode=OneWay}" /> diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs index ca3eeb025..a8e5c47cd 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs @@ -21,6 +21,15 @@ private async void Panel_PanUpdated(object? sender, PanUpdatedEventArgs e) if (_isDismissing) return; + // Swipe-down dismiss cancels the operation. Disallow it for non-cancellable operations + if (!CanCancel) + { + if (RootPanel.TranslationY != 0d) + await RootPanel.TranslateToAsync(0, 0, 250U, Easing.SpringOut); + + return; + } + switch (e.StatusType) { case GestureStatus.Running: @@ -121,6 +130,22 @@ public bool IsProgressing public static readonly BindableProperty IsProgressingProperty = BindableProperty.Create(nameof(IsProgressing), typeof(bool), typeof(TransferControl), false); + public bool IsConfirmShown + { + get => (bool)GetValue(IsConfirmShownProperty); + set => SetValue(IsConfirmShownProperty, value); + } + public static readonly BindableProperty IsConfirmShownProperty = + BindableProperty.Create(nameof(IsConfirmShown), typeof(bool), typeof(TransferControl), false); + + public bool IsError + { + get => (bool)GetValue(IsErrorProperty); + set => SetValue(IsErrorProperty, value); + } + public static readonly BindableProperty IsErrorProperty = + BindableProperty.Create(nameof(IsError), typeof(bool), typeof(TransferControl), false); + public string? Title { get => (string?)GetValue(TitleProperty); diff --git a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs index 14a45a111..3d42a030d 100644 --- a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; using Google; using Google.Apis.Auth.OAuth2; using Google.Apis.Drive.v3; @@ -17,6 +18,7 @@ using SecureFolderFS.Sdk.GoogleDrive.AppModels; using SecureFolderFS.Sdk.GoogleDrive.DataModels; using SecureFolderFS.Sdk.GoogleDrive.Storage; +using SecureFolderFS.Shared; using SecureFolderFS.Shared.Api; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; @@ -222,8 +224,13 @@ private async Task ConnectAccountPromptAsync(CancellationToken cancellationToken await ConnectFromUserInputAsync(cancellationToken); // User info is already fetched in ConnectFromUserInputAsync } - catch (Exception) + catch (OperationCanceledException) + { + // The user dismissed the authorization prompt + } + catch (Exception ex) { + DI.OptionalService()?.LogError(ex, "Failed to connect the Google Drive account."); UserDisplayName = null; UserPhotoUri = null; UserEmail = null; @@ -253,14 +260,18 @@ private async Task FetchUserInfoAsync(CancellationToken cancellationToken) } catch (Exception ex) { - // TODO: Log or handle the error appropriately - // For now, just set default values + // Fall back to a best-effort display name while keeping the account usable UserDisplayName = _credential?.UserId ?? "Unknown user"; UserEmail = null; - // Re-throw if it's a critical error - if (ex is GoogleApiException apiEx && apiEx.HttpStatusCode == HttpStatusCode.Unauthorized) + // Surface an expired/revoked token so the caller can trigger re-authentication and update the stored config + if (ex is GoogleApiException { HttpStatusCode: HttpStatusCode.Unauthorized }) + { + DI.OptionalService()?.LogWarning(ex, "Google Drive authorization expired; re-authentication is required."); throw; + } + + DI.OptionalService()?.LogError(ex, "Failed to fetch Google Drive user information."); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs index a031ff7bb..dc1a4ebf6 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; +using Microsoft.Extensions.Logging; using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.DataModels; @@ -100,8 +101,8 @@ public async Task InitAsync(CancellationToken cancellationToken = default) } catch (Exception ex) { - _ = ex; - continue; + // A single unreadable vault entry must not prevent the rest of the list from loading + DI.OptionalService()?.LogWarning(ex, "Skipped a vault entry that could not be loaded."); } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs b/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs index d9d2f5684..25b303519 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using OwlCore.Storage; @@ -19,6 +18,10 @@ public static bool IsPickingItems(this TransferViewModel transferViewModel) public static async Task HideAsync(this TransferViewModel transferViewModel) { + // An error banner is in control of its own dismissal (see TransferViewModel.ReportErrorAsync) + if (transferViewModel.IsErrorVisible) + return; + transferViewModel.IsPickingFolder = false; transferViewModel.IsVisible = false; await Task.Delay(350); @@ -43,6 +46,7 @@ public static async Task TransferAsync( CancellationToken cancellationToken = default) { var collection = items.ToOrAsCollection(); + transferViewModel.ClearError(); transferViewModel.IsProgressing = true; transferViewModel.IsVisible = true; transferViewModel.Report(new(0, collection.Count, collection.Count)); @@ -53,18 +57,34 @@ public static async Task TransferAsync( transferViewModel.Report(new(counter, 0, x.Name)); }); - for (var i = 0; i < collection.Count; i++) + var failedCount = 0; + var index = 0; + foreach (var item in collection) { cancellationToken.ThrowIfCancellationRequested(); + transferViewModel.Report(new(index++, collection.Count, itemName(item))); - var item = collection.ElementAt(i); - transferViewModel.Report(new(i, collection.Count, itemName(item))); - await callback(item, reporter, cancellationToken); + try + { + await callback(item, reporter, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + // A failed item must not abandon the remaining ones + failedCount++; + } } transferViewModel.Report(new(collection.Count, collection.Count, string.Empty)); await Task.Delay(1000, CancellationToken.None); await transferViewModel.HideAsync(); + + if (failedCount > 0) + await transferViewModel.ReportErrorAsync("TransferItemsFailedPlural".ToLocalized(failedCount)); } public static async Task TransferAsync( @@ -85,22 +105,39 @@ public static async Task TransferAsync( CancellationToken cancellationToken = default) { var collection = items.ToOrAsCollection(); + transferViewModel.ClearError(); transferViewModel.IsProgressing = true; transferViewModel.IsVisible = true; transferViewModel.Report(new(0, collection.Count, collection.Count)); - for (var i = 0; i < collection.Count; i++) + var failedCount = 0; + var index = 0; + foreach (var item in collection) { cancellationToken.ThrowIfCancellationRequested(); + transferViewModel.Report(new(index++, collection.Count, itemName(item))); - var item = collection.ElementAt(i); - transferViewModel.Report(new(i, collection.Count, itemName(item))); - await callback(item, cancellationToken); + try + { + await callback(item, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + // A failed item must not abandon the remaining ones + failedCount++; + } } transferViewModel.Report(new(collection.Count, collection.Count, string.Empty)); await Task.Delay(1000, CancellationToken.None); await transferViewModel.HideAsync(); + + if (failedCount > 0) + await transferViewModel.ReportErrorAsync("TransferItemsFailedPlural".ToLocalized(failedCount)); } public static async Task PerformOperationAsync(this TransferViewModel transferViewModel, Func operation, CancellationToken cancellationToken = default) @@ -132,7 +169,7 @@ public static async Task PerformOperationAsync(this TransferViewModel transferVi if (uiShown) { transferViewModel.Title = "TransferDone".ToLocalized(); - await Task.Delay(300, showUiCts.Token); // Allow user to see the "Done" message + await Task.Delay(300, CancellationToken.None); // Allow user to see the "Done" message } } catch (OperationCanceledException) diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs index 8f693ed07..00b98dd98 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs @@ -19,16 +19,20 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Transfer [Bindable(true)] public sealed partial class TransferViewModel : ObservableObject, IViewable, IProgress, IFolderPicker { + private const int ERROR_DISPLAY_DURATION_MS = 5000; + private readonly BrowserViewModel _browserViewModel; private readonly SynchronizationContext? _synchronizationContext; private TaskCompletionSource? _tcs; private CancellationTokenSource? _cts; + private CancellationTokenSource? _errorCts; [ObservableProperty] private string? _Title; [ObservableProperty] private bool _CanCancel; [ObservableProperty] private bool _IsVisible; [ObservableProperty] private bool _IsProgressing; [ObservableProperty] private bool _IsPickingFolder; + [ObservableProperty] private bool _IsErrorVisible; [ObservableProperty] private TransferType _TransferType; public TransferViewModel(BrowserViewModel browserViewModel) @@ -56,8 +60,76 @@ public void Report(TotalProgress value) ReportCore(value); } + /// + /// Shows an indeterminate progress state, e.g. while item sizes are being calculated before a transfer. + /// + /// The title to display while the operation is running. + public void ShowIndeterminate(string title) + { + ClearError(); + Title = title; + IsProgressing = true; + IsVisible = true; + } + + /// + /// Dismisses any operation UI and shows as a transient error banner. + /// The banner disappears on its own or when dismissed by the user. + /// + /// The error message to display. + /// A that completes once the banner is shown. + public async Task ReportErrorAsync(string message) + { + ClearError(); + _errorCts = new(); + var token = _errorCts.Token; + + // Finish and dismiss the current operation UI before revealing the error + if (IsVisible) + await this.HideAsync(); + + if (token.IsCancellationRequested) + return; + + Title = message; + IsErrorVisible = true; + IsProgressing = false; + CanCancel = true; + IsVisible = true; + + _ = DismissErrorLaterAsync(token); + } + + private async Task DismissErrorLaterAsync(CancellationToken token) + { + try + { + await Task.Delay(ERROR_DISPLAY_DURATION_MS, token); + IsErrorVisible = false; + await this.HideAsync(); + } + catch (OperationCanceledException) + { + // A newer operation or an explicit dismissal took over the control + } + } + + /// + /// Clears the error state, if any, allowing the control to display a new operation. + /// + public void ClearError() + { + _errorCts?.TryCancel(); + _errorCts?.Dispose(); + _errorCts = null; + IsErrorVisible = false; + } + private void ReportCore(TotalProgress value) { + if (IsErrorVisible) + return; + if (value.Achieved >= value.Total && value.Total > 0) { Title = "TransferDone".ToLocalized(); @@ -83,6 +155,7 @@ object GetInterpolation() public CancellationTokenSource GetCancellation(CancellationToken? linkToken = null) { + ClearError(); _cts?.Dispose(); _cts = linkToken is not null ? CancellationTokenSource.CreateLinkedTokenSource(linkToken.Value) @@ -132,6 +205,14 @@ private void Confirm() [RelayCommand] private async Task CancelAsync() { + if (IsErrorVisible) + { + // Dismiss the error banner since there is no operation left to cancel + ClearError(); + await this.HideAsync(); + return; + } + if (_tcs is not null) { _tcs.TrySetCanceled(CancellationToken.None); From a8b8e90d27bbc9156de4e248c9e9d344619ead90 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:15:48 +0200 Subject: [PATCH 45/55] Inform about folder enumeration failure in FolderViewModel --- .../SecureFolderFS.Uno/UserControls/RecoveryControl.xaml | 1 + .../Controls/Storage/Browser/FolderViewModel.cs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml index 10442f07e..5c20ce7b4 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/RecoveryControl.xaml @@ -14,6 +14,7 @@ diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs index b673cd320..7e1d614a0 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; +using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.ViewModels.Views.Vault; using SecureFolderFS.Shared; @@ -117,6 +118,13 @@ public async Task ListContentsAsync(CancellationToken cancellationToken = defaul { // A newer listing superseded this one (or the caller canceled) } + catch (Exception ex) + { + // Only inform the user that the refresh failed and leave existing contents intact + Logger.LogError(ex, "Failed to list the contents of a folder."); + if (BrowserViewModel.TransferViewModel is { } transferViewModel) + await transferViewModel.ReportErrorAsync("FolderLoadFailed".ToLocalized()); + } } /// From cc5de11880b540ee6188e939e55582cbc5288804 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:16:15 +0200 Subject: [PATCH 46/55] Update BrowserPage.xaml --- src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml index 5a56737f4..6d413dada 100644 --- a/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml @@ -103,6 +103,8 @@ Grid.RowSpan="2" CanCancel="{Binding ViewModel.TransferViewModel.CanCancel, Mode=OneWay}" CancelCommand="{Binding ViewModel.TransferViewModel.CancelCommand, Mode=OneWay}" + IsConfirmShown="{Binding ViewModel.TransferViewModel.IsPickingFolder, Mode=OneWay}" + IsError="{Binding ViewModel.TransferViewModel.IsErrorVisible, Mode=OneWay}" IsProgressing="{Binding ViewModel.TransferViewModel.IsProgressing, Mode=OneWay}" IsShown="{Binding ViewModel.TransferViewModel.IsVisible, Mode=OneWay}" PrimaryButtonText="{Binding ViewModel.TransferViewModel.TransferType, Mode=OneWay, Converter={StaticResource TransferTypeToOperationStringConverter}}" From 4d320305f1928432692eb0c57bcf48dbc4f32c90 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:30:55 +0200 Subject: [PATCH 47/55] Added error reporting for BrowserViewModel and FileViewModel --- .../Browser/BrowserControl.DragDrop.xaml.cs | 4 +- .../Controls/Storage/Browser/FileViewModel.cs | 70 ++++---- .../Views/Vault/BrowserViewModel.cs | 157 ++++++++++-------- 3 files changed, 135 insertions(+), 96 deletions(-) diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs index 5750951b8..ee6459f44 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs @@ -235,7 +235,7 @@ await transferViewModel.TransferAsync([ itemToMove ], async (item, reporter, tok } catch (OperationCanceledException) { - // Cancellation is user intent - nothing to report + // Cancellation, nothing to report } catch (Exception) { @@ -468,7 +468,7 @@ await transferViewModel.TransferAsync(itemsToProcess, async (item, reporter, tok } catch (OperationCanceledException) { - // Cancellation is user intent - nothing to report + // Cancellation, nothing to report } catch (Exception) { diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs index 35adda17f..b315f7a40 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs @@ -136,41 +136,53 @@ protected override async Task OpenAsync(CancellationToken cancellationToken) if (BrowserViewModel.TransferViewModel?.IsPickingItems() ?? false) return; - using var viewModel = new PreviewerOverlayViewModel(this, ParentFolder); - await viewModel.InitAsync(cancellationToken); - await OverlayService.ShowAsync(viewModel); - - if (BrowserViewModel.Options.IsReadOnly) - return; - - if (viewModel.PreviewerViewModel is IChangeTracker { WasModified: true } and IPersistable persistable) + try { - var messageOverlay = new MessageOverlayViewModel() - { - Title = "UnsavedChanges".ToLocalized(), - Message = "UnsavedChangesDescription".ToLocalized(), - PrimaryText = "Save".ToLocalized(), - SecondaryText = "Cancel".ToLocalized() - }; - - await Task.Delay(700); - var result = await OverlayService.ShowAsync(messageOverlay); - if (!result.Positive()) + using var viewModel = new PreviewerOverlayViewModel(this, ParentFolder); + await viewModel.InitAsync(cancellationToken); + await OverlayService.ShowAsync(viewModel); + + if (BrowserViewModel.Options.IsReadOnly) return; - if (BrowserViewModel.TransferViewModel is { } transferViewModel) + if (viewModel.PreviewerViewModel is IChangeTracker { WasModified: true } and IPersistable persistable) { - transferViewModel.TransferType = TransferType.Save; - using var saveCancellation = transferViewModel.GetCancellation(); - await transferViewModel.PerformOperationAsync(async ct => + var messageOverlay = new MessageOverlayViewModel() + { + Title = "UnsavedChanges".ToLocalized(), + Message = "UnsavedChangesDescription".ToLocalized(), + PrimaryText = "Save".ToLocalized(), + SecondaryText = "Cancel".ToLocalized() + }; + + await Task.Delay(700, CancellationToken.None); + var result = await OverlayService.ShowAsync(messageOverlay); + if (!result.Positive()) + return; + + if (BrowserViewModel.TransferViewModel is { } transferViewModel) { - await persistable.SaveAsync(ct); - }, saveCancellation.Token); + transferViewModel.TransferType = TransferType.Save; + using var saveCancellation = transferViewModel.GetCancellation(); + await transferViewModel.PerformOperationAsync(async ct => + { + await persistable.SaveAsync(ct); + }, saveCancellation.Token); + } + else + await persistable.SaveAsync(cancellationToken); + + await PerformFileLoadAsync(cancellationToken); } - else - await persistable.SaveAsync(cancellationToken); - - await PerformFileLoadAsync(cancellationToken); + } + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception) + { + if (BrowserViewModel.TransferViewModel is { } transferViewModel) + await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs index 9c965cdf4..ed0921ce4 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs @@ -281,22 +281,34 @@ protected virtual async Task NewItemAsync(string? itemType, CancellationToken ca var formattedName = CollisionHelpers.GetAvailableName( FormattingHelpers.SanitizeItemName(newItemViewModel.ItemName, "New item"), CurrentFolder.Items.Select(x => x.Inner.Name)); - switch (itemType) + try { - case "File": + switch (itemType) { - var file = await modifiableFolder.CreateFileAsync(formattedName, false, cancellationToken); - CurrentFolder.Items.Insert(new FileViewModel(file, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); - break; - } + case "File": + { + var file = await modifiableFolder.CreateFileAsync(formattedName, false, cancellationToken); + CurrentFolder.Items.Insert(new FileViewModel(file, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); + break; + } - case "Folder": - { - var folder = await modifiableFolder.CreateFolderAsync(formattedName, false, cancellationToken); - CurrentFolder.Items.Insert(new FolderViewModel(folder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); - break; + case "Folder": + { + var folder = await modifiableFolder.CreateFolderAsync(formattedName, false, cancellationToken); + CurrentFolder.Items.Insert(new FolderViewModel(folder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); + break; + } } } + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception) + { + if (TransferViewModel is not null) + await TransferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + } } [RelayCommand] @@ -324,77 +336,92 @@ protected virtual async Task ImportItemAsync(string? itemType, CancellationToken itemType = storableTypeViewModel.SelectedOption ?? storableTypeViewModel.StorableType.ToString(); } - switch (itemType) + try { - case "File": + switch (itemType) { - var file = await FileExplorerService.PickFileAsync(null, false, cancellationToken); - if (file is null) - return; - - TransferViewModel.TransferType = TransferType.Copy; - using var cts = TransferViewModel.GetCancellation(cancellationToken); - await TransferViewModel.TransferAsync([ file ], async (item, token) => + case "File": { - // Get available name to avoid collision - var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); + var file = await FileExplorerService.PickFileAsync(null, false, cancellationToken); + if (file is null) + return; - // Copy - var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); + TransferViewModel.TransferType = TransferType.Copy; + using var cts = TransferViewModel.GetCancellation(cancellationToken); + await TransferViewModel.TransferAsync([ file ], async (item, token) => + { + // Get available name to avoid collision + var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); - // Add to destination - CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); - }, cts.Token); + // Copy + var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); - break; - } + // Add to destination + CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); + }, cts.Token); - case "Folder": - { - var folder = await FileExplorerService.PickFolderAsync(null, false, cancellationToken); - if (folder is null) - return; + break; + } - TransferViewModel.TransferType = TransferType.Copy; - using var cts = TransferViewModel.GetCancellation(cancellationToken); - await TransferViewModel.TransferAsync([ folder ], async (item, reporter, token) => + case "Folder": { - // Get available name to avoid collision - var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); + var folder = await FileExplorerService.PickFolderAsync(null, false, cancellationToken); + if (folder is null) + return; - // Copy - var copiedFolder = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, reporter, token); + TransferViewModel.TransferType = TransferType.Copy; + using var cts = TransferViewModel.GetCancellation(cancellationToken); + await TransferViewModel.TransferAsync([ folder ], async (item, reporter, token) => + { + // Get available name to avoid collision + var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); - // Add to destination - CurrentFolder.Items.Insert(new FolderViewModel(copiedFolder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); - }, cts.Token); + // Copy + var copiedFolder = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, reporter, token); - break; - } + // Add to destination + CurrentFolder.Items.Insert(new FolderViewModel(copiedFolder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); + }, cts.Token); - case var t when t == "Gallery".ToLocalized(): - { - var galleryItems = (await FileExplorerService.PickGalleryItemsAsync(cancellationToken)).OfType().ToArray(); - if (galleryItems.Length == 0) - return; + break; + } - TransferViewModel.TransferType = TransferType.Copy; - using var cts = TransferViewModel.GetCancellation(cancellationToken); - await TransferViewModel.TransferAsync(galleryItems, async (item, token) => + case var t when t == "Gallery".ToLocalized(): { - // Get available name to avoid collision - var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); - - // Copy - var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); - - // Add to destination - CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); - }, cts.Token); - - break; + var galleryItems = (await FileExplorerService.PickGalleryItemsAsync(cancellationToken)).OfType().ToArray(); + if (galleryItems.Length == 0) + return; + + TransferViewModel.TransferType = TransferType.Copy; + using var cts = TransferViewModel.GetCancellation(cancellationToken); + await TransferViewModel.TransferAsync(galleryItems, async (item, token) => + { + // Get available name to avoid collision + var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); + + // Copy + var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); + + // Add to destination + CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); + }, cts.Token); + + break; + } } } + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception) + { + await TransferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + } + finally + { + await TransferViewModel.HideAsync(); + } } /// From e097c40f5daf1a05e0273ecf1ea18737d1909443 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:49:16 +0200 Subject: [PATCH 48/55] Minor fixes --- .../SecureFolderFS.Core.WinFsp.csproj | 2 +- .../SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs | 3 ++- .../SecureFolderFS.Uno/UserControls/LoginOptions.xaml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj b/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj index f4ebea679..fb8bff9bf 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj +++ b/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs b/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs index c0d15dfab..fb6f43738 100644 --- a/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs +++ b/src/Platforms/SecureFolderFS.Uno/Helpers/WebDavRedirectorHelpers.cs @@ -35,7 +35,8 @@ public static class WebDavRedirectorHelpers using var parametersKey = Registry.LocalMachine.OpenSubKey(WEBCLIENT_PARAMETERS_KEY); return parametersKey?.GetValue(FILE_SIZE_LIMIT_VALUE) switch { - int value => unchecked((uint)value), + int value when value < 0 => null, + int value => value > 0 ? unchecked((uint)value) : null, // The WebDAV redirector falls back to the default limit when the value is not present null => DEFAULT_FILE_SIZE_LIMIT, diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml index f4e0f0f53..9a78efa51 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml @@ -20,7 +20,7 @@ Content="{l:ResourceString Rid=SaveCredentials}" IsChecked="{x:Bind ShouldSaveCredentials, Mode=TwoWay}" IsEnabled="False" - Visibility="{x:Bind AreCredentialsSaved, Mode=OneWay, Converter={StaticResource BoolInvertConverter}}" /> + Visibility="{x:Bind AreCredentialsSaved, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter='invert'}" />