diff --git a/global.json b/global.json index 5cdd01fa3..bd19c39ea 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "msbuild-sdks": { - "Uno.Sdk": "6.5.31" + "Uno.Sdk": "6.6.29" } } \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs index 4a05e6716..3ad369940 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs @@ -251,8 +251,10 @@ public override void Cleanup(string fileName, IDokanFileInfo info) NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.File); } } - catch (UnauthorizedAccessException) + catch (Exception) { + // An exception must never escape into the driver callback. Concurrent deletes + // can surface IOException (sharing violations) besides UnauthorizedAccessException. } } } @@ -492,6 +494,10 @@ public override NtStatus DeleteFile(string fileName, IDokanFileInfo info) if (ciphertextPath is null) return Trace(NtStatus.ObjectPathInvalid, fileName, info); + // Protect core files from deletion + if (PathHelpers.IsCoreName(Path.GetFileName(ciphertextPath))) + return Trace(DokanResult.AccessDenied, fileName, info); + // Perform checks if (Directory.Exists(ciphertextPath)) return Trace(DokanResult.AccessDenied, fileName, info); @@ -516,6 +522,10 @@ public override NtStatus DeleteDirectory(string fileName, IDokanFileInfo info) if (ciphertextPath is null) return Trace(NtStatus.ObjectPathInvalid, fileName, info); + // Protect core folders from deletion + if (PathHelpers.IsCoreName(Path.GetFileName(ciphertextPath))) + return Trace(DokanResult.AccessDenied, fileName, info); + try { using var directoryEnumerator = Directory.EnumerateFileSystemEntries(ciphertextPath).GetEnumerator(); diff --git a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs index f09d7a998..17519b5a7 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs @@ -1,10 +1,13 @@ using System.Text; +using OwlCore.Storage; using SecureFolderFS.Core.FileSystem; using SecureFolderFS.Core.FileSystem.Helpers.Paths; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native; +using SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native; using SecureFolderFS.Core.FUSE.OpenHandles; using SecureFolderFS.Core.FUSE.UnsafeNative; +using SecureFolderFS.Storage.Extensions; using Tmds.Fuse; using Tmds.Linux; using static SecureFolderFS.Core.FUSE.UnsafeNative.UnsafeNativeApis; @@ -470,19 +473,58 @@ public override unsafe int RmDir(ReadOnlySpan path) if (ciphertextPath is null) return -ENOENT; - if (Directory.EnumerateFileSystemEntries(ciphertextPath).Any(x => !PathHelpers.IsCoreName(x))) + // Protect core folders from deletion + if (PathHelpers.IsCoreName(Path.GetFileName(Path.TrimEndingDirectorySeparator(ciphertextPath)))) + return -EACCES; + + if (Directory.EnumerateFileSystemEntries(ciphertextPath).Any(x => !PathHelpers.IsCoreName(Path.GetFileName(x)))) return -ENOTEMPTY; var directoryIdPath = Path.Combine(ciphertextPath, FileSystem.Constants.Names.DIRECTORY_ID_FILENAME); + specifics.DirectoryIdCache.CacheRemove(directoryIdPath); + + if (FuseOptions.IsRecycleBinEnabled()) + { + try + { + // The folder is moved into the recycle bin together with its DirectoryID file + NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.Folder); + return 0; + } + catch (UnauthorizedAccessException) + { + return -EACCES; + } + catch (Exception) + { + return -EIO; + } + } - // Remove DirectoryID + // Read the DirectoryID so it can be restored if rmdir fails. + // Deleting it permanently while the folder survives would make its contents undecryptable + var directoryId = File.Exists(directoryIdPath) ? File.ReadAllBytes(directoryIdPath) : null; File.Delete(directoryIdPath); - specifics.DirectoryIdCache.CacheRemove(directoryIdPath); fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) { if (rmdir(ciphertextPathPtr) == -1) - return -errno; + { + var error = errno; + if (directoryId is null) + return -error; + + try + { + File.WriteAllBytes(directoryIdPath, directoryId); + } + catch (Exception) + { + // Best effort - the directory may have been removed concurrently + } + + return -error; + } } return 0; @@ -590,6 +632,27 @@ public override unsafe int Unlink(ReadOnlySpan path) if (Directory.Exists(ciphertextPath)) return -EISDIR; + // Protect core files from deletion + if (PathHelpers.IsCoreName(Path.GetFileName(ciphertextPath))) + return -EACCES; + + if (FuseOptions.IsRecycleBinEnabled()) + { + try + { + NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.File); + return 0; + } + catch (UnauthorizedAccessException) + { + return -EACCES; + } + catch (Exception) + { + return -EIO; + } + } + fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) { if (unlink(ciphertextPathPtr) == -1) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs b/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs index 6f90d82f0..6f31b26dc 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs @@ -6,6 +6,16 @@ public static class Constants public const string UNC_NAME = "securefolderfs"; public const int FILE_EOF = 0; public const int DIRECTORY_ID_SIZE = 16; + + /// + /// The time window within which previously recycled items are folded back into a + /// recycled parent folder. OS clients (Finder, Explorer, WebDav/FUSE drivers) delete + /// folder trees member-by-member; when the parent folder finally arrives at the recycle + /// bin, children recycled within this window are reattached to it so the tree appears + /// as a single restorable entry. Membership is proven by Directory ID, so this window + /// only limits how far back unrelated same-folder deletions are pulled in. + /// + public const long RECYCLE_BIN_FOLD_WINDOW_MS = 60L * 60L * 1000L; public const ulong INVALID_HANDLE = 0UL; public const bool OPT_IN_FOR_OPTIONAL_DEBUG_TRACING = false; diff --git a/src/Core/SecureFolderFS.Core.FileSystem/FileSystemSpecifics.cs b/src/Core/SecureFolderFS.Core.FileSystem/FileSystemSpecifics.cs index 39e1b1efb..ad9ec464f 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/FileSystemSpecifics.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/FileSystemSpecifics.cs @@ -5,6 +5,7 @@ using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.VirtualFileSystem; using System; +using System.Threading; namespace SecureFolderFS.Core.FileSystem { @@ -48,6 +49,11 @@ public sealed class FileSystemSpecifics : IDisposable /// public required UniversalCache PlaintextFileNameCache { get; init; } + /// + /// Gets the lock that serializes recycle bin quota checks and occupied-size updates. + /// + public SemaphoreSlim RecycleBinSemaphore { get; } = new(1, 1); + private FileSystemSpecifics() { } @@ -55,6 +61,7 @@ private FileSystemSpecifics() /// public void Dispose() { + RecycleBinSemaphore.Dispose(); PlaintextFileNameCache.Dispose(); CiphertextFileNameCache.Dispose(); DirectoryIdCache.Dispose(); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs index 23dc77ee5..7d2314936 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -6,7 +7,6 @@ using SecureFolderFS.Core.FileSystem.DataModels; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Shared.ComponentModel; -using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; @@ -16,7 +16,7 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Abstract public static partial class AbstractRecycleBinHelpers { /// - /// The threshold in seconds for detecting recently created files. + /// The threshold in milliseconds for detecting recently created files. /// Files created within this threshold will be deleted immediately instead of recycled. /// This helps work around macOS Finder behavior during copy operations. /// @@ -68,49 +68,31 @@ public static async Task RestoreAsync(IStorableChild recycleBinItem, IModifiable throw new FormatException("Could not deserialize recycle bin configuration file."); // Get the plaintext name and parent ID - var plaintextParentPath = deserialized.DecryptParentId(specifics.Security); var plaintextOriginalName = deserialized.DecryptName(specifics.Security); - if (plaintextOriginalName is null || plaintextParentPath is null) + if (plaintextOriginalName is null) throw new FormatException("Could not decrypt recycle bin configuration file."); - var ciphertextParentFolder = await SafetyHelpers.NoFailureAsync(async () => await AbstractPathHelpers.GetCiphertextItemAsync(plaintextParentPath, specifics, cancellationToken) as IFolder); - if (string.IsNullOrEmpty(ciphertextParentFolder?.Id) || !ciphertextDestinationFolder.Id.EndsWith(ciphertextParentFolder.Id)) - { - // Destination folder is different from the original destination - // A new item name should be chosen fit for the new folder (so that Directory ID match) - var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken); + // The name is always re-encrypted for the destination folder so that the + // ciphertext name matches the destination's Directory ID, whether the destination is the original parent. + var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken); - // Get an available name if the destination already exists - ciphertextName = await GetAvailableDestinationNameAsync(ciphertextDestinationFolder, ciphertextName, plaintextOriginalName, specifics, cancellationToken); + // Get an available name if the destination already exists + ciphertextName = await GetAvailableDestinationNameAsync(ciphertextDestinationFolder, ciphertextName, plaintextOriginalName, specifics, cancellationToken); - // Rename and move item to destination - _ = await ciphertextDestinationFolder.MoveStorableFromAsync(recycleBinItem, modifiableRecycleBin, false, ciphertextName, null, cancellationToken); - } - else - { - // Destination folder is the same as the original destination - // The same name could be used since the Directory IDs match - var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken); - - // Get an available name if the destination already exists - ciphertextName = await GetAvailableDestinationNameAsync(ciphertextDestinationFolder, ciphertextName, plaintextOriginalName, specifics, cancellationToken); - - // Rename and move item to destination - _ = await ciphertextDestinationFolder.MoveStorableFromAsync(recycleBinItem, modifiableRecycleBin, false, ciphertextName, null, cancellationToken); - } + // Rename and move item to destination + _ = await ciphertextDestinationFolder.MoveStorableFromAsync(recycleBinItem, modifiableRecycleBin, false, ciphertextName, null, cancellationToken); // Delete the old configuration file - var configurationFile = await recycleBin.GetFileByNameAsync($"{recycleBinItem.Name}.json", cancellationToken); - await modifiableRecycleBin.DeleteAsync(configurationFile, cancellationToken); + var configurationFile = await recycleBin.TryGetFileByNameAsync($"{recycleBinItem.Name}.json", cancellationToken); + if (configurationFile is not null) + await modifiableRecycleBin.DeleteAsync(configurationFile, cancellationToken); // Check if the item had any size if (deserialized.Size is not ({ } size and > 0L)) return; // Update occupied size - var occupiedSize = await GetOccupiedSizeAsync(modifiableRecycleBin, cancellationToken); - var newSize = occupiedSize - size; - await SetOccupiedSizeAsync(modifiableRecycleBin, newSize, cancellationToken); + await AdjustOccupiedSizeAsync(modifiableRecycleBin, specifics, -size, cancellationToken); } public static async Task DeleteOrRecycleAsync( @@ -163,32 +145,29 @@ public static async Task DeleteOrRecycleAsync( if (recycleBin is not IModifiableFolder modifiableRecycleBin) throw new UnauthorizedAccessException("The recycle bin is not modifiable."); - if (sizeHint < 0L && specifics.Options.RecycleBinSize > 0L) - { - sizeHint = ciphertextItem switch - { - IFile file => await file.GetSizeAsync(cancellationToken) ?? 0L, - IFolder folder => await folder.GetSizeAsync(cancellationToken) ?? 0L, - _ => 0L - }; + // The size is always calculated (even for unlimited-capacity bins) so that + // the occupied-size accounting stays accurate if a limit is set later on. + // Sizes are measured in plaintext bytes on every code path. + if (sizeHint < 0L) + sizeHint = await GetPlaintextSizeAsync(ciphertextItem, specifics, cancellationToken); + sizeHint = Math.Max(0L, sizeHint); + await specifics.RecycleBinSemaphore.WaitAsync(cancellationToken); + try + { var occupiedSize = await GetOccupiedSizeAsync(modifiableRecycleBin, cancellationToken); - var availableSize = specifics.Options.RecycleBinSize - occupiedSize; - if (availableSize < sizeHint) + if (specifics.Options.RecycleBinSize > 0L && specifics.Options.RecycleBinSize - occupiedSize < sizeHint) { await DeleteImmediatelyAsync(ciphertextSourceFolder, ciphertextItem, cancellationToken); return; } - } - // Rename and move item - var guid = Guid.NewGuid().ToString(); - _ = await modifiableRecycleBin.MoveStorableFromAsync(ciphertextItem, ciphertextSourceFolder, false, guid, null, cancellationToken); + // The folder's original plaintext path must be captured before the move as it is + // the key that previously recycled children are folded back in by + var plaintextFolderPath = ciphertextItem is IFolder + ? await SafetyHelpers.NoFailureAsync(async () => await AbstractPathHelpers.GetPlaintextPathAsync(ciphertextItem, specifics, cancellationToken)) + : null; - // Create configuration file - var configurationFile = await modifiableRecycleBin.CreateFileAsync($"{guid}.json", false, cancellationToken); - await using (var configurationStream = await configurationFile.OpenWriteAsync(cancellationToken)) - { // Decrypt the plaintext parent ID var plaintextParentId = await AbstractPathHelpers.GetPlaintextPathAsync((IStorableChild)ciphertextSourceFolder, specifics, cancellationToken); if (plaintextParentId is null) @@ -200,29 +179,54 @@ public static async Task DeleteOrRecycleAsync( // Encrypt the new plaintext name and parent ID var newCiphertextName = RecycleBinItemDataModel.Encrypt(plaintextName, specifics.Security, isDirectoryIdPresent ? directoryId : ReadOnlySpan.Empty); var newCiphertextParentId = RecycleBinItemDataModel.Encrypt(plaintextParentId, specifics.Security, isDirectoryIdPresent ? directoryId : ReadOnlySpan.Empty); + var dataModel = new RecycleBinItemDataModel() + { + Name = newCiphertextName, + ParentId = newCiphertextParentId, + DirectoryId = isDirectoryIdPresent ? directoryId : [], + DeletionTimestamp = DateTime.Now, + Size = sizeHint + }; - // Serialize configuration data model - await using var serializedStream = await streamSerializer.SerializeAsync( - new RecycleBinItemDataModel() - { - Name = newCiphertextName, - ParentId = newCiphertextParentId, - DirectoryId = isDirectoryIdPresent ? directoryId : [], - DeletionTimestamp = DateTime.Now, - Size = sizeHint - }, cancellationToken); - - // Write to destination stream - await serializedStream.CopyToAsync(configurationStream, cancellationToken); - await configurationStream.FlushAsync(cancellationToken); - } + // Create the configuration file before moving the payload. If the move fails, + // the leftover configuration file is harmless and gets cleaned up on recalculation, + // whereas a payload without a configuration file would be unrestorable. + var guid = Guid.NewGuid().ToString(); + var configurationFile = await modifiableRecycleBin.CreateFileAsync($"{guid}.json", false, cancellationToken); + await WriteItemDataModelAsync(configurationFile, dataModel, streamSerializer, cancellationToken); - // Update occupied size - if (specifics.Options.IsRecycleBinEnabled()) + IStorableChild movedItem; + try + { + // Rename and move item + movedItem = await modifiableRecycleBin.MoveStorableFromAsync(ciphertextItem, ciphertextSourceFolder, false, guid, null, cancellationToken); + } + catch (Exception) + { + // Best-effort cleanup of the now-orphaned configuration file + await SafetyHelpers.NoFailureAsync(async () => await modifiableRecycleBin.DeleteAsync(configurationFile, CancellationToken.None)); + throw; + } + + // Reattach previously recycled children of this folder so that trees deleted + // member-by-member appear as a single restorable entry + if (plaintextFolderPath is not null && movedItem is IModifiableFolder recycledFolder) + { + var foldedSize = await SafetyHelpers.NoFailureAsync(async () => + await FoldDescendantEntriesAsync(modifiableRecycleBin, recycledFolder, plaintextFolderPath, specifics, streamSerializer, cancellationToken)); + + // The folded sizes were already part of the occupied total; only the + // folder's own entry needs to account for its regained contents + if (foldedSize > 0L) + await WriteItemDataModelAsync(configurationFile, dataModel with { Size = sizeHint + foldedSize }, streamSerializer, cancellationToken); + } + + // Update occupied size + await SetOccupiedSizeAsync(modifiableRecycleBin, occupiedSize + sizeHint, cancellationToken); + } + finally { - var occupiedSize = await GetOccupiedSizeAsync(modifiableRecycleBin, cancellationToken); - var newSize = occupiedSize + sizeHint; - await SetOccupiedSizeAsync(modifiableRecycleBin, newSize, cancellationToken); + _ = specifics.RecycleBinSemaphore.Release(); } static async Task DeleteImmediatelyAsync(IModifiableFolder ciphertextSourceFolder, IStorableChild ciphertextItem, CancellationToken cancellationToken) @@ -231,24 +235,121 @@ static async Task DeleteImmediatelyAsync(IModifiableFolder ciphertextSourceFolde } } + /// + /// Folds previously recycled children of the folder at back into + /// its recycled payload. OS clients (Finder, Explorer, WebDav/FUSE drivers) delete folder trees + /// member-by-member, bottom-up; when the parent folder itself arrives at the recycle bin, every entry + /// that was deleted out of it can be reattached so the tree appears as a single restorable entry. + ///

+ /// Membership is proven by comparing each entry's stored Directory ID with the recycled folder's + /// dirid.iv - an exact lineage match that a recreated folder at the same path cannot satisfy. + /// The original ciphertext names are reproduced exactly because name encryption is deterministic. + ///
+ /// + /// This method must be invoked while holding . + /// + /// The accumulated size in bytes of all folded entries. + internal static async Task FoldDescendantEntriesAsync( + IModifiableFolder recycleBin, + IModifiableFolder recycledFolder, + string plaintextFolderPath, + FileSystemSpecifics specifics, + IAsyncSerializer streamSerializer, + CancellationToken cancellationToken) + { + // Read the recycled folder's Directory ID - children of this exact folder + // incarnation carry it in their configuration files + var directoryIdFile = await recycledFolder.TryGetFileByNameAsync(Constants.Names.DIRECTORY_ID_FILENAME, cancellationToken); + if (directoryIdFile is null) + return 0L; + + var folderDirectoryId = new byte[Constants.DIRECTORY_ID_SIZE]; + await using (var directoryIdStream = await directoryIdFile.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken)) + { + if (await directoryIdStream.ReadAtLeastAsync(folderDirectoryId, Constants.DIRECTORY_ID_SIZE, false, cancellationToken) < Constants.DIRECTORY_ID_SIZE) + return 0L; + } + + // Snapshot the configuration files first - folding mutates the recycle bin contents + var configurationFiles = new List(); + await foreach (var item in recycleBin.GetItemsAsync(StorableType.File, cancellationToken)) + { + if (item is IChildFile file && file.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + configurationFiles.Add(file); + } + + var foldedSize = 0L; + var normalizedFolderPath = Path.TrimEndingDirectorySeparator(plaintextFolderPath); + foreach (var configurationFile in configurationFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + + // A single unreadable entry must not abandon the remaining ones + await SafetyHelpers.NoFailureAsync(async () => + { + var dataModel = await GetItemDataModelAsync(configurationFile, recycleBin, streamSerializer, cancellationToken); + if (dataModel is not { Name: not null, ParentId: not null, DirectoryId: { Length: Constants.DIRECTORY_ID_SIZE } childDirectoryId }) + return; + + // Lineage check: the entry must have been deleted out of this exact folder incarnation + if (!childDirectoryId.AsSpan().SequenceEqual(folderDirectoryId)) + return; + + // Recency check: don't silently pull in unrelated deletions from long ago + if (dataModel.DeletionTimestamp is not { } deletionTimestamp + || Math.Abs((DateTime.Now - deletionTimestamp).TotalMilliseconds) > Constants.RECYCLE_BIN_FOLD_WINDOW_MS) + return; + + // Sanity check: the original parent path must match the folder being recycled + var plaintextParentPath = dataModel.DecryptParentId(specifics.Security); + if (plaintextParentPath is null || Path.TrimEndingDirectorySeparator(plaintextParentPath) != normalizedFolderPath) + return; + + var childPlaintextName = dataModel.DecryptName(specifics.Security); + if (childPlaintextName is null) + return; + + // Get the payload; configurations without one are cleaned up on recalculation + var payloadName = Path.GetFileNameWithoutExtension(configurationFile.Name); + if (await recycleBin.TryGetFirstByNameAsync(payloadName, cancellationToken) is not IStorableChild payload) + return; + + // Reconstruct the child's original ciphertext name (deterministic for the folder's Directory ID). + // On a name collision the entry is left standalone instead of being overwritten + var ciphertextChildName = AbstractPathHelpers.EncryptNewName(childPlaintextName, folderDirectoryId, specifics.Security); + if (await recycledFolder.TryGetFirstByNameAsync(ciphertextChildName, cancellationToken) is not null) + return; + + // Reattach the payload and discard the now-redundant configuration file + _ = await recycledFolder.MoveStorableFromAsync(payload, recycleBin, false, ciphertextChildName, null, cancellationToken); + await recycleBin.DeleteAsync(configurationFile, cancellationToken); + + if (dataModel.Size is { } size and > 0L) + foldedSize += size; + }); + } + + return foldedSize; + } + private static async Task GetAvailableDestinationNameAsync(IFolder ciphertextDestinationFolder, string ciphertextName, string plaintextOriginalName, FileSystemSpecifics specifics, CancellationToken cancellationToken) { // Check if the item already exists var existing = await ciphertextDestinationFolder.TryGetFirstByNameAsync(ciphertextName, cancellationToken); - if (existing is not null) + if (existing is null) + return ciphertextName; + + // If the item already exists, append a suffix to the name + var nameWithoutExtension = Path.GetFileNameWithoutExtension(plaintextOriginalName); + var extension = Path.GetExtension(plaintextOriginalName); + var suffix = 1; + do { - // If the item already exists, append a suffix to the name - var nameWithoutExtension = Path.GetFileNameWithoutExtension(plaintextOriginalName); - var extension = Path.GetExtension(plaintextOriginalName); - var suffix = 1; - do - { - var newPlaintextName = $"{nameWithoutExtension} ({suffix}){extension}"; - ciphertextName = await AbstractPathHelpers.EncryptNameAsync(newPlaintextName, ciphertextDestinationFolder, specifics, cancellationToken); - existing = await ciphertextDestinationFolder.TryGetFirstByNameAsync(ciphertextName, cancellationToken); - suffix++; - } while (existing is not null); - } + var newPlaintextName = $"{nameWithoutExtension} ({suffix}){extension}"; + ciphertextName = await AbstractPathHelpers.EncryptNameAsync(newPlaintextName, ciphertextDestinationFolder, specifics, cancellationToken); + existing = await ciphertextDestinationFolder.TryGetFirstByNameAsync(ciphertextName, cancellationToken); + suffix++; + } while (existing is not null); return ciphertextName; } @@ -262,8 +363,11 @@ private static async Task IsRecentlyCreatedAsync(IStorable storable, Cance return false; var dateCreatedUtc = dateCreated.Value.ToUniversalTime(); - var timeSinceCreation = DateTime.UtcNow - dateCreatedUtc; - return timeSinceCreation.Seconds <= RECENT_FILE_THRESHOLD_MS / 1000; + var elapsedMilliseconds = (DateTime.UtcNow - dateCreatedUtc).TotalMilliseconds; + + // Negative elapsed time indicates clock skew. + // Never treat such files as recently created, as that would permanently delete them + return elapsedMilliseconds is >= 0d and <= RECENT_FILE_THRESHOLD_MS; } catch { diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Shared.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Shared.cs index 844af59aa..e83f5f2ed 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Shared.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Shared.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using OwlCore.Storage; using SecureFolderFS.Core.FileSystem.DataModels; +using SecureFolderFS.Core.FileSystem.Helpers.Paths; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; @@ -14,32 +16,185 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Abstract { public static partial class AbstractRecycleBinHelpers { - public static async Task GetOccupiedSizeAsync(IModifiableFolder recycleBin, CancellationToken cancellationToken = default) + private const int TRANSIENT_IO_RETRIES = 3; + private const int TRANSIENT_IO_RETRY_DELAY_MS = 60; + + /// + /// Reads the occupied size while holding , + /// so the read never collides with an in-flight occupied-size update. + /// + public static async Task GetOccupiedSizeAsync(IModifiableFolder recycleBin, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) { - var recycleBinConfig = await recycleBin.TryGetFileByNameAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, cancellationToken); - recycleBinConfig ??= await recycleBin.CreateFileAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, false, cancellationToken); + await specifics.RecycleBinSemaphore.WaitAsync(cancellationToken); + try + { + return await GetOccupiedSizeAsync(recycleBin, cancellationToken); + } + finally + { + _ = specifics.RecycleBinSemaphore.Release(); + } + } + + public static Task GetOccupiedSizeAsync(IModifiableFolder recycleBin, CancellationToken cancellationToken = default) + { + return WithTransientIoRetryAsync(async () => + { + // Reading must not create the configuration file - reads can happen on read-only file systems + var recycleBinConfig = await recycleBin.TryGetFileByNameAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, cancellationToken); + if (recycleBinConfig is null) + return 0L; - await using var configStream = await recycleBinConfig.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken); - var deserialized = await StreamSerializer.Instance.TryDeserializeAsync(configStream, cancellationToken); - if (deserialized is null) + await using var configStream = await recycleBinConfig.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken); + var deserialized = await StreamSerializer.Instance.TryDeserializeAsync(configStream, cancellationToken); + if (deserialized is null) + return 0L; + + return Math.Max(0L, deserialized.OccupiedSize); + }, cancellationToken); + } + + public static Task SetOccupiedSizeAsync(IModifiableFolder recycleBin, long value, CancellationToken cancellationToken = default) + { + return WithTransientIoRetryAsync(async () => + { + // Recreate the file instead of opening it for write - streams returned by + // OpenWriteAsync do not truncate, which leaves stale tail bytes (and therefore + // unparseable JSON) whenever the serialized payload shrinks + var recycleBinConfig = await recycleBin.CreateFileAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, true, cancellationToken); + + await using var configStream = await recycleBinConfig.OpenWriteAsync(cancellationToken); + if (configStream.CanSeek) + configStream.SetLength(0L); + + await using var serialized = await StreamSerializer.Instance.SerializeAsync(new RecycleBinDataModel() + { + OccupiedSize = Math.Max(0L, value) + }, cancellationToken); + + await serialized.CopyToAsync(configStream, cancellationToken); + await configStream.FlushAsync(cancellationToken); + return null; + }, cancellationToken); + } + + /// + /// Retries on sharing violations. File handles can be held briefly + /// by parties outside the (e.g. antivirus + /// or indexing services), which must not surface as errors for an otherwise valid operation. + /// + private static async Task WithTransientIoRetryAsync(Func> action, CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + return await action(); + } + catch (Exception ex) when (attempt < TRANSIENT_IO_RETRIES && ex is IOException or UnauthorizedAccessException) + { + await Task.Delay(TRANSIENT_IO_RETRY_DELAY_MS * attempt, cancellationToken); + } + } + } + + /// + /// Atomically adds to the occupied size of the recycle bin. + /// + public static async Task AdjustOccupiedSizeAsync(IModifiableFolder recycleBin, FileSystemSpecifics specifics, long delta, CancellationToken cancellationToken = default) + { + await specifics.RecycleBinSemaphore.WaitAsync(cancellationToken); + try + { + var occupiedSize = await GetOccupiedSizeAsync(recycleBin, cancellationToken); + await SetOccupiedSizeAsync(recycleBin, occupiedSize + delta, cancellationToken); + } + finally + { + _ = specifics.RecycleBinSemaphore.Release(); + } + } + + /// + /// Measures the plaintext size in bytes of a ciphertext item. Folder sizes are walked iteratively. + /// + public static async Task GetPlaintextSizeAsync(IStorableChild ciphertextItem, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + { + return ciphertextItem switch + { + IFile file => CalculatePlaintextSize(await GetCiphertextFileSizeAsync(file, cancellationToken), specifics), + IFolder folder => await GetFolderPlaintextSizeAsync(folder, specifics, cancellationToken), + _ => 0L + }; + } + + private static async Task GetCiphertextFileSizeAsync(IFile ciphertextFile, CancellationToken cancellationToken) + { + // Raw ciphertext files usually don't implement ISizeOf - fall back to the stream length + var size = await ciphertextFile.GetSizeAsync(cancellationToken); + if (size is not null) + return size.Value; + + try + { + await using var stream = await ciphertextFile.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken); + return stream.Length; + } + catch (Exception) + { return 0L; + } + } - return Math.Max(0L, deserialized.OccupiedSize); + private static long CalculatePlaintextSize(long ciphertextLength, FileSystemSpecifics specifics) + { + return Math.Max(0L, specifics.Security.ContentCrypt.CalculatePlaintextSize( + Math.Max(0L, ciphertextLength - specifics.Security.HeaderCrypt.HeaderCiphertextSize))); } - public static async Task SetOccupiedSizeAsync(IModifiableFolder recycleBin, long value, CancellationToken cancellationToken = default) + private static async Task GetFolderPlaintextSizeAsync(IFolder ciphertextFolder, FileSystemSpecifics specifics, CancellationToken cancellationToken) { - var recycleBinConfig = await recycleBin.TryGetFileByNameAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, cancellationToken); - recycleBinConfig ??= await recycleBin.CreateFileAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, false, cancellationToken); + var totalSize = 0L; + var stack = new Stack(); + stack.Push(ciphertextFolder); - await using var configStream = await recycleBinConfig.OpenWriteAsync(cancellationToken); - await using var serialized = await StreamSerializer.Instance.SerializeAsync(new RecycleBinDataModel() + while (stack.Count > 0) { - OccupiedSize = Math.Max(0L, value) - }, cancellationToken); + var current = stack.Pop(); + await foreach (var item in current.GetItemsAsync(StorableType.All, cancellationToken)) + { + switch (item) + { + case IFile file when !PathHelpers.IsCoreName(file.Name): + totalSize += CalculatePlaintextSize(await GetCiphertextFileSizeAsync(file, cancellationToken), specifics); + break; + + case IFolder subFolder: + stack.Push(subFolder); + break; + } + } + } + + return totalSize; + } + + /// + /// Serializes into , truncating any previous content. + /// + internal static Task WriteItemDataModelAsync(IFile configurationFile, RecycleBinItemDataModel dataModel, IAsyncSerializer streamSerializer, CancellationToken cancellationToken) + { + return WithTransientIoRetryAsync(async () => + { + await using var configurationStream = await configurationFile.OpenWriteAsync(cancellationToken); + if (configurationStream.CanSeek) + configurationStream.SetLength(0L); - await serialized.CopyToAsync(configStream, cancellationToken); - await configStream.FlushAsync(cancellationToken); + await using var serializedStream = await streamSerializer.SerializeAsync(dataModel, cancellationToken); + await serializedStream.CopyToAsync(configurationStream, cancellationToken); + await configurationStream.FlushAsync(cancellationToken); + return null; + }, cancellationToken); } public static async Task GetItemDataModelAsync(IStorableChild item, IFolder recycleBin, IAsyncSerializer streamSerializer, CancellationToken cancellationToken = default) @@ -49,11 +204,15 @@ public static async Task GetItemDataModelAsync(IStorabl ? await recycleBin.GetFileByNameAsync($"{item.Name}.json", cancellationToken) : (IFile)item; - // Read configuration file - await using var configurationStream = await configurationFile.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken); + var deserialized = await WithTransientIoRetryAsync(async () => + { + // Read configuration file + await using var configurationStream = await configurationFile.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken); + + // Deserialize configuration + return await streamSerializer.DeserializeAsync(configurationStream, cancellationToken); + }, cancellationToken); - // Deserialize configuration - var deserialized = await streamSerializer.DeserializeAsync(configurationStream, cancellationToken); if (deserialized is not { ParentId: not null }) throw new FormatException("Could not deserialize recycle bin configuration file."); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs index df8cd0de7..5beb09080 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.IO; using OwlCore.Storage; using SecureFolderFS.Core.FileSystem.DataModels; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; @@ -13,6 +14,13 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native { public static partial class NativeRecycleBinHelpers { + /// + /// The threshold in milliseconds for detecting recently created files. + /// Files created within this threshold will be deleted immediately instead of recycled. + /// This helps work around macOS Finder behavior during copy operations. + /// + private const int RECENT_FILE_THRESHOLD_MS = 3000; + public static void DeleteOrRecycle(string ciphertextPath, FileSystemSpecifics specifics, StorableType storableType, long sizeHint = -1L) { if (specifics.Options.IsReadOnly) @@ -38,80 +46,119 @@ public static void DeleteOrRecycle(string ciphertextPath, FileSystemSpecifics sp // Check for wildcard file names if (OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst()) { - if (plaintextName == ".DS_Store" || (plaintextName?.StartsWith("._", StringComparison.Ordinal) ?? false)) + if (plaintextName == ".DS_Store" || plaintextName.StartsWith("._", StringComparison.Ordinal)) { // .DS_Store and Apple Double files are unsupported by the recycle bin, delete immediately DeleteImmediately(ciphertextPath, storableType); return; } + + // Check if the file was recently created (likely part of a copy operation) + // On macOS, Finder creates files and immediately deletes them during copy operations + if (storableType == StorableType.File && IsRecentlyCreated(ciphertextPath)) + { + DeleteImmediately(ciphertextPath, storableType); + return; + } } var recycleBinPath = Path.Combine(specifics.ContentFolder.Id, Constants.Names.RECYCLE_BIN_NAME); _ = Directory.CreateDirectory(recycleBinPath); - if (sizeHint < 0L && specifics.Options.RecycleBinSize > 0L) + // The size is always calculated (even for unlimited-capacity bins) so that + // the occupied-size accounting stays accurate if a limit is set later on. + // Sizes are measured in plaintext bytes on every code path. + if (sizeHint < 0L) { sizeHint = storableType switch { - StorableType.File => new FileInfo(ciphertextPath).Length, - StorableType.Folder => GetFolderSizeRecursive(ciphertextPath), + StorableType.File => CalculatePlaintextSize(new FileInfo(ciphertextPath).Length, specifics), + StorableType.Folder => GetFolderPlaintextSize(ciphertextPath, specifics), _ => 0L }; + } + sizeHint = Math.Max(0L, sizeHint); + specifics.RecycleBinSemaphore.Wait(); + try + { var occupiedSize = GetOccupiedSize(specifics); - var availableSize = specifics.Options.RecycleBinSize - occupiedSize; - if (availableSize < sizeHint) + if (specifics.Options.RecycleBinSize > 0L && specifics.Options.RecycleBinSize - occupiedSize < sizeHint) { DeleteImmediately(ciphertextPath, storableType); return; } - } - // Move and rename item - var guid = Guid.NewGuid().ToString(); - var destinationPath = Path.Combine(recycleBinPath, guid); - if (storableType == StorableType.Folder) - Directory.Move(ciphertextPath, destinationPath); - else - File.Move(ciphertextPath, destinationPath); - - // Create the configuration file - using (var configurationStream = File.Create($"{destinationPath}.json")) - { // Decrypt the plaintext parent ID var plaintextParentId = NativePathHelpers.GetPlaintextPath(ciphertextParentPath, specifics); - if (plaintextParentId is null || plaintextName is null) + if (plaintextParentId is null) throw new FormatException("Could not decrypt parent path for recycle bin configuration file."); - // Determine if Directory ID is present - var isDirectoryIdPresent = ciphertextParentPath != Path.DirectorySeparatorChar.ToString() && ciphertextParentPath != specifics.ContentFolder.Id; + // Determine if Directory ID is present (i.e., the source folder is not the content root) + var normalizedParentPath = Path.TrimEndingDirectorySeparator(ciphertextParentPath); + var isDirectoryIdPresent = normalizedParentPath != Path.DirectorySeparatorChar.ToString() + && normalizedParentPath != Path.TrimEndingDirectorySeparator(specifics.ContentFolder.Id); // Encrypt the new plaintext name and parent ID var newCiphertextName = RecycleBinItemDataModel.Encrypt(plaintextName, specifics.Security, isDirectoryIdPresent ? directoryId : ReadOnlySpan.Empty); var newCiphertextParentId = RecycleBinItemDataModel.Encrypt(plaintextParentId, specifics.Security, isDirectoryIdPresent ? directoryId : ReadOnlySpan.Empty); + var dataModel = new RecycleBinItemDataModel() + { + Name = newCiphertextName, + ParentId = newCiphertextParentId, + DirectoryId = isDirectoryIdPresent ? directoryId : Array.Empty(), + DeletionTimestamp = DateTime.Now, + Size = sizeHint + }; + + // Create the configuration file before moving the payload. If the move fails, + // the leftover configuration file is harmless and gets cleaned up on recalculation, + // whereas a payload without a configuration file would be unrestorable. + var guid = Guid.NewGuid().ToString(); + var destinationPath = Path.Combine(recycleBinPath, guid); + var configurationPath = $"{destinationPath}.json"; + WriteItemDataModel(configurationPath, dataModel); - // Serialize configuration data model - using var serializedStream = StreamSerializer.Instance.SerializeAsync( - new RecycleBinItemDataModel() + // The folder's original plaintext path must be captured before the move - it is + // the key that previously recycled children are folded back in by + var plaintextFolderPath = storableType == StorableType.Folder + ? SafetyHelpers.NoFailureResult(() => NativePathHelpers.GetPlaintextPath(ciphertextPath, specifics)) + : null; + + try + { + // Move and rename item + if (storableType == StorableType.Folder) + Directory.Move(ciphertextPath, destinationPath); + else + File.Move(ciphertextPath, destinationPath); + } + catch (Exception) + { + // Best-effort cleanup of the now-orphaned configuration file + SafetyHelpers.NoFailure(() => File.Delete(configurationPath)); + throw; + } + + // Reattach previously recycled children of this folder so that trees deleted + // member-by-member appear as a single restorable entry + if (plaintextFolderPath is not null) + { + var foldedSize = SafetyHelpers.NoFailureResult(() => FoldDescendantEntries(recycleBinPath, destinationPath, plaintextFolderPath, specifics)); + if (foldedSize > 0L) { - Name = newCiphertextName, - ParentId = newCiphertextParentId, - DirectoryId = isDirectoryIdPresent ? directoryId : [], - DeletionTimestamp = DateTime.Now, - Size = sizeHint - }).ConfigureAwait(false).GetAwaiter().GetResult(); - - // Write to destination stream - serializedStream.CopyTo(configurationStream); - serializedStream.Flush(); - } + // The folded sizes were already part of the occupied total; only the + // folder's own entry needs to account for its regained contents + SafetyHelpers.NoFailure(() => WriteItemDataModel(configurationPath, dataModel with { Size = sizeHint + foldedSize })); + } + } - // Update occupied size - if (specifics.Options.IsRecycleBinEnabled()) + // Update occupied size + SetOccupiedSize(specifics, occupiedSize + sizeHint); + } + finally { - var occupiedSize = GetOccupiedSize(specifics); - var newSize = occupiedSize + sizeHint; - SetOccupiedSize(specifics, newSize); + _ = specifics.RecycleBinSemaphore.Release(); } return; @@ -143,6 +190,123 @@ static void DeleteImmediately(string path, StorableType type) else if (type == StorableType.Folder) Directory.Delete(path, true); } + + static bool IsRecentlyCreated(string path) + { + try + { + var elapsedMilliseconds = (DateTime.UtcNow - File.GetCreationTimeUtc(path)).TotalMilliseconds; + + // Negative elapsed time indicates clock skew. + // Never treat such files as recently created, as that would permanently delete them + return elapsedMilliseconds is >= 0d and <= RECENT_FILE_THRESHOLD_MS; + } + catch + { + // If we can't determine creation time, assume it's not recent + return false; + } + } + } + + /// + /// Serializes into the file at , truncating any previous content. + /// + private static void WriteItemDataModel(string configurationPath, RecycleBinItemDataModel dataModel) + { + using var configurationStream = File.Create(configurationPath); + using var serializedStream = StreamSerializer.Instance.SerializeAsync(dataModel).ConfigureAwait(false).GetAwaiter().GetResult(); + + serializedStream.CopyTo(configurationStream); + configurationStream.Flush(); + } + + /// + /// Folds previously recycled children of the folder at back into + /// its recycled payload at . OS clients (Finder, Explorer, WebDav/FUSE + /// drivers) delete folder trees member-by-member, bottom-up; when the parent folder itself arrives at the + /// recycle bin, every entry that was deleted out of it can be reattached so the tree appears as a single + /// restorable entry. + ///

+ /// Membership is proven by comparing each entry's stored Directory ID with the recycled folder's + /// dirid.iv - an exact lineage match that a recreated folder at the same path cannot satisfy. + /// The original ciphertext names are reproduced exactly because name encryption is deterministic. + ///
+ /// + /// This method must be invoked while holding . + /// + /// The accumulated size in bytes of all folded entries. + private static long FoldDescendantEntries(string recycleBinPath, string recycledFolderPath, string plaintextFolderPath, FileSystemSpecifics specifics) + { + // Read the recycled folder's Directory ID - children of this exact folder + // incarnation carry it in their configuration files + var directoryIdPath = Path.Combine(recycledFolderPath, Constants.Names.DIRECTORY_ID_FILENAME); + if (!File.Exists(directoryIdPath)) + return 0L; + + var folderDirectoryId = File.ReadAllBytes(directoryIdPath); + if (folderDirectoryId.Length != Constants.DIRECTORY_ID_SIZE) + return 0L; + + var foldedSize = 0L; + var normalizedFolderPath = Path.TrimEndingDirectorySeparator(plaintextFolderPath); + + // Snapshot the configuration files first - folding mutates the recycle bin contents + foreach (var configurationPath in Directory.GetFiles(recycleBinPath, "*.json")) + { + // A single unreadable entry must not abandon the remaining ones + SafetyHelpers.NoFailure(() => + { + RecycleBinItemDataModel? dataModel; + using (var configurationStream = File.Open(configurationPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + dataModel = StreamSerializer.Instance.TryDeserializeAsync(configurationStream).ConfigureAwait(false).GetAwaiter().GetResult(); + + if (dataModel is not { Name: not null, ParentId: not null, DirectoryId: { Length: Constants.DIRECTORY_ID_SIZE } childDirectoryId }) + return; + + // Lineage check: the entry must have been deleted out of this exact folder incarnation + if (!childDirectoryId.AsSpan().SequenceEqual(folderDirectoryId)) + return; + + // Recency check: don't silently pull in unrelated deletions from long ago + if (dataModel.DeletionTimestamp is not { } deletionTimestamp + || Math.Abs((DateTime.Now - deletionTimestamp).TotalMilliseconds) > Constants.RECYCLE_BIN_FOLD_WINDOW_MS) + return; + + // Sanity check: the original parent path must match the folder being recycled + var plaintextParentPath = dataModel.DecryptParentId(specifics.Security); + if (plaintextParentPath is null || Path.TrimEndingDirectorySeparator(plaintextParentPath) != normalizedFolderPath) + return; + + var childPlaintextName = dataModel.DecryptName(specifics.Security); + if (childPlaintextName is null) + return; + + // Reconstruct the child's original ciphertext name (deterministic for the folder's Directory ID). + // On a name collision the entry is left standalone instead of being overwritten + var ciphertextChildName = AbstractPathHelpers.EncryptNewName(childPlaintextName, folderDirectoryId, specifics.Security); + var reattachedPath = Path.Combine(recycledFolderPath, ciphertextChildName); + if (Path.Exists(reattachedPath)) + return; + + // Reattach the payload; configurations without one are cleaned up on recalculation + var payloadPath = Path.Combine(recycleBinPath, Path.GetFileNameWithoutExtension(configurationPath)); + if (Directory.Exists(payloadPath)) + Directory.Move(payloadPath, reattachedPath); + else if (File.Exists(payloadPath)) + File.Move(payloadPath, reattachedPath); + else + return; + + // Discard the now-redundant configuration file + File.Delete(configurationPath); + + if (dataModel.Size is { } size and > 0L) + foldedSize += size; + }); + } + + return foldedSize; } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Shared.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Shared.cs index 4a9f8909d..2ff570681 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Shared.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Shared.cs @@ -1,51 +1,56 @@ -using SecureFolderFS.Storage.VirtualFileSystem; using System; +using System.Collections.Generic; using System.IO; -using System.Threading; -using System.Threading.Tasks; using SecureFolderFS.Core.FileSystem.DataModels; +using SecureFolderFS.Core.FileSystem.Helpers.Paths; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage.VirtualFileSystem; namespace SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native { public static partial class NativeRecycleBinHelpers { - public static long GetFolderSizeRecursive(string path) + /// + /// Measures the accumulated plaintext size in bytes of all non-core files inside . + /// The directory tree is walked iteratively to avoid unbounded parallelism inside file system callbacks. + /// + public static long GetFolderPlaintextSize(string path, FileSystemSpecifics specifics) { if (!Directory.Exists(path)) throw new DirectoryNotFoundException($"The directory '{path}' does not exist."); var totalSize = 0L; - try + var stack = new Stack(); + stack.Push(path); + + while (stack.Count > 0) { - // Sum file sizes in the current directory - var files = Directory.EnumerateFiles(path); - Parallel.ForEach(files, () => 0L, (file, _, localTotal) => + var current = stack.Pop(); + try { - try - { - var fileInfo = new FileInfo(file); - return localTotal + fileInfo.Length; - } - catch (Exception) + foreach (var file in Directory.EnumerateFiles(current)) { - // Ignore errors (e.g., access denied) - return localTotal; + try + { + if (PathHelpers.IsCoreName(Path.GetFileName(file))) + continue; + + totalSize += CalculatePlaintextSize(new FileInfo(file).Length, specifics); + } + catch (Exception) + { + // Ignore + } } - }, - localTotal => Interlocked.Add(ref totalSize, localTotal)); - // Recurse into subdirectories in parallel - var subDirs = Directory.EnumerateDirectories(path); - Parallel.ForEach(subDirs, dir => + foreach (var subDirectory in Directory.EnumerateDirectories(current)) + stack.Push(subDirectory); + } + catch (Exception) { - var subDirSize = GetFolderSizeRecursive(dir); - Interlocked.Add(ref totalSize, subDirSize); - }); - } - catch (Exception) - { + // Ignore + } } return totalSize; @@ -53,9 +58,12 @@ public static long GetFolderSizeRecursive(string path) public static long GetOccupiedSize(FileSystemSpecifics specifics) { + // Reading must not create the configuration file - reads can happen on read-only file systems var configPath = Path.Combine(specifics.ContentFolder.Id, Constants.Names.RECYCLE_BIN_NAME, Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME); - using var configStream = File.Open(configPath, specifics.Options.IsReadOnly ? FileMode.Open : FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read); + if (!File.Exists(configPath)) + return 0L; + using var configStream = File.Open(configPath, FileMode.Open, FileAccess.Read, FileShare.Read); var deserialized = StreamSerializer.Instance.TryDeserializeAsync(configStream).ConfigureAwait(false).GetAwaiter().GetResult(); if (deserialized is null) return 0L; @@ -68,8 +76,10 @@ public static void SetOccupiedSize(FileSystemSpecifics specifics, long value) if (specifics.Options.IsReadOnly) throw FileSystemExceptions.FileSystemReadOnly; + // File.Create truncates existing content, and so File.OpenWrite must not be used here, + // because it leaves stale tail bytes (and therefore unparseable JSON) whenever the serialized payload shrinks var configPath = Path.Combine(specifics.ContentFolder.Id, Constants.Names.RECYCLE_BIN_NAME, Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME); - using var configStream = !File.Exists(configPath) ? File.Create(configPath) : File.OpenWrite(configPath); + using var configStream = File.Create(configPath); using var serialized = StreamSerializer.Instance.SerializeAsync(new RecycleBinDataModel() { OccupiedSize = Math.Max(0L, value) @@ -78,5 +88,11 @@ public static void SetOccupiedSize(FileSystemSpecifics specifics, long value) serialized.CopyTo(configStream); configStream.Flush(); } + + internal static long CalculatePlaintextSize(long ciphertextLength, FileSystemSpecifics specifics) + { + return Math.Max(0L, specifics.Security.ContentCrypt.CalculatePlaintextSize( + Math.Max(0L, ciphertextLength - specifics.Security.HeaderCrypt.HeaderCiphertextSize))); + } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricViewModel.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricViewModel.cs index 07551542f..97d888383 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricViewModel.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ViewModels/AndroidBiometricViewModel.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Security.Cryptography; using Android.Hardware.Biometrics; +using Android.OS; using AndroidX.Core.Content; using Java.Security; using OwlCore.Storage; @@ -13,6 +14,7 @@ using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Models; using SecureFolderFS.Shared.SecureStore; +using SecureFolderFS.Storage.Extensions; namespace SecureFolderFS.Maui.Platforms.Android.ViewModels { @@ -47,18 +49,24 @@ public AndroidBiometricViewModel(IFolder vaultFolder, string vaultId) } /// - public override Task RevokeAsync(string? id, CancellationToken cancellationToken = default) + public override async Task RevokeAsync(string? id, CancellationToken cancellationToken = default) { + id ??= VaultId; var keyStore = KeyStore.GetInstance(KEYSTORE_PROVIDER); - if (keyStore is null) - return Task.CompletedTask; + if (keyStore is not null) + { + keyStore.Load(null); + var alias = $"{KEY_ALIAS_PREFIX}{id}"; + if (keyStore.ContainsAlias(alias)) + keyStore.DeleteEntry(alias); + } - keyStore.Load(null); - var alias = $"{KEY_ALIAS_PREFIX}{id}"; - if (keyStore.ContainsAlias(alias)) - keyStore.DeleteEntry(alias); + if (VaultFolder is not IModifiableFolder modifiableFolder) + return; - return Task.CompletedTask; + var authenticationFile = await modifiableFolder.TryGetFileByNameAsync($"{Id}{Constants.Vault.Names.CONFIGURATION_EXTENSION}", cancellationToken); + if (authenticationFile is not null) + await modifiableFolder.DeleteAsync(authenticationFile, cancellationToken); } /// @@ -86,7 +94,7 @@ public override async Task> EnrollAsync(string id, byte[]? da privateKey = privateKeyEntry?.PrivateKey ?? throw new CryptographicException("Private key could not be found."); } - var signature = await MakeSignatureAsync(privateKey, data); + var signature = await MakeSignatureAsync(privateKey, data, cancellationToken); return Result.Success(signature); } @@ -105,11 +113,11 @@ public override async Task> AcquireAsync(string id, byte[]? d var privateKeyEntry = keyStore.GetEntry(alias, null) as KeyStore.PrivateKeyEntry; var privateKey = privateKeyEntry?.PrivateKey ?? throw new CryptographicException("Private key could not be found."); - var signature = await MakeSignatureAsync(privateKey, data); + var signature = await MakeSignatureAsync(privateKey, data, cancellationToken); return Result.Success(signature); } - private static async Task MakeSignatureAsync(IPrivateKey privateKey, byte[] data) + private static async Task MakeSignatureAsync(IPrivateKey privateKey, byte[] data, CancellationToken cancellationToken) { var signature = Signature.GetInstance("SHA256withRSA"); if (signature is null) @@ -129,14 +137,18 @@ private static async Task MakeSignatureAsync(IPrivateKey privateKey, })) .Build(); - promptInfo.Authenticate(new BiometricPrompt.CryptoObject(signature), new(), executor, + // Dismiss the prompt when the caller cancels + var cancellationSignal = new CancellationSignal(); + using var cancellationRegistration = cancellationToken.Register(() => cancellationSignal.Cancel()); + + promptInfo.Authenticate(new BiometricPrompt.CryptoObject(signature), cancellationSignal, executor, new BiometricPromptCallback( onSuccess: result => { try { if (result?.CryptoObject?.Signature is null) - return; + throw new CryptographicException("The authenticated signature was not available."); var signedBytes = AndroidBiometricHelpers.SignData(result.CryptoObject.Signature, data); if (signedBytes is null) @@ -151,8 +163,11 @@ private static async Task MakeSignatureAsync(IPrivateKey privateKey, }, onError: (code, message) => { - _ = message; - //tcs.TrySetException(new Exception($"Biometric error {code}: {message}")); + // Every terminal prompt error must complete the task, otherwise the caller awaits forever + if (code is BiometricErrorCode.UserCanceled or BiometricErrorCode.Canceled) + tcs.TrySetCanceled(); + else + tcs.TrySetException(new CryptographicException($"Biometric authentication failed ({code}). {message}")); }, onFailure: null)); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricViewModel.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricViewModel.cs index ce946f0aa..d62184d06 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricViewModel.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricViewModel.cs @@ -11,6 +11,7 @@ using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Models; using SecureFolderFS.Shared.SecureStore; +using SecureFolderFS.Storage.Extensions; using Security; namespace SecureFolderFS.Maui.Platforms.iOS.ViewModels @@ -49,7 +50,7 @@ public IOSBiometricViewModel(IFolder vaultFolder, string vaultId, string title) } /// - public override Task RevokeAsync(string? id, CancellationToken cancellationToken = default) + public override async Task RevokeAsync(string? id, CancellationToken cancellationToken = default) { id ??= VaultId; @@ -59,14 +60,19 @@ public override Task RevokeAsync(string? id, CancellationToken cancellationToken { ApplicationTag = applicationTag, KeyClass = SecKeyClass.Private, - TokenID = SecTokenID.SecureEnclave + TokenID = SecTokenID.SecureEnclave }; // Remove the key from the keychain var status = SecKeyChain.Remove(query); _ = status; - return Task.CompletedTask; + if (VaultFolder is not IModifiableFolder modifiableFolder) + return; + + var authenticationFile = await modifiableFolder.TryGetFileByNameAsync($"{Id}{Constants.Vault.Names.CONFIGURATION_EXTENSION}", cancellationToken); + if (authenticationFile is not null) + await modifiableFolder.DeleteAsync(authenticationFile, cancellationToken); } /// @@ -82,7 +88,7 @@ public override async Task> EnrollAsync(string id, byte[]? da throw new CryptographicException(evalErr?.LocalizedDescription ?? "Authentication failed."); privateKey ??= CreatePrivateKey(alias); - var publicKey = privateKey.GetPublicKey() ?? throw new CryptographicException("Public key could not be retrieved.");; + var publicKey = privateKey.GetPublicKey() ?? throw new CryptographicException("Public key could not be retrieved."); var encrypted = Encrypt(publicKey, data); return Result.Success(encrypted); @@ -114,15 +120,20 @@ private static ManagedKey Encrypt(SecKey publicKey, byte[] data) return ManagedKey.TakeOwnership(ciphertextBuffer); } - private static async Task DecryptAsync(SecKey privateKey, byte[] data) + private static Task DecryptAsync(SecKey privateKey, byte[] data) { - var ciphertext = NSData.FromArray(data); - var plaintext = privateKey.CreateDecryptedData(ALGORITHM, ciphertext, out var error); - if (plaintext is null || error is not null) - throw new CryptographicException($"Could not decrypt the data. {error?.LocalizedDescription}"); + // Decryption with the Secure Enclave key triggers the biometric prompt automatically + // and blocks until it is dismissed, so it must not run on the UI thread + return Task.Run(() => + { + using var ciphertext = NSData.FromArray(data); + using var plaintext = privateKey.CreateDecryptedData(ALGORITHM, ciphertext, out var error); + if (plaintext is null || error is not null) + throw new CryptographicException($"Could not decrypt the data. {error?.LocalizedDescription}"); - var plaintextBuffer = plaintext.ToArray(); - return ManagedKey.TakeOwnership(plaintextBuffer); + var plaintextBuffer = plaintext.ToArray(); + return ManagedKey.TakeOwnership(plaintextBuffer); + }); } private static async Task<(bool ok, NSError? error)> EvaluateAsync(LAContext context, LAPolicy policy, string reason) diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/RecycleBinModalPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/RecycleBinModalPage.xaml index cb5410b69..0c9f813e0 100644 --- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/RecycleBinModalPage.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/RecycleBinModalPage.xaml @@ -51,7 +51,10 @@ - + (); + var configurationFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); + await foreach (var item in recycleBin.GetItemsAsync(StorableType.All, cancellationToken)) { - if (item.Name.EndsWith(".json")) + if (PathHelpers.IsCoreName(item.Name)) continue; - var dataModel = await AbstractRecycleBinHelpers.GetItemDataModelAsync(item, recycleBin, StreamSerializer.Instance, cancellationToken); - if (dataModel.Size is { } size and >= 0L) + if (item.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { - totalSize += size; + if (item is IChildFile configurationFile) + configurationFiles[Path.GetFileNameWithoutExtension(item.Name)] = configurationFile; + continue; } - // Get the configuration file - var configurationFile = await recycleBin.GetFileByNameAsync($"{item.Name}.json", cancellationToken); + payloadItems.Add(item); + } - // Read configuration file - await using var configurationStream = await configurationFile.OpenReadAsync(cancellationToken); + var totalSize = 0L; + foreach (var payloadItem in payloadItems) + { + cancellationToken.ThrowIfCancellationRequested(); - // Calculate new size - var sizeHint = item switch + // Payloads without a configuration file cannot be restored, but they still + // occupy space and are surfaced in the recycle bin view for manual deletion + if (!configurationFiles.Remove(payloadItem.Name, out var configurationFile)) { - IFile file => await file.GetSizeAsync(cancellationToken) ?? 0L, - IFolder folder => await folder.GetSizeAsync(cancellationToken) ?? 0L, - _ => 0L - }; - totalSize += sizeHint; + totalSize += await SafetyHelpers.NoFailureAsync(async () => await AbstractRecycleBinHelpers.GetPlaintextSizeAsync(payloadItem, specifics, cancellationToken)); + continue; + } - // Create new configuration with updated size - var newConfigurationDataModel = dataModel with + // A single corrupt entry must not abandon the recalculation of the remaining ones + try { - Size = sizeHint - }; + var dataModel = await AbstractRecycleBinHelpers.GetItemDataModelAsync(configurationFile, recycleBin, StreamSerializer.Instance, cancellationToken); + if (dataModel.Size is { } size and >= 0L) + { + totalSize += size; + continue; + } + + // Calculate new size + var sizeHint = await AbstractRecycleBinHelpers.GetPlaintextSizeAsync(payloadItem, specifics, cancellationToken); + totalSize += sizeHint; + + // Create new configuration with updated size + var newConfigurationDataModel = dataModel with + { + Size = sizeHint + }; + + // Rewrite the configuration file, truncating any previous content + await using var configurationStream = await configurationFile.OpenWriteAsync(cancellationToken); + if (configurationStream.CanSeek) + configurationStream.SetLength(0L); + + await using var serializedStream = await StreamSerializer.Instance.SerializeAsync(newConfigurationDataModel, cancellationToken); + await serializedStream.CopyToAsync(configurationStream, cancellationToken); + await configurationStream.FlushAsync(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + totalSize += await SafetyHelpers.NoFailureAsync(async () => await AbstractRecycleBinHelpers.GetPlaintextSizeAsync(payloadItem, specifics, cancellationToken)); + } + } - // Serialize configuration data model - await using var serializedStream = await StreamSerializer.Instance.SerializeAsync(newConfigurationDataModel, cancellationToken); + // Any remaining configuration files have no payload (e.g., after an interrupted + // recycle operation) and are safe to remove + foreach (var orphanedConfiguration in configurationFiles.Values) + await SafetyHelpers.NoFailureAsync(async () => await modifiableRecycleBin.DeleteAsync(orphanedConfiguration, cancellationToken)); - // Write to destination stream - await serializedStream.CopyToAsync(configurationStream, cancellationToken); - await configurationStream.FlushAsync(cancellationToken); + await specifics.RecycleBinSemaphore.WaitAsync(cancellationToken); + try + { + await AbstractRecycleBinHelpers.SetOccupiedSizeAsync(modifiableRecycleBin, totalSize, cancellationToken); + } + finally + { + _ = specifics.RecycleBinSemaphore.Release(); } - - await AbstractRecycleBinHelpers.SetOccupiedSizeAsync(modifiableRecycleBin, totalSize, cancellationToken); } /// diff --git a/src/Platforms/SecureFolderFS.UI/Storage/RecycleBinFolder.cs b/src/Platforms/SecureFolderFS.UI/Storage/RecycleBinFolder.cs index 2fb9ac63c..9b2d71223 100644 --- a/src/Platforms/SecureFolderFS.UI/Storage/RecycleBinFolder.cs +++ b/src/Platforms/SecureFolderFS.UI/Storage/RecycleBinFolder.cs @@ -40,7 +40,7 @@ internal sealed class RecycleBinFolder : IRecycleBinFolder public string Name => _recycleBin.Name; /// - public ISizeOfProperty SizeOf => field ??= new RecycleBinSizeOfProperty(_recycleBin); + public ISizeOfProperty SizeOf => field ??= new RecycleBinSizeOfProperty(_recycleBin, _specifics); public RecycleBinFolder(IModifiableFolder recycleBin, IVfsRoot vfsRoot, FileSystemSpecifics specifics, IAsyncSerializer serializer) { @@ -57,19 +57,52 @@ public async Task RestoreItemsAsync(IEnumerable items, IFolderPi throw FileSystemExceptions.FileSystemReadOnly; var allItems = items.ToArray(); - var storableItems = allItems.Select(x => x is IRecycleBinItem recycleBinItem ? recycleBinItem.AsWrapper().GetWrapperAt(1).Inner : x).Cast().ToArray(); - switch (storableItems.Length) + if (allItems.IsEmpty()) + throw new InvalidOperationException("Sequence contains no elements."); + + // The folder picker is only shown (at most once) for items whose original location is gone + IModifiableFolder? pickedFolder = null; + var exceptions = new List(); + + // Read and validate every configuration file up front so that unrestorable items + // fail immediately instead of after prompting the user for a destination + var restoreQueue = new List<(IStorableChild Item, IStorableChild OriginalItem, string? PlaintextParentPath)>(); + foreach (var originalItem in allItems) + { + cancellationToken.ThrowIfCancellationRequested(); + if ((originalItem is IRecycleBinItem unwrappable ? unwrappable.AsWrapper().GetWrapperAt(1).Inner : originalItem) is not IStorableChild item) + continue; + + try + { + var dataModel = await AbstractRecycleBinHelpers.GetItemDataModelAsync(item, _recycleBin, _serializer, cancellationToken); + var plaintextParentPath = SafetyHelpers.NoFailureResult(() => dataModel.DecryptParentId(_specifics.Security)); + restoreQueue.Add((item, originalItem, plaintextParentPath)); + } + catch (Exception ex) + { + // A failed item must not abandon the remaining ones - aggregate and report once + exceptions.Add(ex); + } + } + + // Restore shallow destinations first. Folder trees deleted member-by-member (e.g., by + // OS WebDav or FUSE clients, which issue one delete per item) end up as separate + // entries; restoring parents before children reconstructs the original hierarchy + foreach (var (item, originalItem, _) in restoreQueue.OrderBy(x => GetPathDepth(x.PlaintextParentPath))) { - case 1: + cancellationToken.ThrowIfCancellationRequested(); + + try { - var item = storableItems[0]; - var destinationFolder = await AbstractRecycleBinHelpers.GetDestinationFolderAsync( + // Prefer the item's original location + var destinationFolder = await SafetyHelpers.NoFailureAsync(async () => await AbstractRecycleBinHelpers.GetDestinationFolderAsync( item, _specifics, StreamSerializer.Instance, - cancellationToken); + cancellationToken)); - if (destinationFolder is null && allItems[0] is IRecycleBinItem recycleBinItem) + if (destinationFolder is null && originalItem is IRecycleBinItem recycleBinItem) { var originalParentPath = Path.GetDirectoryName(recycleBinItem.Id); if (!string.IsNullOrWhiteSpace(originalParentPath)) @@ -83,9 +116,11 @@ public async Task RestoreItemsAsync(IEnumerable items, IFolderPi } // Prompt the user to pick the folder when the default destination couldn't be used - destinationFolder ??= await GetDestinationFolderAsync(); if (destinationFolder is null) - throw new InvalidOperationException("The destination folder couldn't be chosen."); + { + pickedFolder ??= await GetDestinationFolderAsync(); + destinationFolder = pickedFolder ?? throw new InvalidOperationException("The destination folder couldn't be chosen."); + } // Restore the item to chosen destination await AbstractRecycleBinHelpers.RestoreAsync( @@ -94,35 +129,33 @@ await AbstractRecycleBinHelpers.RestoreAsync( _specifics, StreamSerializer.Instance, cancellationToken); - - break; } - - case > 1: + catch (OperationCanceledException) { - var destinationFolder = await GetDestinationFolderAsync(); - if (destinationFolder is null) - throw new InvalidOperationException("The destination folder couldn't be chosen."); - - foreach (var item in storableItems) - { - await AbstractRecycleBinHelpers.RestoreAsync( - item, - destinationFolder, - _specifics, - StreamSerializer.Instance, - cancellationToken); - } - - break; + // The user canceled the picker (or the operation was canceled) - abort the remaining items + throw; + } + catch (Exception ex) + { + // A failed item must not abandon the remaining ones + exceptions.Add(ex); } - - default: - throw new InvalidOperationException("Sequence contains no elements."); } + if (exceptions.Count > 0) + throw new AggregateException("One or more items could not be restored.", exceptions); + return; + static int GetPathDepth(string? plaintextPath) + { + // Unknown destinations are restored last; the vault root has a depth of zero + if (plaintextPath is null) + return int.MaxValue; + + return plaintextPath.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries).Length; + } + async Task GetDestinationFolderAsync() { if (await folderPicker.PickFolderAsync(new StartingFolderOptions("ComputerFolder"), false, cancellationToken) is not IModifiableFolder destinationFolder) @@ -167,33 +200,21 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = if (item.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || PathHelpers.IsCoreName(item.Name)) continue; - var recycleBinItem = await SafetyHelpers.NoFailureAsync(async () => - { - var dataModel = await AbstractRecycleBinHelpers.GetItemDataModelAsync(item, _recycleBin, StreamSerializer.Instance, cancellationToken); - if (dataModel.ParentId is null || dataModel.Name is null) - return null; - - // Decrypt name and parent path - var plaintextName = dataModel.DecryptName(_specifics.Security) ?? dataModel.Name; - var plaintextParentId = dataModel.DecryptParentId(_specifics.Security) ?? dataModel.ParentId; + yield return await MaterializeItemAsync(item, cancellationToken); + } + } - IStorable plaintextItem = item switch - { - IFile ciphertextFile => new CryptoFile($"/{plaintextName}", ciphertextFile, _specifics), - IFolder ciphertextFolder => new CryptoFolder($"/{plaintextName}", ciphertextFolder, _specifics), - _ => throw new ArgumentOutOfRangeException(nameof(item)) - }; + /// + public async Task TryGetItemAsync(string ciphertextName, CancellationToken cancellationToken = default) + { + if (ciphertextName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || PathHelpers.IsCoreName(ciphertextName)) + return null; - return new RecycleBinItem(plaintextItem, dataModel, this) - { - Id = string.IsNullOrEmpty(plaintextParentId) || string.IsNullOrEmpty(plaintextName) ? string.Empty : Path.Combine(plaintextParentId, plaintextName), - Name = plaintextName ?? item.Name - }; - }); + var item = await _recycleBin.TryGetFirstByNameAsync(ciphertextName, cancellationToken); + if (item is null) + return null; - if (recycleBinItem is not null) - yield return recycleBinItem; - } + return await MaterializeItemAsync(item, cancellationToken); } /// @@ -202,26 +223,37 @@ public async Task DeleteAsync(IStorableChild item, CancellationToken cancellatio if (_specifics.Options.IsReadOnly) throw FileSystemExceptions.FileSystemReadOnly; - // Get the associated configuration file - var configurationFile = await _recycleBin.GetFileByNameAsync($"{item.Name}.json", cancellationToken); + // Get the associated configuration file. Orphaned payloads (no configuration file) + // must still be deletable, so a missing configuration is not an error here + var configurationFile = await _recycleBin.TryGetFileByNameAsync($"{item.Name}.json", cancellationToken); // Deserialize configuration - RecycleBinItemDataModel? itemDataModel; - await using (var configurationStream = await configurationFile.OpenReadAsync(cancellationToken)) - itemDataModel = await _serializer.DeserializeAsync(configurationStream, cancellationToken); + RecycleBinItemDataModel? itemDataModel = null; + if (configurationFile is not null) + { + itemDataModel = await SafetyHelpers.NoFailureAsync(async () => + { + await using var configurationStream = await configurationFile.OpenReadAsync(cancellationToken); + return await _serializer.DeserializeAsync(configurationStream, cancellationToken); + }); + } + + // Determine the size to subtract before the payload is gone + var size = itemDataModel?.Size is { } configuredSize and >= 0L + ? configuredSize + : await SafetyHelpers.NoFailureAsync(async () => await AbstractRecycleBinHelpers.GetPlaintextSizeAsync(item, _specifics, cancellationToken)); // Delete both items await _recycleBin.DeleteAsync(item, cancellationToken); - await _recycleBin.DeleteAsync(configurationFile, cancellationToken); + if (configurationFile is not null) + await _recycleBin.DeleteAsync(configurationFile, cancellationToken); // Check if the item had any size - if (itemDataModel is not { Size: { } size and > 0L }) + if (size <= 0L) return; // Update occupied size - var occupiedSize = await AbstractRecycleBinHelpers.GetOccupiedSizeAsync(_recycleBin, cancellationToken); - var newSize = occupiedSize - size; - await AbstractRecycleBinHelpers.SetOccupiedSizeAsync(_recycleBin, newSize, cancellationToken); + await AbstractRecycleBinHelpers.AdjustOccupiedSizeAsync(_recycleBin, _specifics, -size, cancellationToken); } /// @@ -241,5 +273,55 @@ public async Task GetFolderWatcherAsync(CancellationToken cancel { return await _recycleBin.GetFolderWatcherAsync(cancellationToken); } + + private async Task MaterializeItemAsync(IStorableChild item, CancellationToken cancellationToken) + { + var recycleBinItem = await SafetyHelpers.NoFailureAsync(async () => + { + var dataModel = await AbstractRecycleBinHelpers.GetItemDataModelAsync(item, _recycleBin, StreamSerializer.Instance, cancellationToken); + if (dataModel.ParentId is null || dataModel.Name is null) + return null; + + // Decrypt name and parent path + var plaintextName = dataModel.DecryptName(_specifics.Security) ?? dataModel.Name; + var plaintextParentId = dataModel.DecryptParentId(_specifics.Security) ?? dataModel.ParentId; + + return new RecycleBinItem(WrapCiphertextItem(item, plaintextName), dataModel, this) + { + Id = string.IsNullOrEmpty(plaintextParentId) || string.IsNullOrEmpty(plaintextName) ? string.Empty : Path.Combine(plaintextParentId, plaintextName), + Name = plaintextName ?? item.Name + }; + }); + + // Payloads with a missing or corrupt configuration file are surfaced as unnamed + // items so they can still be permanently deleted instead of silently occupying space + return recycleBinItem ?? new RecycleBinItem(WrapCiphertextItem(item, item.Name), OrphanedItemDataModel(), this) + { + Id = string.Empty, + Name = item.Name + }; + + IStorable WrapCiphertextItem(IStorableChild ciphertextItem, string plaintextName) + { + return ciphertextItem switch + { + IFile ciphertextFile => new CryptoFile($"/{plaintextName}", ciphertextFile, _specifics), + IFolder ciphertextFolder => new CryptoFolder($"/{plaintextName}", ciphertextFolder, _specifics), + _ => throw new ArgumentOutOfRangeException(nameof(ciphertextItem)) + }; + } + + static RecycleBinItemDataModel OrphanedItemDataModel() + { + return new() + { + Name = null, + ParentId = null, + DirectoryId = null, + DeletionTimestamp = null, + Size = -1L + }; + } + } } } diff --git a/src/Platforms/SecureFolderFS.UI/Storage/StorageProperties/RecycleBinItemCreatedAtProperty.cs b/src/Platforms/SecureFolderFS.UI/Storage/StorageProperties/RecycleBinItemCreatedAtProperty.cs index 6e32f5723..71499007a 100644 --- a/src/Platforms/SecureFolderFS.UI/Storage/StorageProperties/RecycleBinItemCreatedAtProperty.cs +++ b/src/Platforms/SecureFolderFS.UI/Storage/StorageProperties/RecycleBinItemCreatedAtProperty.cs @@ -20,8 +20,8 @@ public sealed class RecycleBinItemCreatedAtProperty : ICreatedAtProperty public RecycleBinItemCreatedAtProperty(string id, RecycleBinItemDataModel dataModel) { _dataModel = dataModel; - Name = nameof(ISizeOf.SizeOf); - Id = $"{id}/{nameof(ISizeOf.SizeOf)}"; + Name = nameof(ICreatedAt.CreatedAt); + Id = $"{id}/{nameof(ICreatedAt.CreatedAt)}"; } /// diff --git a/src/Platforms/SecureFolderFS.UI/Storage/StorageProperties/RecycleBinSizeOfProperty.cs b/src/Platforms/SecureFolderFS.UI/Storage/StorageProperties/RecycleBinSizeOfProperty.cs index c4bb05a1f..5aaba2694 100644 --- a/src/Platforms/SecureFolderFS.UI/Storage/StorageProperties/RecycleBinSizeOfProperty.cs +++ b/src/Platforms/SecureFolderFS.UI/Storage/StorageProperties/RecycleBinSizeOfProperty.cs @@ -1,6 +1,7 @@ using System.Threading; using System.Threading.Tasks; using OwlCore.Storage; +using SecureFolderFS.Core.FileSystem; using SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Abstract; using SecureFolderFS.Storage.StorageProperties; @@ -10,6 +11,7 @@ namespace SecureFolderFS.UI.Storage.StorageProperties public sealed class RecycleBinSizeOfProperty : ISizeOfProperty { private readonly IModifiableFolder _recycleBin; + private readonly FileSystemSpecifics _specifics; /// public string Id { get; } @@ -17,9 +19,10 @@ public sealed class RecycleBinSizeOfProperty : ISizeOfProperty /// public string Name { get; } - public RecycleBinSizeOfProperty(IModifiableFolder recycleBin) + public RecycleBinSizeOfProperty(IModifiableFolder recycleBin, FileSystemSpecifics specifics) { _recycleBin = recycleBin; + _specifics = specifics; Name = nameof(ISizeOf.SizeOf); Id = $"{recycleBin.Id}/{nameof(ISizeOf.SizeOf)}"; } @@ -27,7 +30,9 @@ public RecycleBinSizeOfProperty(IModifiableFolder recycleBin) /// public async Task GetValueAsync(CancellationToken cancellationToken = default) { - return await AbstractRecycleBinHelpers.GetOccupiedSizeAsync(_recycleBin, cancellationToken); + // The semaphore-guarded overload prevents sharing violations with concurrent + // occupied-size updates (e.g. deletes happening through the mounted file system) + return await AbstractRecycleBinHelpers.GetOccupiedSizeAsync(_recycleBin, _specifics, cancellationToken); } } } diff --git a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx index 378b55382..11824f0dd 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx @@ -294,6 +294,12 @@ This is the recovery key which you can use to regain access to this vault. Losing it will prevent you from resetting your password. + + Maximize + + + Minimize + My vaults @@ -1061,6 +1067,15 @@ Setup YubiKey PIV authentication + + Overwrite + + + Overwrite YubiKey slot? + + + The selected slot already holds a configuration that is not HMAC-SHA1 challenge-response. Overwriting it will permanently erase the existing configuration and break anything that still uses it. + Use long press (Slot 2) @@ -1493,4 +1508,7 @@ Couldn't restore {0:plural:one item|{} items|{} items} + + Taken {0} out of {1} + diff --git a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/KeyFileViewModel.cs b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/KeyFileViewModel.cs index 948c60103..b1d770c70 100644 --- a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/KeyFileViewModel.cs +++ b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/KeyFileViewModel.cs @@ -95,7 +95,7 @@ public override async Task> AcquireAsync(string id, byte[]? d await using var keyStream = await keyFile.OpenStreamAsync(FileAccess.Read, cancellationToken); using var secretKey = new ManagedKey(KEY_LENGTH + id.Length); - var read = await keyStream.ReadAsync(secretKey.Key, cancellationToken); + var read = await keyStream.ReadAtLeastAsync(secretKey.Key, secretKey.Length, throwOnEndOfStream: false, cancellationToken); if (read < secretKey.Length) throw new DataException("The key data was too short."); diff --git a/src/Platforms/SecureFolderFS.Uno/App.xaml.cs b/src/Platforms/SecureFolderFS.Uno/App.xaml.cs index 8af53e315..5b55c1604 100644 --- a/src/Platforms/SecureFolderFS.Uno/App.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/App.xaml.cs @@ -67,8 +67,6 @@ public partial class App : Application public BaseLifecycleHelper ApplicationLifecycle { get; } = #if WINDOWS new Platforms.Windows.Helpers.WindowsLifecycleHelper(); -#elif MACCATALYST || __MACOS__ - new Platforms.MacCatalyst.Helpers.MacOsLifecycleHelper(); #elif HAS_UNO_SKIA new SkiaLifecycleHelper(); #else @@ -371,9 +369,9 @@ private static void EnsureEarlyWindow(Window window, string title) window.ExtendsContentIntoTitleBar = true; appWindow.TitleBar.ExtendsContentIntoTitleBar = true; - window.SetTitleBar(titleBar); + window.SetTitleBar(titleBar?.DragRegion); -#if __UNO_SKIA_MACOS__ +#if __UNO_SKIA_MACOS__ || __MACCATALYST__ // Use native macOS APIs to configure the window MacOsWindowHelper.ConfigureFullSizeContentView(window); MacOsWindowHelper.CenterWindow(window); @@ -381,17 +379,26 @@ private static void EnsureEarlyWindow(Window window, string title) // Add left padding for traffic light buttons var (leftPadding, _) = MacOsWindowHelper.GetTrafficLightButtonsInset(); - titleBar.Margin = new Thickness(leftPadding, 0, 0, 0); + titleBar?.Margin = new Thickness(leftPadding, 0, 0, 0); #elif !WINDOWS // For other non-Windows platforms, use OverlappedPresenter - if (appWindow.Presenter is OverlappedPresenter overlappedPresenter) + if (appWindow.Presenter is Microsoft.UI.Windowing.OverlappedPresenter overlappedPresenter) { overlappedPresenter.SetBorderAndTitleBar(true, false); overlappedPresenter.IsMinimizable = true; overlappedPresenter.IsMaximizable = true; } -#endif + // The OS does not paint caption buttons when the title bar is client-drawn, + // so show our own minimize/maximize/close buttons + titleBar?.ShowWindowButtons(window); + +#if __UNO_SKIA_X11__ + // Removing the window decorations also removes the window manager's resize frame, + // so provide client-drawn resize borders along the window edges + X11WindowHelper.EnableResizeBorders(window); +#endif +#endif #if WINDOWS if (Microsoft.UI.Windowing.AppWindowTitleBar.IsCustomizationSupported()) @@ -415,6 +422,16 @@ private static void EnsureMainWindow(Window window, MainViewModel mainViewModel) // Enable early window configuration EnsureEarlyWindow(window, nameof(SecureFolderFS)); +#if __UNO_SKIA_MACOS__ + // Hide the main window instead of closing it when 'Reduce to background' is enabled. + // This is handled natively because Uno's managed close-cancellation is a no-op on the macOS Skia host. + MacOsWindowHelper.InstallMainWindowCloseInterceptor(window, static () => + { + var reduceToBackground = DI.Service().UserSettings.ReduceToBackground; + return reduceToBackground && !(Instance?.UseForceClose ?? false); + }); +#endif + #if WINDOWS // Get BoundsManager var boundsManager = Platforms.Windows.Helpers.WindowsBoundsManager.AddOrGet(window); @@ -439,13 +456,23 @@ private static async void Window_Closed(object sender, WindowEventArgs args) } #endif var settingsService = DI.Service(); - var useForceClose = Instance!.UseForceClose; +#if WINDOWS || __UNO_SKIA_MACOS__ + var useForceClose = Instance?.UseForceClose ?? false; +#else + var useForceClose = true; +#endif var reduceToBackground = settingsService.UserSettings.ReduceToBackground; if (reduceToBackground && !useForceClose) { args.Handled = true; - Instance.MainWindow?.Hide(enableEfficiencyMode: false); +#if __UNO_SKIA_MACOS__ + // Hide the window natively (AppWindow.Hide is not implemented on the macOS Skia host) + if (Instance?.MainWindow is { } mainWindow) + MacOsWindowHelper.HideWindow(mainWindow); +#else + Instance?.MainWindow?.Hide(enableEfficiencyMode: false); +#endif } else { diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/RecycleBinDialog.xaml b/src/Platforms/SecureFolderFS.Uno/Dialogs/RecycleBinDialog.xaml index 95763c68b..75370d25b 100644 --- a/src/Platforms/SecureFolderFS.Uno/Dialogs/RecycleBinDialog.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/RecycleBinDialog.xaml @@ -33,6 +33,7 @@ @@ -59,7 +60,7 @@ Grid.Row="0" Grid.Column="1" VerticalAlignment="Center" - IsEnabled="{x:Bind ViewModel.IsRecycleBinEnabled, Mode=OneWay}" + IsEnabled="{x:Bind ViewModel.CanChangeSizeOption, Mode=OneWay}" ItemsSource="{x:Bind ViewModel.SizeOptions, Mode=OneWay}" SelectedItem="{x:Bind ViewModel.CurrentSizeOption, Mode=TwoWay}" SelectionChanged="SizeOptions_SelectionChanged"> diff --git a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Biometrics.cs b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Biometrics.cs index 6edd928f4..427360ea7 100644 --- a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Biometrics.cs +++ b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Biometrics.cs @@ -31,6 +31,7 @@ internal static partial class Biometrics private static readonly IntPtr _kSecReturnRef; private static readonly IntPtr _kSecMatchLimit; private static readonly IntPtr _kSecMatchLimitOne; + private static readonly IntPtr _kSecUseDataProtectionKeychain; private static readonly IntPtr _kSecKeyAlgorithmECIESEncryptionStandardX963SHA256AESGCM; // errSecSuccess @@ -52,6 +53,10 @@ internal static partial class Biometrics static Biometrics() { + // LAContext lives in LocalAuthentication.framework, which is not linked by the host + // process; it must be loaded before objc_getClass("LAContext") can resolve the class. + dlopen("/System/Library/Frameworks/LocalAuthentication.framework/LocalAuthentication", 1); + var securityLib = dlopen("/System/Library/Frameworks/Security.framework/Security", 1); _kSecAttrKeyType = ReadGlobalCFString(securityLib, "kSecAttrKeyType"); @@ -70,6 +75,7 @@ static Biometrics() _kSecReturnRef = ReadGlobalCFString(securityLib, "kSecReturnRef"); _kSecMatchLimit = ReadGlobalCFString(securityLib, "kSecMatchLimit"); _kSecMatchLimitOne = ReadGlobalCFString(securityLib, "kSecMatchLimitOne"); + _kSecUseDataProtectionKeychain = ReadGlobalCFString(securityLib, "kSecUseDataProtectionKeychain"); // Load the algorithm constant for ECIES encryption var algSymbol = dlsym(securityLib, "kSecKeyAlgorithmECIESEncryptionStandardX963SHA256AESGCM"); @@ -94,6 +100,9 @@ private static IntPtr ReadGlobalCFString(IntPtr lib, string symbol) public static bool CanEvaluateBiometricPolicy() { var laContextClass = objc_getClass("LAContext"); + if (laContextClass == IntPtr.Zero) + return false; + var allocSel = sel_registerName("alloc"); var initSel = sel_registerName("init"); @@ -127,6 +136,12 @@ public static Task EvaluateBiometricPolicyAsync(string reason) var tcs = new TaskCompletionSource(); var laContextClass = objc_getClass("LAContext"); + if (laContextClass == IntPtr.Zero) + { + tcs.SetResult(false); + return tcs.Task; + } + var allocSel = sel_registerName("alloc"); var initSel = sel_registerName("init"); @@ -171,10 +186,12 @@ public static Task EvaluateBiometricPolicyAsync(string reason) objc_msgSend_void_long_IntPtr_IntPtr(context, evaluateSel, LAPolicyDeviceOwnerAuthenticationWithBiometrics, nsReason, block); - // Release context after evaluation completes + // Release context after evaluation completes. The block (and its pinned delegate) + // is intentionally never freed: LocalAuthentication may still hold a reference to it + // after the reply fires, so freeing it here would risk a use-after-free. The few bytes + // leaked per prompt are negligible for how rarely authentication runs. tcs.Task.ContinueWith(_ => { - ReleaseBlock(block); var releaseSel = sel_registerName("release"); objc_msgSend_IntPtr(context, releaseSel); }); @@ -249,9 +266,9 @@ public static IntPtr GetPrivateKey(string alias) var tagData = StringToCFData(alias); var query = CreateCFDictionary( - new[] { _kSecClass, _kSecAttrApplicationTag, _kSecAttrKeyClass, _kSecAttrTokenID, _kSecReturnRef, _kSecMatchLimit }, - new[] { _kSecClassKey, tagData, _kSecAttrKeyClassPrivate, _kSecAttrTokenIDSecureEnclave, GetCFBooleanTrueValue(), _kSecMatchLimitOne }, - 6); + new[] { _kSecClass, _kSecAttrApplicationTag, _kSecAttrKeyClass, _kSecAttrTokenID, _kSecReturnRef, _kSecMatchLimit, _kSecUseDataProtectionKeychain }, + new[] { _kSecClassKey, tagData, _kSecAttrKeyClassPrivate, _kSecAttrTokenIDSecureEnclave, GetCFBooleanTrueValue(), _kSecMatchLimitOne, GetCFBooleanTrueValue() }, + 7); var status = SecItemCopyMatching(query, out var result); @@ -270,9 +287,9 @@ public static void DeleteKey(string alias) var tagData = StringToCFData(alias); var query = CreateCFDictionary( - new[] { _kSecClass, _kSecAttrApplicationTag, _kSecAttrKeyClass, _kSecAttrTokenID }, - new[] { _kSecClassKey, tagData, _kSecAttrKeyClassPrivate, _kSecAttrTokenIDSecureEnclave }, - 4); + new[] { _kSecClass, _kSecAttrApplicationTag, _kSecAttrKeyClass, _kSecAttrTokenID, _kSecUseDataProtectionKeychain }, + new[] { _kSecClassKey, tagData, _kSecAttrKeyClassPrivate, _kSecAttrTokenIDSecureEnclave, GetCFBooleanTrueValue() }, + 5); SecItemDelete(query); @@ -523,24 +540,13 @@ private static IntPtr CreateBlock(BlockCallback callback) var blockPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); Marshal.StructureToPtr(block, blockPtr, false); - // Pin the callback delegate to prevent GC collection + // Keep the callback delegate alive for the lifetime of the block (never freed, + // see the comment in EvaluateBiometricPolicyAsync) GCHandle.Alloc(callback); return blockPtr; } - private static void ReleaseBlock(IntPtr blockPtr) - { - if (blockPtr == IntPtr.Zero) - return; - - var block = Marshal.PtrToStructure(blockPtr); - if (block.descriptor != IntPtr.Zero) - Marshal.FreeHGlobal(block.descriptor); - - Marshal.FreeHGlobal(blockPtr); - } - #endregion } } diff --git a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs index 0946d2469..e3a0c4e99 100644 --- a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs +++ b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs @@ -160,7 +160,7 @@ public static partial void CFNotificationCenterRemoveObserver( public static partial void objc_msgSend_void_long(IntPtr receiver, IntPtr selector, long value); [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")] - public static partial void objc_msgSend_void_bool(IntPtr receiver, IntPtr selector, [MarshalAs(UnmanagedType.Bool)] bool value); + public static partial void objc_msgSend_void_bool(IntPtr receiver, IntPtr selector, [MarshalAs(UnmanagedType.U1)] bool value); [LibraryImport("libobjc.dylib", EntryPoint = "sel_registerName", StringMarshalling = StringMarshalling.Utf8)] public static partial IntPtr sel_registerName(string name); @@ -177,6 +177,37 @@ public static partial void CFNotificationCenterRemoveObserver( [LibraryImport("libobjc.dylib", EntryPoint = "objc_getClass", StringMarshalling = StringMarshalling.Utf8)] public static partial IntPtr objc_getClass(string className); + [LibraryImport("libobjc.dylib", EntryPoint = "object_getClass")] + public static partial IntPtr object_getClass(IntPtr obj); + + [LibraryImport("libobjc.dylib", EntryPoint = "objc_allocateClassPair", StringMarshalling = StringMarshalling.Utf8)] + public static partial IntPtr objc_allocateClassPair(IntPtr superclass, string name, nint extraBytes); + + [LibraryImport("libobjc.dylib", EntryPoint = "objc_registerClassPair")] + public static partial void objc_registerClassPair(IntPtr cls); + + [LibraryImport("libobjc.dylib", EntryPoint = "class_addMethod", StringMarshalling = StringMarshalling.Utf8)] + [return: MarshalAs(UnmanagedType.U1)] + public static partial bool class_addMethod(IntPtr cls, IntPtr name, IntPtr imp, string types); + + [LibraryImport("libobjc.dylib", EntryPoint = "class_replaceMethod", StringMarshalling = StringMarshalling.Utf8)] + public static partial IntPtr class_replaceMethod(IntPtr cls, IntPtr name, IntPtr imp, string types); + + [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")] + public static partial long objc_msgSend_long(IntPtr receiver, IntPtr selector); + + [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")] + public static partial void objc_msgSend_void_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg); + + [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")] + public static partial IntPtr objc_msgSend_IntPtr_double(IntPtr receiver, IntPtr selector, double arg); + + [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")] + public static partial IntPtr objc_msgSend_IntPtr_IntPtr_IntPtr_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1, IntPtr arg2, IntPtr arg3); + + [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")] + public static partial void objc_msgSend_void_CGSize(IntPtr receiver, IntPtr selector, CGSize size); + [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")] public static partial IntPtr objc_msgSend_IntPtr(IntPtr receiver, IntPtr selector); @@ -187,7 +218,7 @@ public static partial void CFNotificationCenterRemoveObserver( public static partial IntPtr objc_msgSend_IntPtr_IntPtr_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1, IntPtr arg2); [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")] - [return: MarshalAs(UnmanagedType.Bool)] + [return: MarshalAs(UnmanagedType.U1)] public static partial bool objc_msgSend_bool_long_IntPtr(IntPtr receiver, IntPtr selector, long arg1, IntPtr arg2); [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")] @@ -366,5 +397,12 @@ internal struct CGRect public double Width; public double Height; } + + [StructLayout(LayoutKind.Sequential)] + internal struct CGSize + { + public double Width; + public double Height; + } #endif } diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/MacOsStatusBarHelper.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/MacOsStatusBarHelper.cs new file mode 100644 index 000000000..73230345b --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/MacOsStatusBarHelper.cs @@ -0,0 +1,296 @@ +#if __UNO_SKIA_MACOS__ +using System; +using System.IO; +using System.Runtime.InteropServices; +using SecureFolderFS.Uno.PInvoke; +using static SecureFolderFS.Uno.PInvoke.UnsafeNative; + +namespace SecureFolderFS.Uno.Platforms.Desktop.Helpers +{ + /// + /// Manages an NSStatusItem in the macOS system menu bar (status bar) with common application actions. + /// Also handles Dock icon reopen requests so that clicking the Dock icon restores a hidden main window. + /// + internal sealed class MacOsStatusBarHelper + { + private const double NSVariableStatusItemLength = -1d; + private const long SHOW_APP_TAG = 1; + private const long LOCK_ALL_TAG = 2; + private const long EXIT_APP_TAG = 3; + + // Rooted delegates to prevent the GC from collecting the native callbacks + private static MenuItemCallback? _menuItemCallback; + private static ReopenCallback? _reopenCallback; + private static MacOsStatusBarHelper? _instance; + + private IntPtr _statusItem; + private IntPtr _lockAllItem; + private IntPtr _target; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void MenuItemCallback(IntPtr self, IntPtr cmd, IntPtr sender); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.U1)] + private delegate bool ReopenCallback(IntPtr self, IntPtr cmd, IntPtr application, [MarshalAs(UnmanagedType.U1)] bool hasVisibleWindows); + + /// + /// Gets or sets the action invoked when the user requests to show the application window. + /// + public Action? ShowAppRequested { get; set; } + + /// + /// Gets or sets the action invoked when the user requests to lock all vaults. + /// + public Action? LockAllRequested { get; set; } + + /// + /// Gets or sets the action invoked when the user requests to exit the application. + /// + public Action? ExitAppRequested { get; set; } + + private MacOsStatusBarHelper() + { + } + + /// + /// Creates the status bar item with the provided menu item titles, or returns the existing instance. + /// + /// + /// Must be called on the main thread. + /// + /// The path to the icon file shown in the status bar. + /// The title of the menu item that shows the app window. + /// The title of the menu item that locks all vaults. + /// The title of the menu item that exits the app. + /// The instance, or null if initialization failed. + public static MacOsStatusBarHelper? GetOrCreate(string iconPath, string showAppTitle, string lockAllTitle, string exitAppTitle) + { + if (_instance is not null) + return _instance; + + if (!OperatingSystem.IsMacOS()) + return null; + + try + { + var instance = new MacOsStatusBarHelper(); + if (!instance.Initialize(iconPath, showAppTitle, lockAllTitle, exitAppTitle)) + return null; + + _instance = instance; + return instance; + } + catch + { + return null; + } + } + + /// + /// Enables or disables the 'Lock All' menu item. + /// + /// Whether the menu item should be enabled. + public void SetLockAllEnabled(bool isEnabled) + { + try + { + if (_lockAllItem != IntPtr.Zero) + objc_msgSend_void_bool(_lockAllItem, sel_registerName("setEnabled:"), isEnabled); + } + catch + { + } + } + + private bool Initialize(string iconPath, string showAppTitle, string lockAllTitle, string exitAppTitle) + { + // Create the Objective-C target class that receives menu item actions + _target = CreateTargetInstance(); + if (_target == IntPtr.Zero) + return false; + + // Get [NSStatusBar systemStatusBar] + var statusBarClass = objc_getClass("NSStatusBar"); + var systemStatusBar = objc_msgSend_IntPtr(statusBarClass, sel_registerName("systemStatusBar")); + if (systemStatusBar == IntPtr.Zero) + return false; + + // Create the status item and retain it (the status bar does not keep a strong reference) + _statusItem = objc_msgSend_IntPtr_double(systemStatusBar, sel_registerName("statusItemWithLength:"), NSVariableStatusItemLength); + if (_statusItem == IntPtr.Zero) + return false; + + CFRetain(_statusItem); + + // Configure the status item button with the app icon + var button = objc_msgSend_IntPtr(_statusItem, sel_registerName("button")); + if (button != IntPtr.Zero) + { + var image = CreateStatusBarImage(iconPath); + if (image != IntPtr.Zero) + objc_msgSend_void_IntPtr(button, sel_registerName("setImage:"), image); + else + objc_msgSend_void_IntPtr(button, sel_registerName("setTitle:"), CreateNSString(nameof(SecureFolderFS))); + + objc_msgSend_void_IntPtr(button, sel_registerName("setToolTip:"), CreateNSString(nameof(SecureFolderFS))); + } + + // Create the menu: Show App | Lock All | --- | Exit App + var menu = AllocInit("NSMenu"); + objc_msgSend_void_bool(menu, sel_registerName("setAutoenablesItems:"), false); + + AddMenuItem(menu, showAppTitle, SHOW_APP_TAG); + _lockAllItem = AddMenuItem(menu, lockAllTitle, LOCK_ALL_TAG); + + var separatorItem = objc_msgSend_IntPtr(objc_getClass("NSMenuItem"), sel_registerName("separatorItem")); + objc_msgSend_void_IntPtr(menu, sel_registerName("addItem:"), separatorItem); + + AddMenuItem(menu, exitAppTitle, EXIT_APP_TAG); + + objc_msgSend_void_IntPtr(_statusItem, sel_registerName("setMenu:"), menu); + SetLockAllEnabled(false); + + // Restore the window when the Dock icon is clicked while the window is hidden + RegisterDockReopenHandler(); + + return true; + } + + private IntPtr AddMenuItem(IntPtr menu, string title, long tag) + { + // Create [[NSMenuItem alloc] initWithTitle:action:keyEquivalent:] + var menuItemClass = objc_getClass("NSMenuItem"); + var menuItem = objc_msgSend_IntPtr(menuItemClass, sel_registerName("alloc")); + menuItem = objc_msgSend_IntPtr_IntPtr_IntPtr_IntPtr( + menuItem, + sel_registerName("initWithTitle:action:keyEquivalent:"), + CreateNSString(title), + sel_registerName("menuItemInvoked:"), + CreateNSString(string.Empty)); + + objc_msgSend_void_IntPtr(menuItem, sel_registerName("setTarget:"), _target); + objc_msgSend_void_long(menuItem, sel_registerName("setTag:"), tag); + objc_msgSend_void_IntPtr(menu, sel_registerName("addItem:"), menuItem); + + return menuItem; + } + + private static IntPtr CreateTargetInstance() + { + // Register the Objective-C class that dispatches menu actions back to managed code + var targetClass = objc_allocateClassPair(objc_getClass("NSObject"), "SFFSStatusBarTarget", 0); + if (targetClass == IntPtr.Zero) + return IntPtr.Zero; + + _menuItemCallback = MenuItemInvoked; + var imp = Marshal.GetFunctionPointerForDelegate(_menuItemCallback); + if (!class_addMethod(targetClass, sel_registerName("menuItemInvoked:"), imp, "v@:@")) + return IntPtr.Zero; + + objc_registerClassPair(targetClass); + + return AllocInit("SFFSStatusBarTarget"); + } + + private static void RegisterDockReopenHandler() + { + try + { + // Get the class of [[NSApplication sharedApplication] delegate] + var nsApplicationClass = objc_getClass("NSApplication"); + var sharedApplication = objc_msgSend_IntPtr(nsApplicationClass, sel_registerName("sharedApplication")); + var appDelegate = objc_msgSend_IntPtr(sharedApplication, sel_registerName("delegate")); + if (appDelegate == IntPtr.Zero) + return; + + // Uno's application delegate does not implement applicationShouldHandleReopen:hasVisibleWindows:, + // so add it to handle Dock icon clicks (class_addMethod is a no-op if it ever becomes implemented) + _reopenCallback = ApplicationShouldHandleReopen; + var imp = Marshal.GetFunctionPointerForDelegate(_reopenCallback); + class_addMethod(object_getClass(appDelegate), sel_registerName("applicationShouldHandleReopen:hasVisibleWindows:"), imp, "B@:@B"); + } + catch + { + } + } + + private static void MenuItemInvoked(IntPtr self, IntPtr cmd, IntPtr sender) + { + try + { + var tag = objc_msgSend_long(sender, sel_registerName("tag")); + switch (tag) + { + case SHOW_APP_TAG: + _instance?.ShowAppRequested?.Invoke(); + break; + + case LOCK_ALL_TAG: + _instance?.LockAllRequested?.Invoke(); + break; + + case EXIT_APP_TAG: + _instance?.ExitAppRequested?.Invoke(); + break; + } + } + catch + { + // Exceptions must not cross the native boundary + } + } + + private static bool ApplicationShouldHandleReopen(IntPtr self, IntPtr cmd, IntPtr application, bool hasVisibleWindows) + { + try + { + if (!hasVisibleWindows) + _instance?.ShowAppRequested?.Invoke(); + } + catch + { + // Exceptions must not cross the native boundary + } + + return hasVisibleWindows; + } + + private static IntPtr CreateStatusBarImage(string iconPath) + { + if (!File.Exists(iconPath)) + return IntPtr.Zero; + + // Create [[NSImage alloc] initWithContentsOfFile:path] + var image = objc_msgSend_IntPtr(objc_getClass("NSImage"), sel_registerName("alloc")); + image = objc_msgSend_IntPtr_IntPtr(image, sel_registerName("initWithContentsOfFile:"), CreateNSString(iconPath)); + if (image == IntPtr.Zero) + return IntPtr.Zero; + + // Scale down to the standard menu bar icon size + objc_msgSend_void_CGSize(image, sel_registerName("setSize:"), new CGSize() { Width = 18d, Height = 18d }); + + return image; + } + + private static IntPtr AllocInit(string className) + { + var instance = objc_msgSend_IntPtr(objc_getClass(className), sel_registerName("alloc")); + return objc_msgSend_IntPtr(instance, sel_registerName("init")); + } + + private static IntPtr CreateNSString(string value) + { + var valuePtr = Marshal.StringToCoTaskMemUTF8(value); + try + { + return objc_msgSend_IntPtr_IntPtr(objc_getClass("NSString"), sel_registerName("stringWithUTF8String:"), valuePtr); + } + finally + { + Marshal.FreeCoTaskMem(valuePtr); + } + } + } +} +#endif diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/MacOsWindowHelper.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/MacOsWindowHelper.cs index ffd9b7459..b8a7928f3 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/MacOsWindowHelper.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/MacOsWindowHelper.cs @@ -1,5 +1,7 @@ +#if __UNO_SKIA_MACOS__ || __MACCATALYST__ using System; using System.Reflection; +using System.Runtime.InteropServices; using Microsoft.UI.Xaml; using Uno.UI.Xaml; using static SecureFolderFS.Uno.PInvoke.UnsafeNative; @@ -18,6 +20,125 @@ internal static class MacOsWindowHelper // NSWindowTitleVisibility values private const long NSWindowTitleHidden = 1; + // Rooted delegates and captured state for the native close interceptor. + // These must remain referenced for the lifetime of the app so the GC does not + // collect the callbacks that AppKit invokes. + private static WindowShouldCloseDelegate? _closeOverride; + private static TerminateDelegate? _terminateOverride; + private static IntPtr _mainWindowHandle; + private static IntPtr _originalWindowShouldClose; + private static Func? _shouldHideOnClose; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool WindowShouldCloseDelegate(IntPtr self, IntPtr cmd, IntPtr sender); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool TerminateDelegate(IntPtr self, IntPtr cmd, IntPtr sender); + + /// + /// Intercepts the main window's native windowShouldClose: so that closing it via the + /// traffic light button hides the window instead of destroying it, keeping the app running in + /// the background. This bypasses Uno's managed close-cancellation, which is a no-op on the + /// macOS Skia host because NativeWindowFactory.SupportsClosingCancellation resolves to false. + /// + /// The main window to intercept. + /// + /// A predicate evaluated on each close attempt. When it returns true, the window is hidden and + /// the close is cancelled; otherwise the close proceeds normally (e.g. when quitting the app). + /// + /// True if the interceptor was installed; otherwise, false. + public static bool InstallMainWindowCloseInterceptor(Window window, Func shouldHideOnClose) + { + if (!OperatingSystem.IsMacOS()) + return false; + + try + { + var handle = GetNativeWindowHandle(window); + if (handle == IntPtr.Zero) + return false; + + var windowClass = object_getClass(handle); + if (windowClass == IntPtr.Zero) + return false; + + _mainWindowHandle = handle; + _shouldHideOnClose = shouldHideOnClose; + _closeOverride = WindowShouldCloseOverride; + + var imp = Marshal.GetFunctionPointerForDelegate(_closeOverride); + + // Replacing on the class affects every NSWindow of this type, so the override forwards + // to the captured original implementation for any window that is not the main window. + _originalWindowShouldClose = class_replaceMethod(windowClass, sel_registerName("windowShouldClose:"), imp, "c@:@"); + + return true; + } + catch + { + return false; + } + } + + /// + /// Overrides the application delegate's applicationShouldTerminateAfterLastWindowClosed: + /// to return false, so the app keeps running in the background when the last window is closed + /// (standard behavior for a menu bar app). Acts as a safety net alongside the close interceptor. + /// + /// True if the override was installed; otherwise, false. + public static bool PreventTerminationOnLastWindowClose() + { + if (!OperatingSystem.IsMacOS()) + return false; + + try + { + var nsApplicationClass = objc_getClass("NSApplication"); + var sharedApplication = objc_msgSend_IntPtr(nsApplicationClass, sel_registerName("sharedApplication")); + var appDelegate = objc_msgSend_IntPtr(sharedApplication, sel_registerName("delegate")); + if (appDelegate == IntPtr.Zero) + return false; + + _terminateOverride = static (_, _, _) => false; + var imp = Marshal.GetFunctionPointerForDelegate(_terminateOverride); + class_replaceMethod(object_getClass(appDelegate), sel_registerName("applicationShouldTerminateAfterLastWindowClosed:"), imp, "c@:@"); + + return true; + } + catch + { + return false; + } + } + + private static bool WindowShouldCloseOverride(IntPtr self, IntPtr cmd, IntPtr sender) + { + try + { + if (self == _mainWindowHandle && _shouldHideOnClose?.Invoke() == true) + { + // Hide the window instead of closing it; the app stays alive in the background. + objc_msgSend_void_IntPtr(self, sel_registerName("orderOut:"), IntPtr.Zero); + return false; + } + } + catch + { + // Exceptions must not cross the native boundary + } + + // Forward to Uno's original implementation for non-main windows or an actual close request. + if (_originalWindowShouldClose != IntPtr.Zero) + { + var original = Marshal.GetDelegateForFunctionPointer(_originalWindowShouldClose); + return original(self, cmd, sender); + } + + return true; + } + /// /// Configures the window to extend content into the title bar while preserving /// the native macOS window chrome (traffic light buttons and rounded corners). @@ -94,9 +215,68 @@ public static bool CenterWindow(Window window) } } + /// + /// Hides the specified window without closing it, keeping the application running in the background. + /// + /// The window to hide. + /// True if the window was successfully hidden; otherwise, false. + public static bool HideWindow(Window window) + { + if (!OperatingSystem.IsMacOS()) + return false; + + try + { + var nsWindow = GetNativeWindowHandle(window); + if (nsWindow == IntPtr.Zero) + return false; + + // Call [nsWindow orderOut:nil] + var selector = sel_registerName("orderOut:"); + objc_msgSend_void_IntPtr(nsWindow, selector, IntPtr.Zero); + + return true; + } + catch + { + return false; + } + } + + /// + /// Shows a previously hidden window and brings the application to the foreground. + /// + /// The window to show. + /// True if the window was successfully shown; otherwise, false. + public static bool ShowWindow(Window window) + { + if (!OperatingSystem.IsMacOS()) + return false; + + try + { + var nsWindow = GetNativeWindowHandle(window); + if (nsWindow == IntPtr.Zero) + return false; + + // Call [nsWindow makeKeyAndOrderFront:nil] + objc_msgSend_void_IntPtr(nsWindow, sel_registerName("makeKeyAndOrderFront:"), IntPtr.Zero); + + // Call [[NSApplication sharedApplication] activateIgnoringOtherApps:YES] + var nsApplicationClass = objc_getClass("NSApplication"); + var sharedApplication = objc_msgSend_IntPtr(nsApplicationClass, sel_registerName("sharedApplication")); + objc_msgSend_void_bool(sharedApplication, sel_registerName("activateIgnoringOtherApps:"), true); + + return true; + } + catch + { + return false; + } + } + private static IntPtr GetNativeWindowHandle(Window window) { -#if __UNO_SKIA_MACOS__ try { // Get the MacOSWindowNative instance @@ -118,9 +298,6 @@ private static IntPtr GetNativeWindowHandle(Window window) { return IntPtr.Zero; } -#else - return IntPtr.Zero; -#endif } private static ulong GetStyleMask(IntPtr nsWindow) @@ -148,4 +325,4 @@ private static void SetTitlebarAppearsTransparent(IntPtr nsWindow, bool transpar } } } - +#endif diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/X11WindowHelper.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/X11WindowHelper.cs new file mode 100644 index 000000000..ce4a896de --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/X11WindowHelper.cs @@ -0,0 +1,397 @@ +#if __UNO_SKIA_X11__ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.InteropServices; +using Microsoft.UI.Input; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Input; +using Windows.Foundation; + +namespace SecureFolderFS.Uno.Platforms.Desktop.Helpers +{ + /// + /// Provides helper methods for controlling X11 windows on Linux, covering window operations + /// that Uno's X11 backend does not implement (interactive window dragging, resizing, and un-maximizing). + /// + internal static partial class X11WindowHelper + { + /// + /// The thickness, in logical pixels, of the client-drawn resize borders along the window edges. + /// + private const double RESIZE_BORDER_THICKNESS = 8d; + + // https://specifications.freedesktop.org/wm-spec/wm-spec-1.3.html + private const nint _NET_WM_STATE_REMOVE = 0; + private const nint _NET_WM_MOVERESIZE_SIZE_TOPLEFT = 0; + private const nint _NET_WM_MOVERESIZE_SIZE_TOP = 1; + private const nint _NET_WM_MOVERESIZE_SIZE_TOPRIGHT = 2; + private const nint _NET_WM_MOVERESIZE_SIZE_RIGHT = 3; + private const nint _NET_WM_MOVERESIZE_SIZE_BOTTOMRIGHT = 4; + private const nint _NET_WM_MOVERESIZE_SIZE_BOTTOM = 5; + private const nint _NET_WM_MOVERESIZE_SIZE_BOTTOMLEFT = 6; + private const nint _NET_WM_MOVERESIZE_SIZE_LEFT = 7; + private const nint _NET_WM_MOVERESIZE_MOVE = 8; + private const nint SOURCE_INDICATION_APPLICATION = 1; + private const long SubstructureNotifyMask = 1L << 19; + private const long SubstructureRedirectMask = 1L << 20; + private const int ClientMessage = 33; + + private static MethodInfo? _getHostFromWindowMethod; + private static PropertyInfo? _rootX11WindowProperty; + + /// + /// Begins an interactive, window manager-driven move of the window, equivalent to dragging its title bar. + /// Call while a pointer button is pressed; the window manager takes over the drag from there. + /// + /// The window to move. + /// True if the move request was sent to the window manager; otherwise, false. + public static bool TryBeginWindowDrag(Window window) + { + return TryBeginMoveResize(window, _NET_WM_MOVERESIZE_MOVE); + } + + /// + /// Restores the window from its maximized state back to a floating window. + /// Works around Uno's X11 OverlappedPresenter.Restore, which only un-minimizes + /// and never clears the window manager's maximized state. + /// + /// The window to un-maximize. + /// True if the request was sent to the window manager; otherwise, false. + public static bool TryUnmaximizeWindow(Window window) + { + if (!OperatingSystem.IsLinux()) + return false; + + try + { + if (GetX11Handles(window) is not { } handles) + return false; + + var (display, x11Window) = handles; + XLockDisplay(display); + try + { + SendClientMessageToRoot( + display, + x11Window, + XInternAtom(display, "_NET_WM_STATE", 0), + _NET_WM_STATE_REMOVE, + XInternAtom(display, "_NET_WM_STATE_MAXIMIZED_HORZ", 0), + XInternAtom(display, "_NET_WM_STATE_MAXIMIZED_VERT", 0), + SOURCE_INDICATION_APPLICATION, + 0); + + return true; + } + finally + { + XUnlockDisplay(display); + } + } + catch (Exception) + { + return false; + } + } + + /// + /// Enables client-drawn resize borders along the window edges. Extending content into the title bar + /// removes the window manager's decorations, including its resize frame, so edge resizing + /// has to be reimplemented by the application. + /// + /// The window to enable the resize borders for. + public static void EnableResizeBorders(Window window) + { + if (!OperatingSystem.IsLinux()) + return; + + if (window.Content is not FrameworkElement root) + return; + + _ = new ResizeBorderTracker(window, root); + } + + /// + /// Determines whether the specified window-space position falls within the client-drawn resize borders. + /// + /// The window to check. + /// The pointer position relative to the window content. + /// True if the position lies on a resize border; otherwise, false. + public static bool IsInResizeBorder(Window window, Point position) + { + if (window.Content is not FrameworkElement root) + return false; + + if (!IsResizable(window)) + return false; + + return GetResizeEdge(position, root.ActualWidth, root.ActualHeight) is not null; + } + + private static bool IsResizable(Window window) + { + return window.AppWindow?.Presenter is OverlappedPresenter { IsResizable: true, State: OverlappedPresenterState.Restored }; + } + + private static (nint Action, InputSystemCursorShape Shape)? GetResizeEdge(Point position, double width, double height) + { + if (width <= 0d || height <= 0d) + return null; + + var left = position.X <= RESIZE_BORDER_THICKNESS; + var right = position.X >= width - RESIZE_BORDER_THICKNESS; + var top = position.Y <= RESIZE_BORDER_THICKNESS; + var bottom = position.Y >= height - RESIZE_BORDER_THICKNESS; + + return (top, bottom, left, right) switch + { + (true, _, true, _) => (_NET_WM_MOVERESIZE_SIZE_TOPLEFT, InputSystemCursorShape.SizeNorthwestSoutheast), + (true, _, _, true) => (_NET_WM_MOVERESIZE_SIZE_TOPRIGHT, InputSystemCursorShape.SizeNortheastSouthwest), + (_, true, true, _) => (_NET_WM_MOVERESIZE_SIZE_BOTTOMLEFT, InputSystemCursorShape.SizeNortheastSouthwest), + (_, true, _, true) => (_NET_WM_MOVERESIZE_SIZE_BOTTOMRIGHT, InputSystemCursorShape.SizeNorthwestSoutheast), + (true, _, _, _) => (_NET_WM_MOVERESIZE_SIZE_TOP, InputSystemCursorShape.SizeNorthSouth), + (_, true, _, _) => (_NET_WM_MOVERESIZE_SIZE_BOTTOM, InputSystemCursorShape.SizeNorthSouth), + (_, _, true, _) => (_NET_WM_MOVERESIZE_SIZE_LEFT, InputSystemCursorShape.SizeWestEast), + (_, _, _, true) => (_NET_WM_MOVERESIZE_SIZE_RIGHT, InputSystemCursorShape.SizeWestEast), + _ => null + }; + } + + private static bool TryBeginMoveResize(Window window, nint action) + { + if (!OperatingSystem.IsLinux()) + return false; + + try + { + if (GetX11Handles(window) is not { } handles) + return false; + + var (display, x11Window) = handles; + XLockDisplay(display); + try + { + // Locate the pointer in root-window coordinates + var rootWindow = XDefaultRootWindow(display); + if (XQueryPointer(display, rootWindow, out _, out _, out var rootX, out var rootY, out _, out _, out _) == 0) + return false; + + // The window manager cannot take over the pointer while it is still implicitly + // grabbed by the button press that initiated the operation, so release the grab first + _ = XUngrabPointer(display, IntPtr.Zero); + + SendClientMessageToRoot( + display, + x11Window, + XInternAtom(display, "_NET_WM_MOVERESIZE", 0), + rootX, + rootY, + action, + 1 /* left mouse button */, + SOURCE_INDICATION_APPLICATION); + + return true; + } + finally + { + XUnlockDisplay(display); + } + } + catch (Exception) + { + return false; + } + } + + private static void SendClientMessageToRoot(IntPtr display, IntPtr x11Window, IntPtr messageType, nint data0, nint data1, nint data2, nint data3, nint data4) + { + var xEvent = new XClientMessageEvent() + { + type = ClientMessage, + send_event = 1, + window = x11Window, + message_type = messageType, + format = 32, + data0 = data0, + data1 = data1, + data2 = data2, + data3 = data3, + data4 = data4 + }; + + _ = XSendEvent(display, XDefaultRootWindow(display), 0, (IntPtr)(SubstructureRedirectMask | SubstructureNotifyMask), ref xEvent); + _ = XFlush(display); + } + + private static (IntPtr Display, IntPtr Window)? GetX11Handles(Window window) + { + try + { + // Uno does not expose the X11 Display handle publicly, so it has to be + // retrieved from the internal X11XamlRootHost associated with the window + _getHostFromWindowMethod ??= Type + .GetType("Uno.WinUI.Runtime.Skia.X11.X11XamlRootHost, Uno.UI.Runtime.Skia.X11")? + .GetMethod("GetHostFromWindow", BindingFlags.Public | BindingFlags.Static); + + var host = _getHostFromWindowMethod?.Invoke(null, [ window ]); + if (host is null) + return null; + + _rootX11WindowProperty ??= host.GetType().GetProperty("RootX11Window", BindingFlags.Public | BindingFlags.Instance); + var rootX11Window = _rootX11WindowProperty?.GetValue(host); + if (rootX11Window is null) + return null; + + var x11WindowType = rootX11Window.GetType(); + var display = x11WindowType.GetProperty("Display")?.GetValue(rootX11Window); + var x11Window = x11WindowType.GetProperty("Window")?.GetValue(rootX11Window); + if (display is not IntPtr displayHandle || x11Window is not IntPtr windowHandle) + return null; + + if (displayHandle == IntPtr.Zero || windowHandle == IntPtr.Zero) + return null; + + return (displayHandle, windowHandle); + } + catch (Exception) + { + return null; + } + } + + /// + /// Tracks pointer movement along the window edges to provide resize cursors + /// and initiate window manager-driven resizing. + /// + private sealed class ResizeBorderTracker + { + private static readonly Dictionary _cursorCache = new(); + private static PropertyInfo? _protectedCursorProperty; + + private readonly Window _window; + private readonly FrameworkElement _root; + private InputSystemCursorShape? _currentShape; + + public ResizeBorderTracker(Window window, FrameworkElement root) + { + _window = window; + _root = root; + + // Handled events are observed as well, so the borders also work above interactive content + root.AddHandler(UIElement.PointerMovedEvent, new PointerEventHandler(Root_PointerMoved), true); + root.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(Root_PointerPressed), true); + root.AddHandler(UIElement.PointerExitedEvent, new PointerEventHandler(Root_PointerExited), true); + } + + private void Root_PointerMoved(object sender, PointerRoutedEventArgs e) + { + var edge = IsResizable(_window) + ? GetResizeEdge(e.GetCurrentPoint(_root).Position, _root.ActualWidth, _root.ActualHeight) + : null; + + SetCursor(edge?.Shape); + } + + private void Root_PointerPressed(object sender, PointerRoutedEventArgs e) + { + // Do not interfere with elements that already handled the press (e.g. buttons, title bar drag) + if (e.Handled) + return; + + if (!IsResizable(_window)) + return; + + var point = e.GetCurrentPoint(_root); + if (!point.Properties.IsLeftButtonPressed) + return; + + if (GetResizeEdge(point.Position, _root.ActualWidth, _root.ActualHeight) is not { } edge) + return; + + if (TryBeginMoveResize(_window, edge.Action)) + e.Handled = true; + } + + private void Root_PointerExited(object sender, PointerRoutedEventArgs e) + { + SetCursor(null); + } + + private void SetCursor(InputSystemCursorShape? shape) + { + if (shape == _currentShape) + return; + + _currentShape = shape; + + // UIElement.ProtectedCursor is not publicly settable, so reflection is required + _protectedCursorProperty ??= typeof(UIElement).GetProperty("ProtectedCursor", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (_protectedCursorProperty is null) + return; + + InputSystemCursor? cursor = null; + if (shape is { } cursorShape) + { + if (!_cursorCache.TryGetValue(cursorShape, out cursor)) + { + cursor = InputSystemCursor.Create(cursorShape); + _cursorCache[cursorShape] = cursor; + } + } + + try + { + _protectedCursorProperty.SetValue(_root, cursor); + } + catch (Exception) + { + } + } + } + + // Mirrors XClientMessageEvent with 32-bit format data (l[5]), padded to the full XEvent union size + [StructLayout(LayoutKind.Sequential, Size = 192)] + private struct XClientMessageEvent + { + public int type; + public nuint serial; + public int send_event; + public IntPtr display; + public IntPtr window; + public IntPtr message_type; + public int format; + public nint data0; + public nint data1; + public nint data2; + public nint data3; + public nint data4; + } + + [LibraryImport("libX11.so.6", StringMarshalling = StringMarshalling.Utf8)] + private static partial IntPtr XInternAtom(IntPtr display, string atomName, int onlyIfExists); + + [LibraryImport("libX11.so.6")] + private static partial IntPtr XDefaultRootWindow(IntPtr display); + + [LibraryImport("libX11.so.6")] + private static partial int XQueryPointer(IntPtr display, IntPtr window, out IntPtr rootReturn, out IntPtr childReturn, out int rootX, out int rootY, out int winX, out int winY, out uint maskReturn); + + [LibraryImport("libX11.so.6")] + private static partial int XUngrabPointer(IntPtr display, IntPtr time); + + [LibraryImport("libX11.so.6")] + private static partial int XSendEvent(IntPtr display, IntPtr window, int propagate, IntPtr eventMask, ref XClientMessageEvent xEvent); + + [LibraryImport("libX11.so.6")] + private static partial int XFlush(IntPtr display); + + [LibraryImport("libX11.so.6")] + private static partial void XLockDisplay(IntPtr display); + + [LibraryImport("libX11.so.6")] + private static partial void XUnlockDisplay(IntPtr display); + } +} +#endif diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs index c7e41d26f..a1c61a788 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs @@ -13,11 +13,12 @@ using SecureFolderFS.UI.ServiceImplementation; using static SecureFolderFS.Sdk.Constants.DataSources; -#if __UNO_SKIA_MACOS__ -using SecureFolderFS.Core.MacFuse; -#else +#if !__UNO_SKIA_MACOS__ using System; +using SecureFolderFS.Core.FUSE; using SecureFolderFS.Uno.Platforms.Desktop.ViewModels; +#else +using SecureFolderFS.Core.MacFuse; #endif namespace SecureFolderFS.Uno.Platforms.Desktop.ServiceImplementation diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/SkiaWebDavFileSystem.Linux.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/SkiaWebDavFileSystem.Linux.cs index 338321da1..83f4f66eb 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/SkiaWebDavFileSystem.Linux.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/SkiaWebDavFileSystem.Linux.cs @@ -1,4 +1,20 @@ using SecureFolderFS.Storage.VirtualFileSystem; +#if HAS_UNO_SKIA && !__MACCATALYST__ && !__UNO_SKIA_MACOS__ +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using NWebDav.Server.Dispatching; +using OwlCore.Storage; +using OwlCore.Storage.Memory; +using SecureFolderFS.Core.FileSystem; +using SecureFolderFS.Core.FileSystem.Storage; +using SecureFolderFS.Core.WebDav; +using SecureFolderFS.Core.WebDav.AppModels; +using SecureFolderFS.Shared.Helpers; +using SecureFolderFS.Storage.SystemStorageEx; +using SecureFolderFS.Uno.Helpers; +#endif namespace SecureFolderFS.Uno.Platforms.Desktop { @@ -7,13 +23,13 @@ internal sealed partial class SkiaWebDavFileSystem { #if HAS_UNO_SKIA && !__MACCATALYST__ && !__UNO_SKIA_MACOS__ /// - public Task GetVolumeNameAsync(string candidateName, CancellationToken cancellationToken = default) + public override Task GetVolumeNameAsync(string candidateName, CancellationToken cancellationToken = default) { return Task.FromResult(candidateName); } /// - protected override async Task MountAsync( + protected override async Task MountAsync( FileSystemSpecifics specifics, HttpListener listener, WebDavOptions options, @@ -26,7 +42,12 @@ protected override async Task MountAsync( var webDavWrapper = new WebDavWrapper(listener, requestDispatcher, mountPath); webDavWrapper.StartFileSystem(); - return new WebDavVFSRoot(webDavWrapper, new SystemFolderEx(remotePath, options.VolumeName), specifics); + // Await a short delay before locating the folder + await Task.Delay(500); + + var virtualizedRoot = (IFolder?)SafetyHelpers.NoFailureResult(() => new SystemFolderEx(remotePath)) ?? new MemoryFolder(remotePath, Path.GetFileName(remotePath)); + var plaintextRoot = new CryptoFolder(Path.DirectorySeparatorChar.ToString(), specifics.ContentFolder, specifics); + return new WebDavVfsRoot(webDavWrapper, virtualizedRoot, plaintextRoot, specifics); } #endif } diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ViewModels/MacOSBiometricsViewModel.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ViewModels/MacOSBiometricsViewModel.cs index bba6dc7b9..d02fb1740 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ViewModels/MacOSBiometricsViewModel.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ViewModels/MacOSBiometricsViewModel.cs @@ -12,6 +12,7 @@ using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Models; using SecureFolderFS.Shared.SecureStore; +using SecureFolderFS.Storage.Extensions; #if __UNO_SKIA_MACOS__ using SecureFolderFS.Uno.PInvoke; #endif @@ -63,14 +64,19 @@ public static bool IsSupported() } /// - public override Task RevokeAsync(string? id, CancellationToken cancellationToken = default) + public override async Task RevokeAsync(string? id, CancellationToken cancellationToken = default) { #if __UNO_SKIA_MACOS__ id ??= VaultId; var alias = $"{KEY_ALIAS_PREFIX}{id}"; UnsafeNative.Biometrics.DeleteKey(alias); #endif - return Task.CompletedTask; + if (VaultFolder is not IModifiableFolder modifiableFolder) + return; + + var authenticationFile = await modifiableFolder.TryGetFileByNameAsync($"{Id}{Constants.Vault.Names.CONFIGURATION_EXTENSION}", cancellationToken); + if (authenticationFile is not null) + await modifiableFolder.DeleteAsync(authenticationFile, cancellationToken); } /// @@ -86,33 +92,56 @@ public override async Task> EnrollAsync(string id, byte[]? da if (!authenticated) throw new CryptographicException("Biometric authentication failed."); - // Get or create the Secure Enclave key pair - var privateKey = UnsafeNative.Biometrics.GetPrivateKey(alias); - if (privateKey == IntPtr.Zero) - privateKey = UnsafeNative.Biometrics.CreateSecureEnclaveKey(alias); - - // Encrypt the key material with the public key - var encrypted = UnsafeNative.Biometrics.Encrypt(privateKey, data); - return Result.Success(ManagedKey.TakeOwnership(encrypted)); + // Keychain and Secure Enclave calls can block, so keep them off the UI thread + return await Task.Run>(() => + { + // Get or create the Secure Enclave key pair + var privateKey = UnsafeNative.Biometrics.GetPrivateKey(alias); + if (privateKey == IntPtr.Zero) + privateKey = UnsafeNative.Biometrics.CreateSecureEnclaveKey(alias); + + try + { + // Encrypt the key material with the public key + var encrypted = UnsafeNative.Biometrics.Encrypt(privateKey, data); + return Result.Success(ManagedKey.TakeOwnership(encrypted)); + } + finally + { + UnsafeNative.CFRelease(privateKey); + } + }, cancellationToken); #else throw new PlatformNotSupportedException("macOS biometrics are only supported on macOS."); #endif } /// - public override Task> AcquireAsync(string id, byte[]? data, CancellationToken cancellationToken = default) + public override async Task> AcquireAsync(string id, byte[]? data, CancellationToken cancellationToken = default) { #if __UNO_SKIA_MACOS__ ArgumentNullException.ThrowIfNull(data); var alias = $"{KEY_ALIAS_PREFIX}{id}"; - var privateKey = UnsafeNative.Biometrics.GetPrivateKey(alias); - if (privateKey == IntPtr.Zero) - throw new CryptographicException("Private key could not be found."); - // Decryption with Secure Enclave key will trigger the biometric prompt automatically - var decrypted = UnsafeNative.Biometrics.Decrypt(privateKey, data); - return Task.FromResult>(Result.Success(ManagedKey.TakeOwnership(decrypted))); + // Decryption with the Secure Enclave key triggers the biometric prompt automatically + // and blocks until it is dismissed, so it must not run on the UI thread + return await Task.Run>(() => + { + var privateKey = UnsafeNative.Biometrics.GetPrivateKey(alias); + if (privateKey == IntPtr.Zero) + throw new CryptographicException("Private key could not be found."); + + try + { + var decrypted = UnsafeNative.Biometrics.Decrypt(privateKey, data); + return Result.Success(ManagedKey.TakeOwnership(decrypted)); + } + finally + { + UnsafeNative.CFRelease(privateKey); + } + }, cancellationToken); #else throw new PlatformNotSupportedException("macOS biometrics are only supported on macOS."); #endif diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Entitlements.plist b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Entitlements.plist deleted file mode 100644 index 24c310368..000000000 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Entitlements.plist +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Helpers/MacOsLifecycleHelper.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Helpers/MacOsLifecycleHelper.cs deleted file mode 100644 index a46baa2c8..000000000 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Helpers/MacOsLifecycleHelper.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using OwlCore.Storage; -using OwlCore.Storage.System.IO; -using SecureFolderFS.Sdk.Services; -using SecureFolderFS.UI.Helpers; -using SecureFolderFS.UI.ServiceImplementation; -using SecureFolderFS.Uno.Extensions; -using SecureFolderFS.Uno.Platforms.MacCatalyst.ServiceImplementation; -using Windows.Storage; -using SecureFolderFS.Shared.Extensions; -using AddService = Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions; - -namespace SecureFolderFS.Uno.Platforms.MacCatalyst.Helpers -{ - /// - internal sealed class MacOsLifecycleHelper : BaseLifecycleHelper - { - /// - protected override string AppDirectory { get; } = Path.Combine(ApplicationData.Current.LocalFolder.Path, nameof(SecureFolderFS)); - - /// - public override Task InitAsync(CancellationToken cancellationToken = default) - { - var settingsFolderPath = Path.Combine(AppDirectory, UI.Constants.FileNames.SETTINGS_FOLDER_NAME); - var settingsFolder = new SystemFolder(Directory.CreateDirectory(settingsFolderPath)); - ConfigureServices(settingsFolder); - - return Task.CompletedTask; - } - - /// - public override void LogExceptionToFile(Exception? ex) - { - ExceptionHelpers.TryWriteToFile(AppDirectory, ex); - } - - /// - public override void LogException(Exception? ex) - { - if (ex?.InnerException?.Message.Contains("The remote party") ?? false) - return; - - base.LogException(ex); - } - - /// - protected override IServiceCollection ConfigureServices(IModifiableFolder settingsFolder) - { - return base.ConfigureServices(settingsFolder) - .Override(AddService.AddSingleton) - .Override(AddService.AddSingleton) - .Override(AddService.AddSingleton) - .Override(AddService.AddSingleton) - .Override(AddService.AddSingleton) - .Override(AddService.AddSingleton) - .Override(AddService.AddSingleton) - - .WithUnoServices(settingsFolder) - - ; // Finish service initialization - } - } -} diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Info.plist b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Info.plist deleted file mode 100644 index 8759149bf..000000000 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Info.plist +++ /dev/null @@ -1,28 +0,0 @@ - - - - - UIDeviceFamily - - 2 - - LSApplicationCategoryType - public.app-category.utilities - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - XSAppIconAssets - Assets.xcassets/iconapp.appiconset - CFBundleDisplayName - SecureFolderFS - - - - diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/MacOsWebDavFileSystem.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/MacOsWebDavFileSystem.cs deleted file mode 100644 index b85729210..000000000 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/MacOsWebDavFileSystem.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using NWebDav.Server.Dispatching; -using SecureFolderFS.Core.FileSystem; -using SecureFolderFS.Core.WebDav; -using SecureFolderFS.Core.WebDav.AppModels; -using SecureFolderFS.Storage.VirtualFileSystem; -#if __MACCATALYST__ || __MACOS__ -using System.Diagnostics; -using OwlCore.Storage.Memory; -using SecureFolderFS.Uno.Helpers; -#endif - -namespace SecureFolderFS.Uno.Platforms.MacCatalyst -{ - /// - internal sealed partial class MacOsWebDavFileSystem : WebDavFileSystem - { - protected override async Task MountAsync( - FileSystemSpecifics specifics, - HttpListener listener, - WebDavOptions options, - IRequestDispatcher requestDispatcher, - CancellationToken cancellationToken) - { -#if __MACCATALYST__ || __MACOS__ - var remotePath = DriveMappingHelpers.GetRemotePath(options.Protocol, options.Domain, options.Port, options.VolumeName); - var remoteUri = new Uri(remotePath); - - // Mount WebDAV volume via AppleScript - Process.Start("/usr/bin/osascript", ["-e", $"mount volume \"{remoteUri.AbsoluteUri}\""]); - var mountPoint = $"/Volumes/{options.VolumeName}"; - - // Create wrapper - var webDavWrapper = new WebDavWrapper(listener, requestDispatcher, mountPoint); - webDavWrapper.StartFileSystem(); - - Debug.WriteLine($"Mounted {remoteUri} on {mountPoint}."); - await Task.CompletedTask; - return new WebDavVFSRoot(webDavWrapper, new MemoryFolder(mountPoint, options.VolumeName), specifics); -#else - throw new PlatformNotSupportedException(); -#endif - } - } -} diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Main.maccatalyst.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Main.maccatalyst.cs deleted file mode 100644 index 8856ac90a..000000000 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Main.maccatalyst.cs +++ /dev/null @@ -1,15 +0,0 @@ -using UIKit; - -namespace SecureFolderFS.Uno.MacCatalyst -{ - public sealed class EntryPoint - { - // This is the main entry point of the application. - public static void Main(string[] args) - { - // if you want to use a different Application Delegate class from "AppDelegate" - // you can specify it here. - UIApplication.Main(args, null, typeof(App)); - } - } -} diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Media.xcassets/LaunchImages.launchimage/Contents.json b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Media.xcassets/LaunchImages.launchimage/Contents.json deleted file mode 100644 index 69555e440..000000000 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/Media.xcassets/LaunchImages.launchimage/Contents.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "images": [ - { - "orientation": "portrait", - "extent": "full-screen", - "minimum-system-version": "7.0", - "scale": "2x", - "size": "640x960", - "idiom": "iphone" - }, - { - "orientation": "portrait", - "extent": "full-screen", - "minimum-system-version": "7.0", - "subtype": "retina4", - "scale": "2x", - "size": "640x1136", - "idiom": "iphone" - }, - { - "orientation": "portrait", - "extent": "full-screen", - "minimum-system-version": "7.0", - "scale": "1x", - "size": "768x1024", - "idiom": "ipad" - }, - { - "orientation": "landscape", - "extent": "full-screen", - "minimum-system-version": "7.0", - "scale": "1x", - "size": "1024x768", - "idiom": "ipad" - }, - { - "orientation": "portrait", - "extent": "full-screen", - "minimum-system-version": "7.0", - "scale": "2x", - "size": "1536x2048", - "idiom": "ipad" - }, - { - "orientation": "landscape", - "extent": "full-screen", - "minimum-system-version": "7.0", - "scale": "2x", - "size": "2048x1536", - "idiom": "ipad" - } - ], - "properties": {}, - "info": { - "version": 1, - "author": "" - } -} \ No newline at end of file diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsApplicationService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsApplicationService.cs deleted file mode 100644 index 31b78f16e..000000000 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsApplicationService.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Threading.Tasks; -using Windows.System; -using SecureFolderFS.Sdk.Services; -using SecureFolderFS.UI.ServiceImplementation; - -namespace SecureFolderFS.Uno.Platforms.MacCatalyst.ServiceImplementation -{ - /// - internal sealed class MacOsApplicationService : BaseApplicationService - { - /// - public override bool IsDesktop { get; } = true; - - /// - public override string Platform { get; } = "Mac Catalyst - Uno"; - - /// - public override string GetSystemVersion() - { - return Environment.OSVersion.VersionString; - } - - /// - public override async Task OpenUriAsync(Uri uri) - { - await Launcher.LaunchUriAsync(uri); - } - - public override Task TryRestartAsync() - { - return Task.CompletedTask; - } - } -} diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsSystemService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsSystemService.cs deleted file mode 100644 index 8a40293f7..000000000 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsSystemService.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using Foundation; -using OwlCore.Storage; -using SecureFolderFS.Sdk.Services; - -namespace SecureFolderFS.Uno.Platforms.MacCatalyst.ServiceImplementation -{ - /// - internal sealed class MacOsSystemService : ISystemService - { - private EventHandler? _deviceLocked; - - /// - public event EventHandler? DeviceLocked // TODO: Disabled on MacOS due to exception when adding a observer - { - add - { - return; - - _deviceLocked += value; - NSNotificationCenter.DefaultCenter.AddObserver((NSString)"com.apple.screenIsLocked", - NSKeyValueObservingOptions.New, _ => - { - _deviceLocked?.Invoke(this, EventArgs.Empty); - }); - } - remove - { - return; - - _deviceLocked -= value; - NSNotificationCenter.DefaultCenter.RemoveObserver((NSString)"com.apple.screenIsLocked"); - } - } - - /// - public Task GetAvailableFreeSpaceAsync(IFolder storageRoot, CancellationToken cancellationToken = default) - { - // TODO: Implement size calculation - return Task.FromResult(0); - } - - [DllImport("/System/Library/Frameworks/CoreServices.framework/CoreServices")] - private static extern IntPtr CGSessionCopyCurrentDictionary(); - } -} diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsVaultCredentialsService.cs deleted file mode 100644 index 2eef69bc2..000000000 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsVaultCredentialsService.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using OwlCore.Storage; -using SecureFolderFS.Core.Cryptography; -using SecureFolderFS.Core.VaultAccess; -using SecureFolderFS.Sdk.Enums; -using SecureFolderFS.Sdk.Services; -using SecureFolderFS.Sdk.Services; -using SecureFolderFS.Sdk.ViewModels.Controls.Authentication; -using SecureFolderFS.Shared; -using SecureFolderFS.Shared.Models; -using SecureFolderFS.UI.ServiceImplementation; -using SecureFolderFS.UI.ViewModels.Authentication; -using SecureFolderFS.Uno.ViewModels; - -namespace SecureFolderFS.Uno.Platforms.MacCatalyst.ServiceImplementation -{ - /// - internal sealed class MacOsVaultCredentialsService : BaseVaultCredentialsService - { - /// - public override async IAsyncEnumerable GetLoginAsync(IFolder vaultFolder, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var vaultReader = new VaultReader(vaultFolder, StreamSerializer.Instance); - var config = await vaultReader.ReadConfigurationAsync(cancellationToken); - var authenticationMethod = AuthenticationMethod.FromString(config.AuthenticationMethod); - - foreach (var item in authenticationMethod.Methods) - { - yield return item switch - { - Core.Constants.Vault.Authentication.AUTH_PASSWORD => new PasswordLoginViewModel(), - Core.Constants.Vault.Authentication.AUTH_KEYFILE => new KeyFileLoginViewModel(vaultFolder), - Core.Constants.Vault.Authentication.AUTH_HARDWARE_KEY => YubiKeyViewModel.IsSupported() - ? new YubiKeyLoginViewModel(vaultFolder, config.Uid) - : throw new NotSupportedException($"The authentication method '{item}' is not supported by the platform. Please insert your YubiKey."), - _ => throw new NotSupportedException($"The authentication method '{item}' is not supported by the platform.") - }; - } - } - - /// - public override async IAsyncEnumerable GetCreationAsync(IFolder vaultFolder, string vaultId, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - // Password - yield return new PasswordCreationViewModel(); - - // Key File - yield return new KeyFileCreationViewModel(vaultId); - - var iapService = DI.Service(); - if (await iapService.IsOwnedAsync(IapProductType.Any, cancellationToken)) - { - // YubiKey - if (YubiKeyViewModel.IsSupported()) - yield return new YubiKeyCreationViewModel(vaultFolder, vaultId) { Icon = new ImageGlyph("\uEE7E") }; - } - - await Task.CompletedTask; - } - } -} diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsVaultFileSystemService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsVaultFileSystemService.cs deleted file mode 100644 index 1e5d5dee2..000000000 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsVaultFileSystemService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using SecureFolderFS.Core.FUSE; -using SecureFolderFS.Sdk.Services; -using SecureFolderFS.Storage.VirtualFileSystem; -using SecureFolderFS.UI.ServiceImplementation; - -namespace SecureFolderFS.Uno.Platforms.MacCatalyst.ServiceImplementation -{ - /// - internal sealed class MacOsVaultFileSystemService : BaseVaultFileSystemService - { - /// - public override async IAsyncEnumerable GetFileSystemsAsync([EnumeratorCancellation] CancellationToken cancellationToken) - { - await Task.CompletedTask; - yield return new FuseFileSystem(); - yield return new MacOsWebDavFileSystem(); - } - } -} diff --git a/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj b/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj index e2cf67010..13a71d0a6 100644 --- a/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj +++ b/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj @@ -159,6 +159,36 @@ + + + + <_SkiaTargetOS>$(DesktopTargetOS.ToLowerInvariant()) + <_SkiaTargetOS Condition="'$(_SkiaTargetOS)' == '' AND $(RuntimeIdentifier.StartsWith('osx'))">macos + <_SkiaTargetOS Condition="'$(_SkiaTargetOS)' == '' AND $(RuntimeIdentifier.StartsWith('linux'))">linux + <_SkiaTargetOS Condition="'$(_SkiaTargetOS)' == '' AND $(RuntimeIdentifier.StartsWith('win'))">windows + <_SkiaTargetOS Condition="'$(_SkiaTargetOS)' == '' AND $([MSBuild]::IsOSPlatform('OSX'))">macos + <_SkiaTargetOS Condition="'$(_SkiaTargetOS)' == '' AND $([MSBuild]::IsOSPlatform('Linux'))">linux + <_SkiaTargetOS Condition="'$(_SkiaTargetOS)' == '' AND $([MSBuild]::IsOSPlatform('Windows'))">windows + + + + $(DefineConstants.Replace('__UNO_SKIA_MACOS__', '').Replace('HAS_UNO_SKIA_MACOS', '')) + + + $(DefineConstants.Replace('__UNO_SKIA_X11__', '').Replace('HAS_UNO_SKIA_X11', '').Replace('__UNO_SKIA_LINUX_FB__', '').Replace('HAS_UNO_SKIA_LINUX_FB', '')) + + + $(DefineConstants.Replace('__UNO_SKIA_WIN32__', '').Replace('HAS_UNO_SKIA_WIN32', '').Replace('__UNO_SKIA_WPF__', '').Replace('HAS_UNO_SKIA_WPF', '')) + + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs index 1c7004af0..466379079 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs @@ -23,6 +23,11 @@ using SecureFolderFS.UI.Helpers; using SecureFolderFS.Uno.Helpers; using Uno.UI; +#if __UNO_SKIA_MACOS__ +using System.IO; +using SecureFolderFS.Storage.VirtualFileSystem; +using SecureFolderFS.Uno.Platforms.Desktop.Helpers; +#endif // To learn more about WinUI, the WinUI project structure, // and more about our project templates, see: http://aka.ms/winui-project-info. @@ -56,10 +61,53 @@ private void MainWindowRootControl_Loaded(object sender, RoutedEventArgs e) if (OperatingSystem.IsMacCatalyst()) RootGrid.Margin = new(0, 37, 0, 0); +#if __UNO_SKIA_MACOS__ + InitializeStatusBarIcon(); +#endif + ViewModel?.RootNavigationService.SetupNavigation(Navigation); _ = EnsureRootAsync(); } +#if __UNO_SKIA_MACOS__ + private void InitializeStatusBarIcon() + { + // Keep the app alive when the last window is closed (standard menu bar app behavior) + MacOsWindowHelper.PreventTerminationOnLastWindowClose(); + + var statusBar = MacOsStatusBarHelper.GetOrCreate( + Path.Combine(Directory.GetCurrentDirectory(), "Assets", "AppIcon", "AppIcon.icns"), + "ViewInApp".ToLocalized(), + "LockAll".ToLocalized(), + "ExitApp".ToLocalized()); + if (statusBar is null) + return; + + statusBar.ShowAppRequested = () => + { + if (App.Instance?.MainWindow is not { } mainWindow) + return; + + MacOsWindowHelper.ShowWindow(mainWindow); + mainWindow.Activate(); + }; + statusBar.LockAllRequested = LockAllVaults; + statusBar.ExitAppRequested = () => + { + LockAllVaults(); + App.Instance?.UseForceClose = true; + Application.Current.Exit(); + }; + + statusBar.SetLockAllEnabled(!FileSystemManager.Instance.FileSystems.IsEmpty()); + FileSystemManager.Instance.FileSystems.CollectionChanged += (_, _) => + { + var isEnabled = !FileSystemManager.Instance.FileSystems.IsEmpty(); + DispatcherQueue.TryEnqueue(() => statusBar.SetLockAllEnabled(isEnabled)); + }; + } +#endif + private async Task EnsureRootAsync() { #if WINDOWS @@ -218,13 +266,16 @@ private void MenuShowApp_Click(object sender, RoutedEventArgs e) private void MenuLockAll_Click(object sender, RoutedEventArgs e) { -#if WINDOWS + LockAllVaults(); + } + + private void LockAllVaults() + { if (ViewModel is null) return; foreach (var item in ViewModel.VaultCollectionModel) WeakReferenceMessenger.Default.Send(new VaultLockRequestedMessage(item)); -#endif } } } diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml.cs index d229bf9ed..ee9e90bb7 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml.cs @@ -37,11 +37,15 @@ private void ResetAnimationState() private static void ResetIcon(Border icon) { + // The static scale stays at 1.0 (opacity hides the icon) so WinUI rasterizes + // the content at full resolution; a static 0.4 would make the composition + // surface low-res and the pop animation would scale it up blurry. The + // storyboard's From=0.4 provides the visual starting point instead. icon.Opacity = 0d; if (icon.RenderTransform is ScaleTransform scale) { - scale.ScaleX = 0.4d; - scale.ScaleY = 0.4d; + scale.ScaleX = 1d; + scale.ScaleY = 1d; } } diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml.cs index 99020b7de..ffe0a585b 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml.cs @@ -10,7 +10,7 @@ using SecureFolderFS.UI.Enums; using SecureFolderFS.Uno.Helpers; using SkiaSharp; -#if HAS_UNO_SKIA +#if HAS_UNO_SKIA || __UNO_SKIA_MACOS__ || __UNO_SKIA_X11__ using Windows.Foundation; using Uno.WinUI.Graphics2DSK; #else @@ -21,9 +21,7 @@ namespace SecureFolderFS.Uno.UserControls.Introduction { public sealed partial class EncryptedFileSlide : UserControl, IAsyncInitialize, IDisposable { - private const float INNER_SHADOW_OFFSET = 6f; - private const float DEFORM_STRENGTH = 0.25f; - private const float LENS_ZOOM = 1.3f; + private const float LENS_ZOOM = 1.15f; private const string UI_ASSEMBLY_NAME = $"{nameof(SecureFolderFS)}.UI"; private const float VELOCITY_SCALE = 0.00018f; // pixels/sec to deform ratio @@ -32,17 +30,13 @@ public sealed partial class EncryptedFileSlide : UserControl, IAsyncInitialize, private const float SPRING_STIFFNESS = 280f; private const float SPRING_DAMPING = 18f; -#if HAS_UNO_SKIA - private const float MAGNIFIER_RADIUS = 115f; - private const float MOVEMENT_THRESHOLD = 2.5f; -#else + // Logical (DIP) units, converted to physical pixels per frame - the lens + // occupies the same visual size regardless of screen scaling or resolution private const float MAGNIFIER_RADIUS = 60f; - private const float MOVEMENT_THRESHOLD = 1f; -#endif private bool _isInitialized; -#if HAS_UNO_SKIA +#if HAS_UNO_SKIA || __UNO_SKIA_MACOS__ || __UNO_SKIA_X11__ private readonly LensCanvas _canvas; #else private readonly SKXamlCanvas _canvas; @@ -57,20 +51,8 @@ public sealed partial class EncryptedFileSlide : UserControl, IAsyncInitialize, private SKSurface? _sceneSurface; private SKSizeI _sceneSize; - // Reusable resources for better performance - private readonly SKPaint _highlightPaint; - private readonly SKPaint _shadowPaint; - private readonly SKPaint _blurPaint; - - private SKColor[]? _cachedEdgeColors; - private SKColor[]? _cachedCoreColors; - private float[]? _cachedSweepStops; - - private readonly SKPaint _invisiblePaint; // for the warping trick - private readonly SKPaint _outerGlowPaint; - private readonly SKPaint _iridescentPaint; - private readonly SKPaint _corePaint; - private readonly SKPaint _additiveGlowPaint; + // Draws the glass disc with per-pixel refraction + private readonly GlassLensRenderer _lensRenderer = new(); // Fluid pointer dynamics private SKPoint _targetPosition; @@ -91,7 +73,7 @@ public EncryptedFileSlide() { InitializeComponent(); -#if HAS_UNO_SKIA +#if HAS_UNO_SKIA || __UNO_SKIA_MACOS__ || __UNO_SKIA_X11__ // SKCanvasElement renders in the app's composition pass (GPU-backed, no // per-frame bitmap upload), unlike SKXamlCanvas which lags on desktop _canvas = new LensCanvas(this); @@ -103,68 +85,6 @@ public EncryptedFileSlide() _canvas.VerticalAlignment = VerticalAlignment.Stretch; SlotGrid.Children.Add(_canvas); - // Pre-create expensive paint objects - _blurPaint = new SKPaint - { - IsAntialias = true, - ImageFilter = SKImageFilter.CreateBlur(4.8f, 4.8f, SKImageFilter.CreateBlur(1f, 1f)) // light + subtle secondary blur - }; - - _highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.White.WithAlpha(140), - StrokeWidth = 1.8f, - IsAntialias = true - }; - - _shadowPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = new SKColor(0, 0, 0, 30), - StrokeWidth = 3f, - IsAntialias = true, - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 6.5f) - }; - - _invisiblePaint = new SKPaint - { - IsAntialias = true, - ColorFilter = SKColorFilter.CreateBlendMode(new SKColor(255, 255, 255, 0), SKBlendMode.SrcOver) - }; - - _outerGlowPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = 24f, - IsAntialias = true, - BlendMode = SKBlendMode.Screen - }; - - _iridescentPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = 12f, - IsAntialias = true, - BlendMode = SKBlendMode.Screen - }; - - _corePaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = 3.5f, - IsAntialias = true, - BlendMode = SKBlendMode.Screen - }; - - _additiveGlowPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = 28f, - IsAntialias = true, - BlendMode = SKBlendMode.Plus - }; - _animTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; // ~60 fps _animTimer.Tick += AnimTimer_Tick; } @@ -183,7 +103,9 @@ public Task InitAsync(CancellationToken cancellationToken = default) _wallpaperBitmap = LoadBitmap(assembly, $"Introduction.intro_wallpaper{rnd}.jpg"); _hexBitmap = LoadBitmap(assembly, "Introduction." + UnoThemeHelper.Instance.ActualTheme switch { +#if !__UNO_SKIA_X11__ ThemeType.Light => "intro_hex_light.png", +#endif _ => "intro_hex_dark.png" }); if (_wallpaperBitmap is not null) @@ -192,9 +114,6 @@ public Task InitAsync(CancellationToken cancellationToken = default) _wallpaperBitmap = FlipBitmap(_wallpaperBitmap, horizontal: true, vertical: true); } - _cachedEdgeColors = null; - _cachedSweepStops = null; - _cachedCoreColors = null; _canvas.Invalidate(); _isInitialized = true; @@ -243,7 +162,7 @@ private static SKBitmap FlipBitmap(SKBitmap src, bool horizontal, bool vertical) return result; } -#if HAS_UNO_SKIA +#if HAS_UNO_SKIA || __UNO_SKIA_MACOS__ || __UNO_SKIA_X11__ private sealed class LensCanvas(EncryptedFileSlide owner) : SKCanvasElement { protected override void RenderOverride(SKCanvas canvas, Size area) @@ -253,7 +172,7 @@ protected override void RenderOverride(SKCanvas canvas, Size area) var scale = (float)(owner.XamlRoot?.RasterizationScale ?? 1.0); canvas.Save(); canvas.Scale(1f / scale); - owner.RenderSlide(canvas, (float)area.Width * scale, (float)area.Height * scale); + owner.RenderSlide(canvas, (float)area.Width * scale, (float)area.Height * scale, scale); canvas.Restore(); } } @@ -262,15 +181,21 @@ private void SkiaCanvas_PaintSurface(object sender, SKPaintSurfaceEventArgs e) { var canvas = e.Surface.Canvas; canvas.Clear(SKColors.Transparent); - RenderSlide(canvas, e.Info.Width, e.Info.Height); + + // Display scaling, not canvas-to-control ratio: the canvas only fills the + // left slot, so dividing by this control's width would shrink the lens + var scale = (float)(XamlRoot?.RasterizationScale ?? 1.0); + RenderSlide(canvas, e.Info.Width, e.Info.Height, scale); } #endif - private void RenderSlide(SKCanvas canvas, float width, float height) + private void RenderSlide(SKCanvas canvas, float width, float height, float scale) { if (width < 1f || height < 1f) return; + var radius = MAGNIFIER_RADIUS * scale; + var center = _smoothPosition != default ? _smoothPosition : _pointerPosition ?? new SKPoint(width / 2f, height / 2f); var sceneSize = new SKSizeI((int)width, (int)height); if (_sceneSurface is null || sceneSize != _sceneSize) @@ -306,12 +231,14 @@ private void RenderSlide(SKCanvas canvas, float width, float height) using var erasePaint = new SKPaint(); erasePaint.IsAntialias = true; erasePaint.BlendMode = SKBlendMode.DstOut; - erasePaint.Shader = SKShader.CreateRadialGradient(center, MAGNIFIER_RADIUS, - [new SKColor(0, 0, 0, 250), new SKColor(0, 0, 0, 200), SKColors.Transparent], - [0.2f, 0.4f, 1f], + // Fully clear until deep into the lens so the magnified interior has no dark + // vignette (which would read as sphere shading instead of flat glass) + erasePaint.Shader = SKShader.CreateRadialGradient(center, radius, + [new SKColor(0, 0, 0, 255), new SKColor(0, 0, 0, 245), SKColors.Transparent], + [0.6f, 0.88f, 1f], SKShaderTileMode.Clamp); - scene.DrawCircle(center, MAGNIFIER_RADIUS, erasePaint); + scene.DrawCircle(center, radius, erasePaint); scene.Restore(); } @@ -320,215 +247,8 @@ private void RenderSlide(SKCanvas canvas, float width, float height) canvas.DrawImage(snapshot, 0f, 0f); // Layer 3: Liquid Glass Lens - DrawLiquidGlassLens(canvas, center, snapshot, _lensScale); - } - - private void DrawLiquidGlassLens(SKCanvas canvas, SKPoint center, SKImage snapshot, float lensScale = 1f) - { - var r = MAGNIFIER_RADIUS; - var (rx, ry) = ComputeRingRadii(r); - var innerR = r * 0.76f; - - // Approximate inner ellipse with same aspect ratio as outer - var innerRx = innerR * (rx / r); - var innerRy = innerR * (ry / r); - - using var ringPath = new SKPath(); - ringPath.AddOval(new SKRect(center.X - rx, center.Y - ry, center.X + rx, center.Y + ry)); - ringPath.AddOval(new SKRect(center.X - innerRx, center.Y - innerRy, center.X + innerRx, center.Y + innerRy)); - ringPath.FillType = SKPathFillType.EvenOdd; - - // Lens Interior with Edge Deformation - canvas.SaveLayer(); - canvas.ClipPath(ringPath, SKClipOperation.Intersect, true); - - canvas.Save(); - - // Center the transform - canvas.Translate(center.X, center.Y); - - // Base zoom (slightly reduced so deformation is more visible) - canvas.Scale(LENS_ZOOM * lensScale, LENS_ZOOM * lensScale); - - // Edge Deformation - // This creates a directional outward push at the four edges - // We apply a small additional translation based on normalized position - // This is approximated by drawing the image multiple times with slight offsets - - // 1. Base zoomed content - canvas.Translate(-center.X, -center.Y); - canvas.DrawImage(snapshot, 0, 0, _blurPaint); - - // 2. Deformed passes for edge stretch (directional) - using var deformPaint = new SKPaint(); - deformPaint.IsAntialias = true; - deformPaint.ColorFilter = SKColorFilter.CreateBlendMode(new SKColor(255, 255, 255, 40), SKBlendMode.SrcOver); - - // Top edge - push upward - canvas.DrawImage(snapshot, 0, DEFORM_STRENGTH * 12, deformPaint); - - // Bottom edge - push downward - canvas.DrawImage(snapshot, 0, -DEFORM_STRENGTH * 12, deformPaint); - - // Left edge - push left - canvas.DrawImage(snapshot, DEFORM_STRENGTH * 12, 0, deformPaint); - - // Right edge - push right - canvas.DrawImage(snapshot, -DEFORM_STRENGTH * 12, 0, deformPaint); - - // Invisible pass to help with warping consistency - canvas.DrawImage(snapshot, 0, 0, _invisiblePaint); - - canvas.Restore(); // end all transforms - - // Internal Glass Effects - - // Caustic light scattering - var causticPhase = (float)(DateTime.UtcNow.TimeOfDay.TotalSeconds * 0.8) % (MathF.PI * 2); - using var causticPaint = new SKPaint - { - BlendMode = SKBlendMode.Screen, - Shader = SKShader.CreateRadialGradient( - new SKPoint(center.X + MathF.Sin(causticPhase) * 12, - center.Y + MathF.Cos(causticPhase) * 12), - r * 0.45f, - [SKColors.White.WithAlpha(0), SKColors.White.WithAlpha(90), SKColors.White.WithAlpha(0)], - [0.3f, 0.7f, 1f], SKShaderTileMode.Clamp) - }; - canvas.DrawCircle(center, r * 0.65f, causticPaint); - - // Dynamic inner shadow for thickness - using var innerShadowPaint = new SKPaint - { - BlendMode = SKBlendMode.DstOut, - Shader = SKShader.CreateRadialGradient( - new SKPoint(center.X - INNER_SHADOW_OFFSET, center.Y - INNER_SHADOW_OFFSET), - r * 0.82f, - [new SKColor(0, 0, 0, 0), new SKColor(0, 0, 0, 80)], - [0.7f, 1f], SKShaderTileMode.Clamp) - }; - canvas.DrawCircle(center, r * 0.78f, innerShadowPaint); - - // Smooth radial fade - using var fadePaint = new SKPaint - { - BlendMode = SKBlendMode.DstIn, - Shader = SKShader.CreateRadialGradient(center, r, - [SKColors.Transparent, SKColors.Transparent, new SKColor(255, 255, 255, 235)], - [0f, innerR / r, 1f], SKShaderTileMode.Clamp) - }; - canvas.DrawCircle(center, r, fadePaint); - - canvas.Restore(); // end lens interior SaveLayer - - // Iridescent Rim + Edge Highlights - var edgeColors = GetRimColors(snapshot, center, r); - if (_cachedSweepStops == null || _cachedSweepStops.Length != edgeColors.Length) - { - _cachedSweepStops = Enumerable.Range(0, edgeColors.Length) - .Select(i => i / (float)(edgeColors.Length - 1)).ToArray(); - } - - if (_cachedCoreColors == null || _cachedCoreColors.Length != edgeColors.Length) - _cachedCoreColors = edgeColors.Select(c => LightenColor(c, 70)).ToArray(); - - // Deformed ring rect - rx/ry drive horizontal/vertical radius independently - var edgeRingRect = new SKRect(center.X - rx + 1, center.Y - ry + 1, center.X + rx - 1, center.Y + ry - 1); - - // Wide outer glow - _outerGlowPaint.Shader = SKShader.CreateSweepGradient(center, edgeColors, _cachedSweepStops); - canvas.DrawOval(edgeRingRect, _outerGlowPaint); - - // Vibrant mid ring - _iridescentPaint.Shader = SKShader.CreateSweepGradient(center, edgeColors, _cachedSweepStops); - canvas.DrawOval(edgeRingRect, _iridescentPaint); - - // Bright core - _corePaint.Shader = SKShader.CreateSweepGradient(center, _cachedCoreColors, _cachedSweepStops); - canvas.DrawOval(edgeRingRect, _corePaint); - - // Aggressive additive glow - _additiveGlowPaint.Shader = SKShader.CreateSweepGradient(center, edgeColors, _cachedSweepStops); - canvas.DrawOval(edgeRingRect, _additiveGlowPaint); - - // Fresnel bright edge - follows deformed shape - using var fresnelPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = 3.5f, - IsAntialias = true, - BlendMode = SKBlendMode.Screen, - Color = SKColors.White.WithAlpha(180) - }; - var fresnelRect = new SKRect(center.X - rx + 1.5f, center.Y - ry + 1.5f, center.X + rx - 1.5f, center.Y + ry - 1.5f); - canvas.DrawOval(fresnelRect, fresnelPaint); - canvas.DrawOval(fresnelRect, _highlightPaint); - - // Outer shadow - a slightly larger oval - var shadowRect = new SKRect(center.X - rx - 3f, center.Y - ry - 3f, center.X + rx + 3f, center.Y + ry + 3f); - canvas.DrawOval(shadowRect, _shadowPaint); - - // Specular highlight - arc mapped onto the deformed ellipse - using var specularPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = 2.8f, - IsAntialias = true - }; - specularPaint.Shader = SKShader.CreateLinearGradient( - new SKPoint(center.X + rx * 0.55f, center.Y - ry * 0.65f), - new SKPoint(center.X + rx * 0.95f, center.Y + ry * 0.45f), - [SKColors.Transparent, SKColors.White.WithAlpha(235), SKColors.Transparent], - [0f, 0.5f, 1f], - SKShaderTileMode.Clamp); - - canvas.DrawArc( - new SKRect(center.X - rx + 6, center.Y - ry + 6, center.X + rx - 6, center.Y + ry - 6), - 305, 110, false, specularPaint); - } - - private SKColor[] GetRimColors(SKImage snapshot, SKPoint center, float radius) - { - // Recompute only when necessary (e.g., the pointer moved a lot) - if (_cachedEdgeColors != null) - return _cachedEdgeColors; - - _cachedEdgeColors = SampleRimColors(snapshot, center, radius, sampleCount: 20); - return _cachedEdgeColors; - } - - /// - /// Samples pixel colors from the lens rim and boosts them aggressively for a vivid iridescent effect. - /// - private static SKColor[] SampleRimColors(SKImage image, SKPoint center, float radius, int sampleCount) - { - var colors = new SKColor[sampleCount + 1]; - using var bitmap = SKBitmap.FromImage(image); - - for (var i = 0; i < sampleCount; i++) - { - var angle = 2f * MathF.PI * i / sampleCount; - var x = (int)(center.X + radius * MathF.Cos(angle)); - var y = (int)(center.Y + radius * MathF.Sin(angle)); - - x = Math.Clamp(x, 0, bitmap.Width - 1); - y = Math.Clamp(y, 0, bitmap.Height - 1); - - var pixel = bitmap.GetPixel(x, y); - pixel.ToHsv(out var h, out var s, out var v); - - // Aggressive boost for maximum visibility - s = Math.Min(1f, s * 5.0f + 0.65f); // very heavy saturation - v = Math.Min(1f, v * 3.6f + 0.55f); // strong brightness push - - // Gentle hue rotation for a more dynamic color feel - h = (h + 0.025f) % 1f; - - colors[i] = SKColor.FromHsv(h, s, v).WithAlpha(250); - } - - colors[sampleCount] = colors[0]; - return colors; + var (radiusX, radiusY) = ComputeRingRadii(radius); + _lensRenderer.Draw(canvas, snapshot, center, radius, radiusX, radiusY, LENS_ZOOM * _lensScale); } /// @@ -567,18 +287,6 @@ private static SKColor[] SampleRimColors(SKImage image, SKPoint center, float ra return (uniformR * (1f + deformX), uniformR * (1f + deformY)); } - /// - /// Mixes a color toward white by (0–255) for the bright core pass. - /// - private static SKColor LightenColor(SKColor color, byte amount) - { - return new SKColor( - (byte)Math.Min(255, color.Red + amount), - (byte)Math.Min(255, color.Green + amount), - (byte)Math.Min(255, color.Blue + amount), - color.Alpha); - } - private static SKRect ComputeUniformToFillRect(int srcWidth, int srcHeight, int dstWidth, int dstHeight) { var scale = Math.Max((float)dstWidth / srcWidth, (float)dstHeight / srcHeight); @@ -639,12 +347,6 @@ private void AnimTimer_Tick(object? sender, object e) StopAnimation(); } - // Invalidate edge color cache if smoothed position moved meaningfully - var moved = MathF.Abs(_smoothPosition.X - prevSmooth.X) > MOVEMENT_THRESHOLD - || MathF.Abs(_smoothPosition.Y - prevSmooth.Y) > MOVEMENT_THRESHOLD; - if (moved) - _cachedEdgeColors = null; - _canvas.Invalidate(); } @@ -716,7 +418,7 @@ private void UpdatePointer(PointerRoutedEventArgs e) if (width <= 0 || height <= 0) return; -#if HAS_UNO_SKIA +#if HAS_UNO_SKIA || __UNO_SKIA_MACOS__ || __UNO_SKIA_X11__ // The lens is rendered in physical pixels; map the logical pointer position accordingly var scaleX = (float)(XamlRoot?.RasterizationScale ?? 1.0); var scaleY = scaleX; @@ -725,8 +427,8 @@ private void UpdatePointer(PointerRoutedEventArgs e) var scaleY = _canvas.CanvasSize.Height / height; #endif - var clampedX = Math.Clamp((float)pos.X, MAGNIFIER_RADIUS / scaleX, width - MAGNIFIER_RADIUS / scaleX); - var clampedY = Math.Clamp((float)pos.Y, MAGNIFIER_RADIUS / scaleY, height - MAGNIFIER_RADIUS / scaleY); + var clampedX = Math.Clamp((float)pos.X, MAGNIFIER_RADIUS, width - MAGNIFIER_RADIUS); + var clampedY = Math.Clamp((float)pos.Y, MAGNIFIER_RADIUS, height - MAGNIFIER_RADIUS); var newPosition = new SKPoint(clampedX * scaleX, clampedY * scaleY); _targetPosition = newPosition; @@ -753,10 +455,6 @@ private void UpdatePointer(PointerRoutedEventArgs e) _wallpaperBitmap = null; _hexBitmap = null; _sceneSurface = null; - - _cachedEdgeColors = null; - _cachedSweepStops = null; - _cachedCoreColors = null; } } } diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/GlassLensRenderer.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/GlassLensRenderer.cs new file mode 100644 index 000000000..8bcdb5e8d --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/GlassLensRenderer.cs @@ -0,0 +1,306 @@ +using System; +using SkiaSharp; + +namespace SecureFolderFS.Uno.UserControls.Introduction +{ + /// + /// Paints a glass-style lens over a scene snapshot. A thick convex glass disc + /// with true per-pixel refraction. The interior magnifies the content while samples + /// sweep outward across the rim, visibly bending the surrounding image around the + /// edge, with chromatic dispersion fringing the bent band like real glass. + /// + internal sealed class GlassLensRenderer : IDisposable + { + private const float SOURCE_PADDING = 1.6f; // sampled region extent, relative to the radius + private const float SOURCE_BLUR_SIGMA = 2.5f; // frost of the wrap band and the seam - the stretch stays sharp + private const float STRETCH_START = 0.70f; // where the interior content starts stretching outward + private const float WRAP_START = 0.84f; // where the outer wrap band takes over + private const float STRETCH_GAIN = 0.5f; // how little source the stretch band covers (smaller = more stretch) + private const float FROST_START = 0.88f; // frost onset of the wrap band + private const float EDGE_OVERSHOOT = 0.42f; // how far past the rim the wrap band's samples reach + private const float EDGE_DISPERSION = 0.028f; // chromatic separation in the bent bands (relative radius) + private const float INNER_BEND = 0.03f; // near-imperceptible curvature of the interior glyphs + private const float INNER_DISPERSION = 0.005f; // subtle chromatic fringing across the interior + private const float RIM_CHROMATIC_OFFSET = 1.4f; // radial separation of the specular ring's color channels + + /// + /// Refraction of a thick glass disc with a flat center. Coordinates are in canvas + /// units. Three radial zones: a flat interior; a stretch band where magnification + /// rises sharply so the inner content smears outward toward the rim; and a wrap band + /// where samples sweep past the rim, compressing the surroundings around the edge. + /// The opposing slopes meet at uWrapStart. That seam is masked with a localized + /// frost bump and a slight dim, like light scattering inside the glass fold. + /// + private const string LENS_SKSL = + """ + uniform shader uContent; + uniform shader uFrosted; + uniform float2 uCenter; + uniform float uRadius; + uniform float uZoom; + uniform float uStretchStart; + uniform float uWrapStart; + uniform float uStretchGain; + uniform float uFrostStart; + uniform float uOvershoot; + uniform float uEdgeDispersion; + uniform float uInnerBend; + uniform float uInnerDispersion; + + half4 main(float2 frag) { + float2 offset = frag - uCenter; + float r = length(offset); + float rn = min(r / uRadius, 1.0); + float2 dir = r > 0.001 ? offset / r : float2(0.0, 0.0); + + // Flat interior mapping + float interiorRn = (rn / uZoom) * (1.0 + uInnerBend * rn * rn); + + // Stretch band. The sample radius saturates (ease-out), so magnification keeps + // rising toward the band's end and the content visibly smears outward + float bandWidth = uWrapStart - uStretchStart; + float t = clamp((rn - uStretchStart) / bandWidth, 0.0, 1.0); + float ease = 1.0 - pow(1.0 - t, 2.2); + float s0 = (uStretchStart / uZoom) * (1.0 + uInnerBend * uStretchStart * uStretchStart); + float sEnd = s0 + bandWidth / uZoom * uStretchGain; + float stretchRn = s0 + (sEnd - s0) * ease; + float innerRn = rn < uStretchStart ? interiorRn : stretchRn; + + // Wrap band. Samples sweep from the stretch band's end past the rim, + // compressing the remaining inner content and the surroundings around the edge + float w = clamp((rn - uWrapStart) / (1.0 - uWrapStart), 0.0, 1.0); + float wrap = pow(w, 1.6); + float wrapRn = sEnd + (1.0 + uOvershoot - sEnd) * wrap; + + // Soft handoff between the opposing zones + float blend = smoothstep(uWrapStart - 0.025, uWrapStart + 0.025, rn); + float baseRn = mix(innerRn, wrapRn, blend); + + float dispersion = uInnerDispersion * rn + uEdgeDispersion * (0.15 * ease + wrap); + float3 sampleRn = baseRn + float3(-dispersion, 0.0, dispersion); + float3 sampleR = sampleRn * uRadius; + + float2 pr = uCenter + dir * sampleR.x; + float2 pg = uCenter + dir * sampleR.y; + float2 pb = uCenter + dir * sampleR.z; + + half3 sharp = half3(uContent.eval(pr).r, uContent.eval(pg).g, uContent.eval(pb).b); + half3 frosted = half3(uFrosted.eval(pr).r, uFrosted.eval(pg).g, uFrosted.eval(pb).b); + + // The seam between the zones is masked by a localized frost bump and a slight dim + float seamDelta = (rn - uWrapStart) / 0.035; + float seam = exp(-seamDelta * seamDelta); + float frost = max(smoothstep(uFrostStart, 1.0, rn), 0.8 * seam); + + half3 col = mix(sharp, frosted, half(frost)); + col *= 1.0 + 0.10 * half(wrap); + col *= 1.0 - 0.12 * half(seam); + return half4(min(col, half3(1.0)), 1.0); + } + """; + + private static readonly SKRuntimeEffect? LensEffect = SKRuntimeEffect.CreateShader(LENS_SKSL, out _); + + private SKSurface? _sourceSurface; + private SKSurface? _frostSurface; + private int _sourceSize; + private readonly SKPaint _sourceBlurPaint; + private readonly SKPaint _dropShadowPaint; + private readonly SKPaint _innerEdgePaint; + private readonly SKPaint _rimSpecularPaint; + + public GlassLensRenderer() + { + // The under-glass frosting + _sourceBlurPaint = new SKPaint + { + IsAntialias = true, + ImageFilter = SKImageFilter.CreateBlur(SOURCE_BLUR_SIGMA, SOURCE_BLUR_SIGMA) + }; + + _dropShadowPaint = new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = new SKColor(0, 0, 0, 40), + StrokeWidth = 4f, + IsAntialias = true, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 7f) + }; + + // Thin dark contour just inside the edge, selling the glass thickness + _innerEdgePaint = new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = new SKColor(0, 0, 0, 36), + StrokeWidth = 1.6f, + IsAntialias = true, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 1.2f) + }; + + _rimSpecularPaint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = 2.2f, + IsAntialias = true, + BlendMode = SKBlendMode.Plus + }; + } + + /// + /// Draws the lens onto . + /// + /// The target canvas. + /// A snapshot of the fully composed scene beneath the lens. + /// The lens center, in canvas units. + /// The undeformed lens radius. + /// The horizontal radius after squash-and-stretch deformation. + /// The vertical radius after squash-and-stretch deformation. + /// The magnification of the lens interior. + public void Draw(SKCanvas canvas, SKImage scene, SKPoint center, float radius, float radiusX, float radiusY, float zoom) + { + using var lensSource = CreateLensSource(scene, center, radius, frosted: false); + using var frostedSource = CreateLensSource(scene, center, radius, frosted: true); + + // The squash deformation is a plain scale about the center. + // The refracted content deforms together with the glass, like a liquid surface would + canvas.Save(); + canvas.Translate(center.X, center.Y); + canvas.Scale(radiusX / radius, radiusY / radius); + canvas.Translate(-center.X, -center.Y); + + // Soft drop shadow around the disc + canvas.DrawCircle(center, radius + 1.5f, _dropShadowPaint); + + // Refracted interior + canvas.Save(); + using (var clipPath = new SKPath()) + { + clipPath.AddCircle(center.X, center.Y, radius); + canvas.ClipPath(clipPath, SKClipOperation.Intersect, true); + } + + if (LensEffect is not null) + { + var uniforms = new SKRuntimeEffectUniforms(LensEffect) + { + ["uCenter"] = new[] { center.X, center.Y }, + ["uRadius"] = radius, + ["uZoom"] = zoom, + ["uStretchStart"] = STRETCH_START, + ["uWrapStart"] = WRAP_START, + ["uStretchGain"] = STRETCH_GAIN, + ["uFrostStart"] = FROST_START, + ["uOvershoot"] = EDGE_OVERSHOOT, + ["uEdgeDispersion"] = EDGE_DISPERSION, + ["uInnerBend"] = INNER_BEND, + ["uInnerDispersion"] = INNER_DISPERSION + }; + var children = new SKRuntimeEffectChildren(LensEffect) + { + ["uContent"] = lensSource, + ["uFrosted"] = frostedSource + }; + + using var refractionShader = LensEffect.ToShader(uniforms, children); + using var refractionPaint = new SKPaint(); + refractionPaint.Shader = refractionShader; + + canvas.DrawRect(new SKRect(center.X - radius, center.Y - radius, center.X + radius, center.Y + radius), refractionPaint); + } + else + { + // Runtime effects unavailable (fall back to a plain magnification) + using var fallbackPaint = new SKPaint(); + fallbackPaint.Shader = lensSource; + + canvas.Save(); + canvas.Translate(center.X, center.Y); + canvas.Scale(zoom, zoom); + canvas.Translate(-center.X, -center.Y); + canvas.DrawRect(new SKRect(center.X - radius, center.Y - radius, center.X + radius, center.Y + radius), fallbackPaint); + canvas.Restore(); + } + + // Thin dark contour just inside the rim + canvas.DrawCircle(center, radius - 1.5f, _innerEdgePaint); + canvas.Restore(); // end interior clip + + // Specular edge, split spectrally. The ring is drawn once per color channel at + // slightly offset radii with additive blending. Where the channels overlap the + // highlight is white; at the band's borders they separate into the RGB fringes + // seen along Liquid Glass edges on iOS + ReadOnlySpan channels = [new(255, 0, 0), new(0, 255, 0), new SKColor(0, 0, 255)]; + for (var i = 0; i < channels.Length; i++) + { + _rimSpecularPaint.Shader = CreateRimSweep(center, channels[i]); + canvas.DrawCircle(center, radius - 0.5f + (1 - i) * RIM_CHROMATIC_OFFSET, _rimSpecularPaint); + } + + canvas.Restore(); // end squash transform + } + + /// + /// Builds the angular specular profile of the rim for one color channel with brightest + /// toward the light (top-left), and a weaker counter-glint at the bottom-right. + /// + private static SKShader CreateRimSweep(SKPoint center, SKColor channel) + { + return SKShader.CreateSweepGradient( + center, + [ + channel.WithAlpha(0x3C), // 0deg (+x) + channel.WithAlpha(0x96), // 45deg - counter glint + channel.WithAlpha(0x2E), + channel.WithAlpha(0x28), // 180deg (-x) + channel.WithAlpha(0xE6), // 225deg - main highlight (top-left) + channel.WithAlpha(0x2E), + channel.WithAlpha(0x3C) // 360deg, matches the start + ], + [0f, 0.125f, 0.30f, 0.50f, 0.625f, 0.78f, 1f]); + } + + /// + /// Extracts the region of the scene the lens samples from, optionally frosted. Keeping + /// the blur axis-aligned to the scene (no offsets, no per-copy shifts) is what keeps the + /// refracted content perfectly registered with the unrefracted surroundings. + /// + private SKShader CreateLensSource(SKImage scene, SKPoint center, float radius, bool frosted) + { + var padding = radius * SOURCE_PADDING; + var size = (int)MathF.Ceiling(padding * 2f); + if (_sourceSurface is null || _frostSurface is null || size != _sourceSize) + { + _sourceSurface?.Dispose(); + _frostSurface?.Dispose(); + _sourceSurface = SKSurface.Create(new SKImageInfo(size, size)); + _frostSurface = SKSurface.Create(new SKImageInfo(size, size)); + _sourceSize = size; + } + + var surface = (frosted ? _frostSurface : _sourceSurface)!; + var source = surface.Canvas; + source.Clear(SKColors.Transparent); + source.DrawImage(scene, -(center.X - padding), -(center.Y - padding), frosted ? _sourceBlurPaint : null); + + using var snapshot = surface.Snapshot(); + return snapshot.ToShader( + SKShaderTileMode.Clamp, + SKShaderTileMode.Clamp, + frosted ? new SKSamplingOptions(SKFilterMode.Linear) : new SKSamplingOptions(SKCubicResampler.Mitchell), + SKMatrix.CreateTranslation(center.X - padding, center.Y - padding)); + } + + /// + public void Dispose() + { + _sourceSurface?.Dispose(); + _sourceSurface = null; + _frostSurface?.Dispose(); + _frostSurface = null; + _sourceBlurPaint.Dispose(); + _dropShadowPaint.Dispose(); + _innerEdgePaint.Dispose(); + _rimSpecularPaint.Dispose(); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs index bd3a39c44..6c5c50bd2 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs @@ -19,7 +19,7 @@ namespace SecureFolderFS.Uno.UserControls.Introduction /// public sealed partial class IntroductionBackground : UserControl, IDisposable { - private const double REVEAL_DURATION_MS = 750d; + private const double REVEAL_DURATION_MS = 1200d; private const double SHADOW_FADE_MS = 350d; #if HAS_UNO_SKIA @@ -28,10 +28,17 @@ public sealed partial class IntroductionBackground : UserControl, IDisposable private const int FRAME_INTERVAL_MS = 16; private readonly CompositionCanvas _canvas; #else - // SKXamlCanvas rasterizes on the CPU and uploads the bitmap each frame - keep - // the cadence lower and let the renderer use cheaper sampling + // The reveal ripple needs true per-pixel transparency over the XAML behind this + // control, which the ANGLE swap chain cannot compose (it presents opaquely). The + // reveal therefore runs on a CPU-rasterized SKXamlCanvas at a reduced cadence with + // cheaper sampling; once the gradient covers every pixel, rendering hands off to a + // GPU-backed SKSwapChainPanel at full frame rate and quality private const int FRAME_INTERVAL_MS = 33; - private readonly SKXamlCanvas _canvas; + private const int GPU_FRAME_INTERVAL_MS = 16; + private readonly Grid _canvasHost; + private readonly SKXamlCanvas _revealCanvas; + private SKSwapChainPanel? _gpuCanvas; + private bool _revealCanvasRemoved; #endif private readonly IntroductionBackgroundRenderer _renderer = new(); @@ -52,14 +59,20 @@ public IntroductionBackground() #if HAS_UNO_SKIA _canvas = new CompositionCanvas(this); -#else - _renderer.HighQualitySampling = false; - _canvas = new SKXamlCanvas(); - _canvas.PaintSurface += Canvas_PaintSurface; -#endif _canvas.HorizontalAlignment = HorizontalAlignment.Stretch; _canvas.VerticalAlignment = VerticalAlignment.Stretch; Content = _canvas; +#else + _renderer.HighQualitySampling = false; + _revealCanvas = new SKXamlCanvas(); + _revealCanvas.PaintSurface += Canvas_PaintSurface; + _revealCanvas.HorizontalAlignment = HorizontalAlignment.Stretch; + _revealCanvas.VerticalAlignment = VerticalAlignment.Stretch; + + _canvasHost = new Grid(); + _canvasHost.Children.Add(_revealCanvas); + Content = _canvasHost; +#endif _frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(FRAME_INTERVAL_MS) }; _frameTimer.Tick += FrameTimer_Tick; @@ -70,7 +83,7 @@ public IntroductionBackground() } /// - /// Sweeps the background into view with a bottom-up gradient wipe. + /// Reveals the background with a soft-edged ripple expanding from the center. /// /// A that completes when the background is fully revealed. public Task RevealAsync() @@ -102,7 +115,7 @@ private void FrameTimer_Tick(object? sender, object e) { // The first composited frame can land well after RevealAsync was called // (the freshly added overlay still needs to load and lay out); hold the - // clock at zero until then so the wipe visibly starts at the bottom + // clock at zero until then so the ripple visibly starts from nothing _revealStart = DateTime.UtcNow; } else @@ -114,9 +127,17 @@ private void FrameTimer_Tick(object? sender, object e) _revealStart = null; _shadowStart = DateTime.UtcNow; _revealTcs?.TrySetResult(); +#if !HAS_UNO_SKIA + AttachGpuCanvas(); +#endif } else - _revealProgress = 1f - MathF.Pow(1f - progress, 3f); // ease-out cubic + { + // Cubic ease-in-out: the ripple builds up gently, sweeps, then settles + _revealProgress = progress < 0.5f + ? 4f * progress * progress * progress + : 1f - MathF.Pow(-2f * progress + 2f, 3f) / 2f; + } } } @@ -127,7 +148,13 @@ private void FrameTimer_Tick(object? sender, object e) _shadowOpacity = progress >= 1f ? 1f : 1f - MathF.Pow(1f - progress, 3f); } +#if HAS_UNO_SKIA _canvas.Invalidate(); +#else + _gpuCanvas?.Invalidate(); + if (!_revealCanvasRemoved) + _revealCanvas.Invalidate(); +#endif } private void PrepareFrame(float shadowScale) @@ -158,20 +185,63 @@ protected override void RenderOverride(SKCanvas canvas, Size area) } } #else - private void Canvas_PaintSurface(object? sender, SKPaintSurfaceEventArgs e) + private void RenderFrame(SKCanvas canvas, SKImageInfo info) { - // SKXamlCanvas works in physical pixels while element bounds are logical - var scale = ActualWidth > 0 ? (float)(e.Info.Width / ActualWidth) : 1f; + // Both canvases work in physical pixels while element bounds are logical + var scale = ActualWidth > 0 ? (float)(info.Width / ActualWidth) : 1f; PrepareFrame(scale); - e.Surface.Canvas.Clear(SKColors.Transparent); + canvas.Clear(SKColors.Transparent); _renderer.Render( - e.Surface.Canvas, - e.Info.Width, - e.Info.Height, + canvas, + info.Width, + info.Height, (float)_clock.Elapsed.TotalSeconds, _revealProgress); } + + private void Canvas_PaintSurface(object? sender, SKPaintSurfaceEventArgs e) + { + RenderFrame(e.Surface.Canvas, e.Info); + } + + private void GpuCanvas_PaintSurface(object? sender, SKPaintGLSurfaceEventArgs e) + { + RenderFrame(e.Surface.Canvas, e.Info); + + // The CPU canvas stays stacked on top until the swap chain has painted, so the + // handoff never flashes an unpainted (opaque black) panel + if (!_revealCanvasRemoved) + DispatcherQueue.TryEnqueue(RemoveRevealCanvas); + } + + private void AttachGpuCanvas() + { + if (_gpuCanvas is not null) + return; + + _gpuCanvas = new SKSwapChainPanel(); + _gpuCanvas.PaintSurface += GpuCanvas_PaintSurface; + _gpuCanvas.HorizontalAlignment = HorizontalAlignment.Stretch; + _gpuCanvas.VerticalAlignment = VerticalAlignment.Stretch; + + // Inserted behind the CPU canvas: both hosts render identical frames during + // the handoff, so the swap is not visible + _canvasHost.Children.Insert(0, _gpuCanvas); + + _renderer.HighQualitySampling = true; + _frameTimer.Interval = TimeSpan.FromMilliseconds(GPU_FRAME_INTERVAL_MS); + } + + private void RemoveRevealCanvas() + { + if (_revealCanvasRemoved) + return; + + _revealCanvasRemoved = true; + _revealCanvas.PaintSurface -= Canvas_PaintSurface; + _canvasHost.Children.Remove(_revealCanvas); + } #endif private void IntroductionBackground_Loaded(object sender, RoutedEventArgs e) @@ -200,6 +270,13 @@ private void IntroductionBackground_ActualThemeChanged(FrameworkElement sender, _frameTimer.Stop(); _frameTimer.Tick -= FrameTimer_Tick; +#if !HAS_UNO_SKIA + if (_gpuCanvas is not null) + _gpuCanvas.PaintSurface -= GpuCanvas_PaintSurface; + if (!_revealCanvasRemoved) + _revealCanvas.PaintSurface -= Canvas_PaintSurface; +#endif + Loaded -= IntroductionBackground_Loaded; Unloaded -= IntroductionBackground_Unloaded; ActualThemeChanged -= IntroductionBackground_ActualThemeChanged; diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs index 322c66bf5..50b9e3e4b 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs @@ -13,8 +13,10 @@ internal sealed class IntroductionBackgroundRenderer : IDisposable private const int FIELD_MAX_SIZE = 160; // the color field is rendered at most this large, then upscaled private const int NOISE_TILE_SIZE = 256; private const float GRAIN_SCALE = 1.6f; // enlarges the noise texels so the grain reads clearly - private const float REVEAL_BAND = 0.22f; // relative height of the soft edge of the reveal wipe - private const float REVEAL_LIFT = 0.30f; // relative distance the colors travel upward while revealing + private const float REVEAL_BAND = 0.22f; // relative width of the ripple's soft edge + private const float REVEAL_LIFT = 0.30f; // how zoomed the field starts, expanding out with the ripple + private const float REVEAL_CENTER_Y = 2.4f; // ripple origin below the visible area, relative to the height; + // smaller values curve the front more strongly /// /// Domain-warped fractal noise: fbm distorted by two nested fbm passes folds the color @@ -61,7 +63,12 @@ float fbm(float2 p) { half4 main(float2 frag) { float2 uv = frag / uSize; - float2 p = float2(uv.x * uSize.x / uSize.y, uv.y + uLift) * 1.35; + + // While revealing, the field starts zoomed about the bottom center and + // settles to 1:1, so the colors push upward with the expanding ripple + float2 anchor = float2(0.5, 1.1); + float2 uvr = (uv - anchor) / (1.0 + uLift) + anchor; + float2 p = float2(uvr.x * uSize.x / uSize.y, uvr.y) * 1.35; float t = uTime * 0.15; float2 q = float2( @@ -173,7 +180,7 @@ public void SetTheme(bool light) /// /// Draws one frame onto . Nothing is drawn while /// is 0; a partially revealed frame keeps everything - /// above the wipe front fully transparent. + /// beyond the ripple front fully transparent. /// public void Render(SKCanvas canvas, float width, float height, float time, float reveal) { @@ -190,17 +197,21 @@ public void Render(SKCanvas canvas, float width, float height, float time, float if (needsMask) { - // Erase everything above the reveal front, with a soft gradient edge + // Ripple reveal: a circle centered below the visible area expands upward, its + // gently curved, soft-edged front sweeping from the bottom to the top corners + var center = new SKPoint(width / 2f, height * REVEAL_CENTER_Y); + var startRadius = center.Y - height; // front tangent to the bottom edge + var endRadius = MathF.Sqrt(center.X * center.X + center.Y * center.Y); // front past the top corners var band = height * REVEAL_BAND; - var frontTop = height - reveal * (height + band); + var front = Math.Max(1f, startRadius + reveal * (endRadius + band - startRadius)); using var maskPaint = new SKPaint(); maskPaint.BlendMode = SKBlendMode.DstIn; - maskPaint.Shader = SKShader.CreateLinearGradient( - new SKPoint(0f, frontTop), - new SKPoint(0f, frontTop + band), - [SKColors.Transparent, SKColors.White], - null, + maskPaint.Shader = SKShader.CreateRadialGradient( + center, + front, + [SKColors.White, SKColors.White, SKColors.Transparent], + [0f, Math.Max(0f, (front - band) / front), 1f], SKShaderTileMode.Clamp); canvas.DrawRect(new SKRect(0, 0, width, height), maskPaint); diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/RegisterControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/RegisterControl.xaml index 9680209b1..acb7d20b0 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/RegisterControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/RegisterControl.xaml @@ -94,11 +94,23 @@ + + - + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/TitleBarControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/TitleBarControl.xaml.cs index 3db0c5ab3..271347685 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/TitleBarControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/TitleBarControl.xaml.cs @@ -1,5 +1,10 @@ +using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +#if __UNO_SKIA_X11__ +using Microsoft.UI.Xaml.Input; +using SecureFolderFS.Uno.Platforms.Desktop.Helpers; +#endif // To learn more about WinUI, the WinUI project structure, // and more about our project templates, see: http://aka.ms/winui-project-info. @@ -8,11 +13,108 @@ namespace SecureFolderFS.Uno.UserControls { public sealed partial class TitleBarControl : UserControl { + private Window? _window; + public TitleBarControl() { InitializeComponent(); } + /// + /// Shows client-drawn caption buttons and binds them to the specified . + /// Used on platforms where the OS does not paint window buttons when the title bar is customized. + /// + /// The window that the caption buttons control. + public void ShowWindowButtons(Window window) + { + if (_window is not null) + _window.AppWindow.Changed -= AppWindow_Changed; + + _window = window; + _window.AppWindow.Changed += AppWindow_Changed; + IsWindowButtonsVisible = true; + UpdateMaximizeRestoreGlyph(); + +#if __UNO_SKIA_X11__ + // Uno's X11 backend does not implement SetTitleBar dragging, so initiate the move manually + DragRegion.PointerPressed -= DragRegion_PointerPressed; + DragRegion.PointerPressed += DragRegion_PointerPressed; +#endif + } + +#if __UNO_SKIA_X11__ + private void DragRegion_PointerPressed(object sender, PointerRoutedEventArgs e) + { + if (_window is null) + return; + + var point = e.GetCurrentPoint(null); + if (!point.Properties.IsLeftButtonPressed) + return; + + // Leave the outer edge strip to the client-drawn resize borders + if (X11WindowHelper.IsInResizeBorder(_window, point.Position)) + return; + + if (X11WindowHelper.TryBeginWindowDrag(_window)) + e.Handled = true; + } +#endif + + private void UpdateMaximizeRestoreGlyph() + { + if (MaximizeRestoreIcon is null) + return; + + var isMaximized = _window?.AppWindow.Presenter is OverlappedPresenter { State: OverlappedPresenterState.Maximized }; + MaximizeRestoreIcon.Glyph = isMaximized ? "\uE923" : "\uE922"; + } + + private void AppWindow_Changed(AppWindow sender, AppWindowChangedEventArgs args) + { + UpdateMaximizeRestoreGlyph(); + } + + private void WindowButtons_Loaded(object sender, RoutedEventArgs e) + { + UpdateMaximizeRestoreGlyph(); + } + + private void MinimizeButton_Click(object sender, RoutedEventArgs e) + { + if (_window?.AppWindow.Presenter is OverlappedPresenter presenter) + presenter.Minimize(); + } + + private void MaximizeButton_Click(object sender, RoutedEventArgs e) + { + if (_window is not { } window || window.AppWindow.Presenter is not OverlappedPresenter presenter) + return; + + if (presenter.State == OverlappedPresenterState.Maximized) + { + var restored = false; +#if __UNO_SKIA_X11__ + // Uno's X11 OverlappedPresenter.Restore only un-minimizes and never leaves + // the maximized state, so clear the window manager's maximized state directly + restored = X11WindowHelper.TryUnmaximizeWindow(window); +#endif + if (!restored) + presenter.Restore(); + } + else + { + presenter.Maximize(); + } + + UpdateMaximizeRestoreGlyph(); + } + + private void CloseButton_Click(object sender, RoutedEventArgs e) + { + _window?.Close(); + } + public string? PrimaryTitle { get => (string?)GetValue(PrimaryTitleProperty); @@ -28,5 +130,13 @@ public string? SecondaryTitle } public static readonly DependencyProperty SecondaryTitleProperty = DependencyProperty.Register(nameof(SecondaryTitle), typeof(string), typeof(TitleBarControl), new PropertyMetadata(null)); + + public bool IsWindowButtonsVisible + { + get => (bool)GetValue(IsWindowButtonsVisibleProperty); + set => SetValue(IsWindowButtonsVisibleProperty, value); + } + public static readonly DependencyProperty IsWindowButtonsVisibleProperty = + DependencyProperty.Register(nameof(IsWindowButtonsVisible), typeof(bool), typeof(TitleBarControl), new PropertyMetadata(false)); } } diff --git a/src/Platforms/SecureFolderFS.Uno/ViewModels/WindowsHello/WindowsHelloViewModel.cs b/src/Platforms/SecureFolderFS.Uno/ViewModels/WindowsHello/WindowsHelloViewModel.cs index 7edd76654..5de9cfeb5 100644 --- a/src/Platforms/SecureFolderFS.Uno/ViewModels/WindowsHello/WindowsHelloViewModel.cs +++ b/src/Platforms/SecureFolderFS.Uno/ViewModels/WindowsHello/WindowsHelloViewModel.cs @@ -54,8 +54,9 @@ public override async Task RevokeAsync(string? id, CancellationToken cancellatio if (VaultFolder is not IModifiableFolder modifiableFolder) return; - var authenticationFile = await modifiableFolder.GetFileByNameAsync($"{Id}{Constants.Vault.Names.CONFIGURATION_EXTENSION}", cancellationToken); - await modifiableFolder.DeleteAsync(authenticationFile, cancellationToken); + var authenticationFile = await modifiableFolder.TryGetFileByNameAsync($"{Id}{Constants.Vault.Names.CONFIGURATION_EXTENSION}", cancellationToken); + if (authenticationFile is not null) + await modifiableFolder.DeleteAsync(authenticationFile, cancellationToken); } /// diff --git a/src/Platforms/SecureFolderFS.Uno/ViewModels/YubiKey/YubiKeyViewModel.cs b/src/Platforms/SecureFolderFS.Uno/ViewModels/YubiKey/YubiKeyViewModel.cs index b55ca7fb5..3c3b2cdb4 100644 --- a/src/Platforms/SecureFolderFS.Uno/ViewModels/YubiKey/YubiKeyViewModel.cs +++ b/src/Platforms/SecureFolderFS.Uno/ViewModels/YubiKey/YubiKeyViewModel.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using OwlCore.Storage; using SecureFolderFS.Core; using SecureFolderFS.Sdk.Enums; @@ -26,9 +27,11 @@ namespace SecureFolderFS.Uno.ViewModels.YubiKey public abstract partial class YubiKeyViewModel : AuthenticationViewModel { private readonly SynchronizationContext? _synchronizationContext; + private TaskCompletionSource? _slotOverwriteDecisionTcs; [ObservableProperty] private bool _IsAwaitingTouch; [ObservableProperty] private bool _UseLongPress = true; + [ObservableProperty] private bool _IsSlotOverwriteWarningOpen; /// /// Gets the unique ID of the vault. @@ -75,13 +78,27 @@ public override async Task> EnrollAsync(string id, byte[]? da try { - // During enrollment, configure the slot for HMAC-SHA1 challenge-response first - var key = await PerformChallengeResponseAsync( - data, - configureSlot: true, - UseLongPress, - () => _synchronizationContext.PostOrExecute(_ => IsAwaitingTouch = true), - cancellationToken); + Action touchNotifier = () => _synchronizationContext.PostOrExecute(_ => IsAwaitingTouch = true); + + // The slot is global YubiKey state shared by every vault and application, so reuse + // an existing secret whenever possible; regenerating it would irreversibly break + // everything else that relies on the slot + var slotConfigured = await Task.Run(() => IsSlotConfigured(UseLongPress), cancellationToken); + if (slotConfigured) + { + var existingKey = await PerformChallengeResponseAsync(data, configureSlot: false, UseLongPress, touchNotifier, cancellationToken); + if (existingKey is not null) + return Result.Success(existingKey); + + // The slot holds a configuration other than HMAC-SHA1 challenge-response; + // overwriting it is destructive, so the user must explicitly confirm + _synchronizationContext.PostOrExecute(_ => IsAwaitingTouch = false); + if (!await RequestSlotOverwriteConfirmationAsync()) + throw new OperationCanceledException("The user declined to overwrite the configured YubiKey slot."); + } + + var key = await PerformChallengeResponseAsync(data, configureSlot: true, UseLongPress, touchNotifier, cancellationToken) + ?? throw new InvalidOperationException("Challenge-response failed after configuring the slot for HMAC-SHA1."); return Result.Success(key); } @@ -105,7 +122,8 @@ public override async Task> AcquireAsync(string id, byte[]? d configureSlot: false, UseLongPress, () => _synchronizationContext.PostOrExecute(_ => IsAwaitingTouch = true), - cancellationToken); + cancellationToken) + ?? throw new InvalidOperationException("Challenge-response failed. Ensure the YubiKey slot is configured for HMAC-SHA1."); return Result.Success(key); } @@ -138,15 +156,15 @@ protected static IYubiKeyDevice GetYubiKeyDevice() /// If true, uses Slot 2 (LongPress); otherwise uses Slot 1 (ShortPress). /// Callback invoked when touch is required. /// The cancellation token. - /// The response key bytes. - protected static Task PerformChallengeResponseAsync( - byte[] challenge, + /// The response key bytes, or null when the slot is not configured for HMAC-SHA1 challenge-response. + protected static Task PerformChallengeResponseAsync( + byte[] challenge, bool configureSlot, bool useLongPress, Action? touchNotifier, CancellationToken cancellationToken) { - return Task.Run(() => + return Task.Run(() => { cancellationToken.ThrowIfCancellationRequested(); @@ -166,9 +184,8 @@ protected static Task PerformChallengeResponseAsync( // Perform challenge-response var response = ChallengeResponse(otpSession, slot, challengeBytes, touchNotifier); - if (response is null) - throw new InvalidOperationException("Challenge-response failed. Ensure the YubiKey slot is configured for HMAC-SHA1."); + return null; var responseBytes = response.Value.ToArray(); var secretKey = new ManagedKey(responseBytes.Length); @@ -178,6 +195,45 @@ protected static Task PerformChallengeResponseAsync( }, cancellationToken); } + /// + /// Checks whether the given slot already holds a configuration of any kind. + /// + /// If true, checks Slot 2 (LongPress); otherwise Slot 1 (ShortPress). + /// True if the slot is configured, false otherwise. + private static bool IsSlotConfigured(bool useLongPress) + { + var device = GetYubiKeyDevice(); + using var otpSession = new OtpSession(device); + + return useLongPress ? otpSession.IsLongPressConfigured : otpSession.IsShortPressConfigured; + } + + /// + /// Opens the slot overwrite warning and waits for the user's decision. + /// + /// True if the user confirmed overwriting the slot, false otherwise. + private Task RequestSlotOverwriteConfirmationAsync() + { + _slotOverwriteDecisionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _synchronizationContext.PostOrExecute(_ => IsSlotOverwriteWarningOpen = true); + + return _slotOverwriteDecisionTcs.Task; + } + + partial void OnIsSlotOverwriteWarningOpenChanged(bool value) + { + // Dismissing the warning without choosing the action counts as declining + if (!value) + _slotOverwriteDecisionTcs?.TrySetResult(false); + } + + [RelayCommand] + private void ConfirmSlotOverwrite() + { + _slotOverwriteDecisionTcs?.TrySetResult(true); + IsSlotOverwriteWarningOpen = false; + } + /// /// Configures a YubiKey slot for HMAC-SHA1 challenge-response with touch required. /// @@ -215,10 +271,10 @@ private static void ConfigureSlotForChallengeResponse(IOtpSession session, Slot /// The slot to use. /// The challenge bytes (must be exactly 64 bytes). /// Callback invoked when touch is required. - /// The response bytes, or null if the operation failed. + /// The response bytes, or null when the slot is not configured for HMAC-SHA1 challenge-response. private static ReadOnlyMemory? ChallengeResponse( - IOtpSession session, - Slot slot, + IOtpSession session, + Slot slot, byte[] challenge, Action? touchNotifier) { @@ -245,25 +301,11 @@ private static void ConfigureSlotForChallengeResponse(IOtpSession session, Slot ex.Message.Contains("keyboard", StringComparison.OrdinalIgnoreCase) || ex.Message.Contains("acknowledge", StringComparison.OrdinalIgnoreCase)) { - // This error occurs when: - // 1. The slot is not configured for HMAC-SHA1 challenge-response - // 2. The slot is configured for a different mode (static password, Yubico OTP, HOTP) - // 3. There's a USB communication issue - Debug.WriteLine($"Slot {slot} failed - likely not configured for HMAC-SHA1 challenge-response: {ex.Message}"); - return null; - } - catch (TimeoutException ex) - { - // Touch timeout - user didn't touch the key in time - Debug.WriteLine($"Challenge-response timed out on {slot}: {ex.Message}"); - return null; - } - catch (Exception ex) - { - // Log the full exception type and inner exception for debugging - Debug.WriteLine($"Challenge-response failed on {slot} ({ex.GetType().FullName}): {ex.Message}"); - if (ex.InnerException != null) - Debug.WriteLine($" Inner exception ({ex.InnerException.GetType().FullName}): {ex.InnerException.Message}"); + // Thrown when the slot is configured for a mode other than HMAC-SHA1 challenge-response + // (static password, Yubico OTP, HOTP). Only this case may return null - callers use it + // to decide whether reprogramming the slot should be offered, so timeouts and transport + // errors must propagate instead of being conflated with a misconfigured slot. + Debug.WriteLine($"Slot {slot} is not configured for HMAC-SHA1 challenge-response: {ex.Message}"); return null; } } diff --git a/src/Sdk/SecureFolderFS.Sdk/Enums/IapProductType.cs b/src/Sdk/SecureFolderFS.Sdk/Enums/IapProductType.cs index bdbd4078c..50d39db3f 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Enums/IapProductType.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Enums/IapProductType.cs @@ -1,19 +1,22 @@ -namespace SecureFolderFS.Sdk.Enums +using System; + +namespace SecureFolderFS.Sdk.Enums { /// /// Represents types of in-app purchase (IAP) products available within the application. /// + [Flags] public enum IapProductType { /// /// Represents the one-time purchase option for acquiring the lifetime license. /// - PlusLifetime = 0, + PlusLifetime = 1, /// /// Represents the subscription-based option for accessing premium features. /// - PlusSubscription = 1, + PlusSubscription = 2, /// /// Represents any of the available IAP products. diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/RecycleBinItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/RecycleBinItemViewModel.cs index 8ac9b5961..fb69b0c1f 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/RecycleBinItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/RecycleBinItemViewModel.cs @@ -18,6 +18,7 @@ using SecureFolderFS.Shared.Enums; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Helpers; +using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.Pickers; using SecureFolderFS.Storage.VirtualFileSystem; @@ -53,13 +54,13 @@ public RecycleBinItemViewModel(RecycleBinOverlayViewModel overlayViewModel, IRec /// public async Task InitAsync(CancellationToken cancellationToken = default) { - if (Inner is not IFile file) - return; - var size = await _recycleBinItem.SizeOf.GetValueAsync(cancellationToken); Size = size.HasValue ? ByteSize.FromBytes(size.Value).ToString().Replace(" ", string.Empty) : string.Empty; DeletionTimestamp = await _recycleBinItem.CreatedAt.GetValueAsync(cancellationToken); + if (Inner is not IFile file) + return; + var extension = Path.GetExtension(Title); if (extension is null) return; @@ -97,9 +98,18 @@ private async Task RestoreAsync(CancellationToken cancellationToken) { foreach (var item in items) OverlayViewModel.Items.Remove(item); + + OverlayViewModel.ToggleSelectionCommand.Execute(false); } + else + { + OverlayViewModel.Report(new MessageResult(false, "ItemsFailedToRestorePlural".ToLocalized(items.Length))); - OverlayViewModel.ToggleSelectionCommand.Execute(false); + // Some items may have been restored before the failure. + // Refresh the listing so the view matches the on-disk state + OverlayViewModel.ToggleSelectionCommand.Execute(false); + await OverlayViewModel.InitAsync(cancellationToken); + } } [RelayCommand] @@ -117,6 +127,8 @@ private async Task DeletePermanentlyAsync(CancellationToken cancellationToken) items = [this]; } + var failedCount = 0; + Exception? lastException = null; foreach (var item in items) { if (item.AsWrapper().GetWrapperAt(1).Inner is not IStorableChild innerChild) @@ -129,10 +141,15 @@ private async Task DeletePermanentlyAsync(CancellationToken cancellationToken) } catch (Exception ex) { - _ = ex; + // A failed item must not abandon the remaining ones + failedCount++; + lastException = ex; } } + if (failedCount > 0) + OverlayViewModel.Report(Result.Failure(lastException)); + OverlayViewModel.ToggleSelectionCommand.Execute(false); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs index 65e46c5b2..cdc9ebcf9 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs @@ -56,6 +56,14 @@ public async Task InitAsync(CancellationToken cancellationToken = default) await UpdateIconAsync(cancellationToken); } + public void UpdateCanMove() + { + var itemIndex = _vaultCollectionModel.IndexOf(VaultViewModel.VaultModel); + CanMoveDown = itemIndex < _vaultCollectionModel.Count - 1; + CanMoveUp = itemIndex > 0; + CanMove = CanMoveUp && CanMoveDown; + } + [RelayCommand] private void RequestLock() { @@ -196,13 +204,5 @@ private async Task UpdateIconAsync(CancellationToken cancellationToken) CustomIcon = await MediaService.ReadImageFileAsync(imageFile, cancellationToken); } - - private void UpdateCanMove() - { - var itemIndex = _vaultCollectionModel.IndexOf(VaultViewModel.VaultModel); - CanMoveDown = itemIndex < _vaultCollectionModel.Count - 1; - CanMoveUp = itemIndex > 0; - CanMove = CanMoveUp && CanMoveDown; - } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs index c38523312..c5009992b 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs @@ -139,6 +139,9 @@ private void AddVault(VaultViewModel vaultViewModel) Items.Add(itemViewModel); HasVaults = true; + + foreach (var item in Items) + item.UpdateCanMove(); } private void RemoveVault(IVaultModel vaultModel) diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PaymentOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PaymentOverlayViewModel.cs index b494d9d77..5ee700628 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PaymentOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PaymentOverlayViewModel.cs @@ -12,7 +12,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays { - [Inject] + [Inject, Inject] [Bindable(true)] public sealed partial class PaymentOverlayViewModel : OverlayViewModel, IAsyncInitialize { @@ -40,15 +40,25 @@ public async Task InitAsync(CancellationToken cancellationToken = default) } [RelayCommand] - private async Task PurchaseLifetimeAsync(CancellationToken cancellationToken) + private Task PurchaseLifetimeAsync(CancellationToken cancellationToken) { - await IapService.PurchaseAsync(IapProductType.PlusSubscription, cancellationToken); + return PurchaseAsync(IapProductType.PlusLifetime, cancellationToken); } [RelayCommand] - private async Task PurchaseSubscriptionAsync(CancellationToken cancellationToken) + private Task PurchaseSubscriptionAsync(CancellationToken cancellationToken) { - await IapService.PurchaseAsync(IapProductType.PlusSubscription, cancellationToken); + return PurchaseAsync(IapProductType.PlusSubscription, cancellationToken); + } + + private async Task PurchaseAsync(IapProductType productType, CancellationToken cancellationToken) + { + // The Store surfaces its own native UI for progress, cancellation, and errors, + // so we only need to react to a confirmed success here. On success, close the + // overlay so the gated action that opened it can re-check ownership and proceed. + var purchased = await IapService.PurchaseAsync(productType, cancellationToken); + if (purchased) + await OverlayService.CloseAllAsync(); } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs index ee6ed12ee..44f52e445 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs @@ -29,7 +29,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays { [Bindable(true)] - [Inject, Inject] + [Inject, Inject, Inject] public sealed partial class RecycleBinOverlayViewModel : BaseDesignationViewModel, IAsyncInitialize, IProgress, IDisposable { private readonly SynchronizationContext? _synchronizationContext; @@ -38,7 +38,8 @@ public sealed partial class RecycleBinOverlayViewModel : BaseDesignationViewMode private IFolderWatcher? _folderWatcher; [ObservableProperty] private bool _IsSelecting; - [ObservableProperty] private bool _IsRecycleBinEnabled; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanChangeSizeOption))] private bool _IsRecycleBinEnabled; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanChangeSizeOption))] private bool _CanConfigure; [ObservableProperty] private string? _SpaceTakenText; [ObservableProperty] private double _PercentageTaken; [ObservableProperty] private PickerOptionViewModel? _CurrentSizeOption; @@ -51,6 +52,12 @@ public sealed partial class RecycleBinOverlayViewModel : BaseDesignationViewMode public bool IsInitialized { get; private set; } + /// + /// Gets whether the size limit picker should be interactable. Changing the limit requires + /// the recycle bin to be enabled and an active subscription (see ). + /// + public bool CanChangeSizeOption => IsRecycleBinEnabled && CanConfigure; + public RecycleBinOverlayViewModel(UnlockedVaultViewModel unlockedVaultViewModel, INavigator outerNavigator) { ServiceProvider = DI.Default; @@ -65,6 +72,11 @@ public RecycleBinOverlayViewModel(UnlockedVaultViewModel unlockedVaultViewModel, /// public async Task InitAsync(CancellationToken cancellationToken = default) { + // Configuration (toggling the recycle bin, changing its size limit) requires an + // active subscription. Viewing, restoring, and deleting existing items does not, + // so that users whose subscription expired can always get their files back + CanConfigure = await IapService.IsOwnedAsync(IapProductType.Any, cancellationToken); + // Get storage root folder var rootFolder = UnlockedVaultViewModel.VaultFolder; if (rootFolder is IChildFolder childFolder) @@ -76,8 +88,19 @@ public async Task InitAsync(CancellationToken cancellationToken = default) SizeOptions.Clear(); SizeOptions.AddMultiple(sizeOptions); - // Choose the saved size option - CurrentSizeOption = SizeOptions.FirstOrDefault(x => long.Parse(x.Id) == UnlockedVaultViewModel.StorageRoot.Options.RecycleBinSize) + // Choose the saved size option. If the configured size does not match any of the + // generated options (e.g., the vault was configured on a device with more free space), + // a custom option is added so that toggling the recycle bin never silently rewrites + // the user's configured quota + var configuredSize = UnlockedVaultViewModel.StorageRoot.Options.RecycleBinSize; + var matchedOption = SizeOptions.FirstOrDefault(x => long.Parse(x.Id) == configuredSize); + if (matchedOption is null && configuredSize > 0L) + { + matchedOption = new(configuredSize.ToString(), ByteSize.FromBytes(configuredSize).ToBinaryString()); + SizeOptions.Add(matchedOption); + } + + CurrentSizeOption = matchedOption ?? SizeOptions.ElementAtOrDefault(1) ?? SizeOptions.FirstOrDefault(); @@ -120,6 +143,23 @@ public async Task InitAsync(CancellationToken cancellationToken = default) IsInitialized = true; } + /// + /// Determines whether the recycle bin exists and contains any items. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is true if the recycle bin contains at least one item. + public async Task HasItemsAsync(CancellationToken cancellationToken = default) + { + _recycleBin ??= await RecycleBinService.TryGetRecycleBinAsync(UnlockedVaultViewModel.StorageRoot, cancellationToken); + if (_recycleBin is null) + return false; + + await foreach (var _ in _recycleBin.GetItemsAsync(StorableType.All, cancellationToken)) + return true; + + return false; + } + /// /// Toggles the recycle bin on or off updating the configuration file. /// @@ -127,6 +167,10 @@ public async Task InitAsync(CancellationToken cancellationToken = default) /// A that cancels this action. public async Task ToggleRecycleBinAsync(bool value, CancellationToken cancellationToken = default) { + // Configuration requires an active subscription + if (!CanConfigure) + return; + if (!long.TryParse(CurrentSizeOption?.Id, out var size)) return; @@ -211,12 +255,19 @@ private async Task RestoreSelectedAsync(IList? selectedItems, Cancellati StatusInfoBar.IsOpen = false; foreach (var item in items) Items.Remove(item); + + ToggleSelectionCommand.Execute(false); + await UpdateSizesAsync(false, cancellationToken); } else + { Report(new MessageResult(false, "ItemsFailedToRestorePlural".ToLocalized(items.Length))); - ToggleSelectionCommand.Execute(false); - await UpdateSizesAsync(false, cancellationToken); + // Some items may have been restored before the failure - refresh the + // listing so the view matches the on-disk state + ToggleSelectionCommand.Execute(false); + await InitAsync(cancellationToken); + } } [RelayCommand] @@ -270,11 +321,10 @@ public void Report(IResult result) private void UpdateSizeBar(PickerOptionViewModel? value) { - if (value is not null && value.Id != "-1") + if (value is not null && value.Id != "-1" && long.Parse(value.Id) is > 0L and var totalSize) { - var totalSize = long.Parse(value.Id); - PercentageTaken = (double)_occupiedSize / totalSize * 100d; - SpaceTakenText = $"Taken {ByteSize.FromBytes(_occupiedSize).ToBinaryString()} out of {ByteSize.FromBytes(totalSize).ToBinaryString()}"; + PercentageTaken = Math.Clamp((double)_occupiedSize / totalSize * 100d, 0d, 100d); + SpaceTakenText = "RecycleBinSpaceTaken".ToLocalized(ByteSize.FromBytes(_occupiedSize).ToBinaryString(), ByteSize.FromBytes(totalSize).ToBinaryString()); } else SpaceTakenText = null; @@ -306,17 +356,11 @@ async Task UpdateCollectionAsync() if (Items.Any(x => x.AsWrapper().GetWrapperAt(1).Inner.Id == newItem.Id)) continue; - await Task.Delay(200); // Delay to ensure the item is fully available - - // Get the recycle bin item wrapper - await foreach (var item in _recycleBin.GetItemsAsync()) - { - if (item is not IRecycleBinItem recycleBinItem || recycleBinItem.AsWrapper().GetWrapperAt(1).Inner.Id != newItem.Id) - continue; - + // The configuration file is written before the payload is moved, so the + // item is materializable as soon as its event arrives. Core files (e.g. + // the occupied-size file, which changes on every delete) resolve to null + if (await _recycleBin.TryGetItemAsync(newItem.Name) is IRecycleBinItem recycleBinItem) Items.Add(new RecycleBinItemViewModel(this, recycleBinItem, _recycleBin).WithInitAsync()); - break; - } } // Update size bar after changes @@ -348,11 +392,8 @@ async Task UpdateCollectionAsync() /// public void Dispose() { - if (_folderWatcher is not null) - { - _folderWatcher.CollectionChanged -= FolderWatcher_CollectionChanged; - _folderWatcher.Dispose(); - } + _folderWatcher?.CollectionChanged -= FolderWatcher_CollectionChanged; + _folderWatcher?.Dispose(); } } } \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs index c7368712c..5e45efdf9 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs @@ -55,7 +55,8 @@ public async Task InitAsync(CancellationToken cancellationToken = default) FileSystemDescriptionText = UnlockedVaultViewModel.StorageRoot.Options.GetDescription(); SecurityText = await VaultCredentialsService.FromUnlockProcedureAsync(UnlockedVaultViewModel.VaultFolder, vaultOptions.UnlockProcedure, cancellationToken); - if (!RecycleBinOverlayViewModel.IsInitialized && await IapService.IsOwnedAsync(IapProductType.Any, cancellationToken)) + if (!RecycleBinOverlayViewModel.IsInitialized + && (await IapService.IsOwnedAsync(IapProductType.Any, cancellationToken) || await RecycleBinOverlayViewModel.HasItemsAsync(cancellationToken))) await RecycleBinOverlayViewModel.InitAsync(cancellationToken); } @@ -106,9 +107,15 @@ private async Task ViewRecycleBinAsync(CancellationToken cancellationToken) await Task.Delay(100, cancellationToken); if (!await IapService.IsOwnedAsync(IapProductType.Any, cancellationToken)) { - await OverlayService.ShowAsync(PaymentOverlayViewModel.Instance.WithInitAsync(cancellationToken)); - if (!await IapService.IsOwnedAsync(IapProductType.Any, cancellationToken)) - return; + // Users whose subscription expired must still be able to access items already + // present in the recycle bin (the dialog opens with configuration locked). + // The paywall is only shown when there is nothing to view + if (!await RecycleBinOverlayViewModel.HasItemsAsync(cancellationToken)) + { + await OverlayService.ShowAsync(PaymentOverlayViewModel.Instance.WithInitAsync(cancellationToken)); + if (!await IapService.IsOwnedAsync(IapProductType.Any, cancellationToken)) + return; + } } if (!RecycleBinOverlayViewModel.IsInitialized) diff --git a/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/IRecycleBinFolder.cs b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/IRecycleBinFolder.cs index afc52ad0f..35cedfede 100644 --- a/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/IRecycleBinFolder.cs +++ b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/IRecycleBinFolder.cs @@ -16,8 +16,8 @@ public interface IRecycleBinFolder : IModifiableFolder, ISizeOf /// Restores a collection of items from the recycle bin. /// /// - /// If the collection contains more than one element, the is always invoked once, otherwise, - /// the recycle bin tries to restore the item into its original location resorting to the when necessary. + /// Every item is restored to its original location when it still exists. The + /// is invoked at most once for the items whose original location could not be used. ///

/// The may only accept the ciphertext implementation or . ///
@@ -26,5 +26,13 @@ public interface IRecycleBinFolder : IModifiableFolder, ISizeOf /// A that cancels this action. /// A that represents the asynchronous operation. Task RestoreItemsAsync(IEnumerable items, IFolderPicker folderPicker, CancellationToken cancellationToken = default); + + /// + /// Gets a single recycle bin item that matches the given ciphertext (on-disk) name, if one exists. + /// + /// The on-disk name of the payload inside the recycle bin. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the matched item, or null. + Task TryGetItemAsync(string ciphertextName, CancellationToken cancellationToken = default); } } diff --git a/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs b/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs index 7dcd8d3e9..c3d2187e2 100644 --- a/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs +++ b/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs @@ -5,6 +5,7 @@ using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; namespace SecureFolderFS.Tests.FileSystemTests @@ -80,6 +81,171 @@ public async Task Create_FolderWith_SubFile_SubFolder_Delete_And_InspectRecycleB Assert.Pass($"{nameof(recycleBinItems)}:\n" + string.Join('\n', recycleBinItems.Select(x => (x as IWrapper)?.Inner.Id))); } + [Test] + public async Task Delete_File_TracksOccupiedSize_And_ClearsOnPermanentDelete() + { + ArgumentNullException.ThrowIfNull(_storageRoot); + ArgumentNullException.ThrowIfNull(_recycleBinService); + + // Arrange + var modifiableFolder = _storageRoot.PlaintextRoot as IModifiableFolder ?? throw new ArgumentException($"Folder is not {nameof(IModifiableFolder)}."); + var data = new byte[4096]; + Random.Shared.NextBytes(data); + + // Act + var file = await modifiableFolder.CreateFileAsync("SIZED_FILE"); + await file.WriteBytesAsync(data); + await modifiableFolder.DeleteAsync(file); + + // Assert - the occupied size reflects the plaintext size even with an unlimited-capacity bin + var recycleBin = await _recycleBinService.GetOrCreateRecycleBinAsync(_storageRoot); + var occupiedSize = await recycleBin.GetSizeAsync(); + occupiedSize.Should().Be(data.Length); + + // Act - permanently delete the recycled item + var recycleBinItems = await recycleBin.GetItemsAsync().ToArrayAsyncImpl(); + var innerItem = (IStorableChild)recycleBinItems.First().AsWrapper().GetWrapperAt(1).Inner; + await recycleBin.DeleteAsync(innerItem); + + // Assert - the occupied size returns to zero and the bin is empty + (await recycleBin.GetSizeAsync()).Should().Be(0L); + (await recycleBin.GetItemsAsync().ToArrayAsyncImpl()).Should().BeEmpty(); + } + + [Test] + public async Task Delete_File_ExceedingQuota_IsDeletedPermanently() + { + ArgumentNullException.ThrowIfNull(_recycleBinService); + ArgumentNullException.ThrowIfNull(_fileExplorerService); + + // Arrange - a bin with a 4KB quota + var vaultFileSystemService = DI.Service(); + var localFileSystem = await vaultFileSystemService.GetLocalFileSystemAsync(CancellationToken.None); + var storageRoot = await MountVault(localFileSystem, null, (nameof(FileSystemOptions.RecycleBinSize), 4096L)); + var modifiableFolder = storageRoot.PlaintextRoot as IModifiableFolder ?? throw new ArgumentException($"Folder is not {nameof(IModifiableFolder)}."); + + // Act - a small file fits the quota, a large one does not + var smallFile = await modifiableFolder.CreateFileAsync("SMALL_FILE"); + await smallFile.WriteBytesAsync(new byte[128]); + await modifiableFolder.DeleteAsync(smallFile); + + var largeFile = await modifiableFolder.CreateFileAsync("LARGE_FILE"); + await largeFile.WriteBytesAsync(new byte[128 * 1024]); + await modifiableFolder.DeleteAsync(largeFile); + + // Assert - only the small file was recycled + var recycleBin = await _recycleBinService.GetOrCreateRecycleBinAsync(storageRoot); + var recycleBinItems = await recycleBin.GetItemsAsync().ToArrayAsyncImpl(); + recycleBinItems.Select(x => x.Name).Should().BeEquivalentTo("SMALL_FILE"); + (await recycleBin.GetSizeAsync()).Should().Be(128L); + } + + [Test] + public async Task Restore_File_WhenNameConflictExists_AppendsSuffix() + { + ArgumentNullException.ThrowIfNull(_storageRoot); + ArgumentNullException.ThrowIfNull(_recycleBinService); + ArgumentNullException.ThrowIfNull(_fileExplorerService); + + // Arrange + var modifiableFolder = _storageRoot.PlaintextRoot as IModifiableFolder ?? throw new ArgumentException($"Folder is not {nameof(IModifiableFolder)}."); + + // Act - delete a file, then recreate one under the same name, then restore + var file = await modifiableFolder.CreateFileAsync("CONFLICT"); + await modifiableFolder.DeleteAsync(file); + _ = await modifiableFolder.CreateFileAsync("CONFLICT"); + + var recycleBin = await _recycleBinService.GetRecycleBinAsync(_storageRoot); + var recycleBinItems = await recycleBin.GetItemsAsync().ToArrayAsyncImpl(); + await recycleBin.RestoreItemsAsync([ recycleBinItems.First(x => x.Name == "CONFLICT") ], _fileExplorerService); + + // Assert - both files exist, the restored one with a suffix + var rootItems = await modifiableFolder.GetItemsAsync().ToArrayAsyncImpl(); + rootItems.Select(x => x.Name).Should().Contain("CONFLICT"); + rootItems.Select(x => x.Name).Should().Contain("CONFLICT (1)"); + } + + [Test] + public async Task RecalculateSizes_Matches_TrackedOccupiedSize() + { + ArgumentNullException.ThrowIfNull(_storageRoot); + ArgumentNullException.ThrowIfNull(_recycleBinService); + + // Arrange - recycle a file and a folder (with contents) + var modifiableFolder = _storageRoot.PlaintextRoot as IModifiableFolder ?? throw new ArgumentException($"Folder is not {nameof(IModifiableFolder)}."); + var file = await modifiableFolder.CreateFileAsync("FILE"); + await file.WriteBytesAsync(new byte[1024]); + + var subFolder = await modifiableFolder.CreateFolderAsync("FOLDER") as IModifiableFolder ?? throw new ArgumentException($"Folder is not {nameof(IModifiableFolder)}."); + var subFile = await subFolder.CreateFileAsync("SUB_FILE"); + await subFile.WriteBytesAsync(new byte[2048]); + + await modifiableFolder.DeleteAsync(file); + await modifiableFolder.DeleteAsync((IStorableChild)subFolder); + + var recycleBin = await _recycleBinService.GetOrCreateRecycleBinAsync(_storageRoot); + var trackedSize = await recycleBin.GetSizeAsync(); + + // Act - a full recalculation must succeed and agree with the tracked value + await _recycleBinService.RecalculateSizesAsync(_storageRoot); + + // Assert + trackedSize.Should().Be(1024L + 2048L); + (await recycleBin.GetSizeAsync()).Should().Be(trackedSize); + } + + [Test] + public async Task Delete_FolderTree_MemberByMember_FoldsIntoSingleEntry_And_Restores() + { + ArgumentNullException.ThrowIfNull(_storageRoot); + ArgumentNullException.ThrowIfNull(_recycleBinService); + ArgumentNullException.ThrowIfNull(_fileExplorerService); + + // Arrange - TREE/LEAF1 and TREE/sub/LEAF2 + var modifiableFolder = _storageRoot.PlaintextRoot as IModifiableFolder ?? throw new ArgumentException($"Folder is not {nameof(IModifiableFolder)}."); + var treeFolder = await modifiableFolder.CreateFolderAsync("TREE") as IModifiableFolder ?? throw new ArgumentException($"Folder is not {nameof(IModifiableFolder)}."); + var leaf1 = await treeFolder.CreateFileAsync("LEAF1"); + await leaf1.WriteBytesAsync(new byte[512]); + + var subFolder = await treeFolder.CreateFolderAsync("sub") as IModifiableFolder ?? throw new ArgumentException($"Folder is not {nameof(IModifiableFolder)}."); + var leaf2 = await subFolder.CreateFileAsync("LEAF2"); + await leaf2.WriteBytesAsync(new byte[256]); + + // Act - simulate an OS client deleting the tree member-by-member, bottom-up + await subFolder.DeleteAsync(leaf2); + await treeFolder.DeleteAsync((IStorableChild)subFolder); + await treeFolder.DeleteAsync(leaf1); + await modifiableFolder.DeleteAsync((IStorableChild)treeFolder); + + // Assert - the fragments were folded back into a single restorable entry + var recycleBin = await _recycleBinService.GetRecycleBinAsync(_storageRoot); + var recycleBinItems = await recycleBin.GetItemsAsync().ToArrayAsyncImpl(); + recycleBinItems.Select(x => x.Name).Should().BeEquivalentTo("TREE"); + + // The entry's size and the occupied total reflect the folded contents + var treeEntry = (IRecycleBinItem)recycleBinItems[0]; + (await treeEntry.SizeOf.GetValueAsync()).Should().Be(512L + 256L); + (await recycleBin.GetSizeAsync()).Should().Be(512L + 256L); + + // Act - restoring the single entry brings the whole tree back + await recycleBin.RestoreItemsAsync([ treeEntry ], _fileExplorerService); + + // Assert - the hierarchy is reconstructed and the bin is empty + var restoredRoot = await modifiableFolder.GetItemsAsync().ToArrayAsyncImpl(); + restoredRoot.Select(x => x.Name).Should().Contain("TREE"); + + var restoredTree = restoredRoot.First(x => x.Name == "TREE") as IFolder ?? throw new ArgumentException("Restored item is not a folder."); + var restoredTreeChildren = await restoredTree.GetItemsAsync().ToArrayAsyncImpl(); + restoredTreeChildren.Select(x => x.Name).Should().BeEquivalentTo("LEAF1", "sub"); + + var restoredSub = restoredTreeChildren.First(x => x.Name == "sub") as IFolder ?? throw new ArgumentException("Restored item is not a folder."); + var restoredSubChildren = await restoredSub.GetItemsAsync().ToArrayAsyncImpl(); + restoredSubChildren.Select(x => x.Name).Should().BeEquivalentTo("LEAF2"); + + (await recycleBin.GetItemsAsync().ToArrayAsyncImpl()).Should().BeEmpty(); + (await recycleBin.GetSizeAsync()).Should().Be(0L); + } + [Test] public async Task Create_FolderWith_SubFile_SubFolder_Delete_And_Restore_NoThrow() {