diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 1ff0c4230..000000000 --- a/.gitattributes +++ /dev/null @@ -1,63 +0,0 @@ -############################################################################### -# Set default behavior to automatically normalize line endings. -############################################################################### -* text=auto - -############################################################################### -# Set default behavior for command prompt diff. -# -# This is need for earlier builds of msysgit that does not have it on by -# default for csharp files. -# Note: This is only used by command line -############################################################################### -#*.cs diff=csharp - -############################################################################### -# Set the merge driver for project and solution files -# -# Merging from the command prompt will add diff markers to the files if there -# are conflicts (Merging from VS is not affected by the settings below, in VS -# the diff markers are never inserted). Diff markers may cause the following -# file extensions to fail to load in VS. An alternative would be to treat -# these files as binary and thus will always conflict and require user -# intervention with every merge. To do so, just uncomment the entries below -############################################################################### -#*.sln merge=binary -#*.csproj merge=binary -#*.vbproj merge=binary -#*.vcxproj merge=binary -#*.vcproj merge=binary -#*.dbproj merge=binary -#*.fsproj merge=binary -#*.lsproj merge=binary -#*.wixproj merge=binary -#*.modelproj merge=binary -#*.sqlproj merge=binary -#*.wwaproj merge=binary - -############################################################################### -# behavior for image files -# -# image files are treated as binary by default. -############################################################################### -#*.jpg binary -#*.png binary -#*.gif binary - -############################################################################### -# diff behavior for common document formats -# -# Convert binary document formats to text before diffing them. This feature -# is only available from the command line. Turn it on by uncommenting the -# entries below. -############################################################################### -#*.doc diff=astextplain -#*.DOC diff=astextplain -#*.docx diff=astextplain -#*.DOCX diff=astextplain -#*.dot diff=astextplain -#*.DOT diff=astextplain -#*.pdf diff=astextplain -#*.PDF diff=astextplain -#*.rtf diff=astextplain -#*.RTF diff=astextplain diff --git a/assets/SecureFolderFS_Dashboard.png b/.github/assets/SecureFolderFS_Dashboard.png similarity index 100% rename from assets/SecureFolderFS_Dashboard.png rename to .github/assets/SecureFolderFS_Dashboard.png diff --git a/assets/SecureFolderFS_Header.png b/.github/assets/SecureFolderFS_Header.png similarity index 100% rename from assets/SecureFolderFS_Header.png rename to .github/assets/SecureFolderFS_Header.png diff --git a/assets/SecureFolderFS_Hero.png b/.github/assets/SecureFolderFS_Hero.png similarity index 100% rename from assets/SecureFolderFS_Hero.png rename to .github/assets/SecureFolderFS_Hero.png diff --git a/assets/SecureFolderFS_LockedVault.png b/.github/assets/SecureFolderFS_LockedVault.png similarity index 100% rename from assets/SecureFolderFS_LockedVault.png rename to .github/assets/SecureFolderFS_LockedVault.png diff --git a/assets/Source_AppStore.png b/.github/assets/Source_AppStore.png similarity index 100% rename from assets/Source_AppStore.png rename to .github/assets/Source_AppStore.png diff --git a/assets/Source_Flathub.png b/.github/assets/Source_Flathub.png similarity index 100% rename from assets/Source_Flathub.png rename to .github/assets/Source_Flathub.png diff --git a/assets/Source_GitHub.png b/.github/assets/Source_GitHub.png similarity index 100% rename from assets/Source_GitHub.png rename to .github/assets/Source_GitHub.png diff --git a/assets/Source_GooglePlay.png b/.github/assets/Source_GooglePlay.png similarity index 100% rename from assets/Source_GooglePlay.png rename to .github/assets/Source_GooglePlay.png diff --git a/assets/Source_Microsoft.png b/.github/assets/Source_Microsoft.png similarity index 100% rename from assets/Source_Microsoft.png rename to .github/assets/Source_Microsoft.png diff --git a/assets/app_title.png b/.github/assets/app_title.png similarity index 100% rename from assets/app_title.png rename to .github/assets/app_title.png diff --git a/README.md b/README.md index c23edcd49..0f8c04a8c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

@@ -32,19 +32,19 @@ This ensures that your data will always remain secure.

- Get it from Microsoft + Get it from Microsoft - Get it from GitHub + Get it from GitHub

@@ -58,14 +58,14 @@ SecureFolderFS is a modern encryption program that helps you keep your files saf From the app's UI, you can create new vaults to store items securely.

- +

> *For example, the 'Secrets' vault is locked behind a password.* > *You must enter the correct password, which was set when the vault was created, to decrypt and unlock the vault.*

- +

> *Upon entering the correct password, the vault will then open.* @@ -162,5 +162,5 @@ cd SecureFolderFS ---

- +

\ No newline at end of file diff --git a/lib/nwebdav b/lib/nwebdav index 1e3693ce3..e1df3f4a1 160000 --- a/lib/nwebdav +++ b/lib/nwebdav @@ -1 +1 @@ -Subproject commit 1e3693ce3789409464873480bce920d5711307a7 +Subproject commit e1df3f4a1c80aef525814c0c193ed56251224be2 diff --git a/solution-config.props b/solution-config.props deleted file mode 100644 index 23a780e1d..000000000 --- a/solution-config.props +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - net8.0-windows10.0.19041.0 - - - - - - - - diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesCtr128.cs b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesCtr256.cs similarity index 99% rename from src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesCtr128.cs rename to src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesCtr256.cs index f15f7cabc..1a757260c 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesCtr128.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesCtr256.cs @@ -5,7 +5,7 @@ namespace SecureFolderFS.Core.Cryptography.Cipher { [Obsolete("AES-CTR + HMAC encryption mode is deprecated.")] - public static class AesCtr128 + public static class AesCtr256 { private const ulong CTR_START = 0UL; diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesGcm128.cs b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesGcm256.cs similarity index 95% rename from src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesGcm128.cs rename to src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesGcm256.cs index 00dc63cf5..b570f207b 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesGcm128.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesGcm256.cs @@ -3,8 +3,7 @@ namespace SecureFolderFS.Core.Cryptography.Cipher { - /// TODO: Needs docs - public static class AesGcm128 + public static class AesGcm256 { public static void Encrypt(ReadOnlySpan bytes, ReadOnlySpan key, ReadOnlySpan nonce, Span tag, Span result, ReadOnlySpan associatedData) { diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv128.cs b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv256.cs similarity index 83% rename from src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv128.cs rename to src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv256.cs index 0064e1f5f..74c3b22bf 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv128.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv256.cs @@ -1,21 +1,19 @@ -using Miscreant; -using System; +using System; using System.Runtime.CompilerServices; -using System.Security.Cryptography; +using Miscreant; namespace SecureFolderFS.Core.Cryptography.Cipher { - // TODO: Needs docs - public sealed class AesSiv128 : IDisposable + public sealed class AesSiv256 : IDisposable { private readonly Aead _aesCmacSiv; - private AesSiv128(Aead aesCmacSiv) + private AesSiv256(Aead aesCmacSiv) { _aesCmacSiv = aesCmacSiv; } - public static AesSiv128 CreateInstance(ReadOnlySpan dekKey, ReadOnlySpan macKey) + public static AesSiv256 CreateInstance(ReadOnlySpan dekKey, ReadOnlySpan macKey) { // The longKey will be split into two keys - one for S2V and the other one for CTR var longKey = new byte[dekKey.Length + macKey.Length]; @@ -26,7 +24,7 @@ public static AesSiv128 CreateInstance(ReadOnlySpan dekKey, ReadOnlySpan plaintextChunk, long RandomNumberGenerator.Fill(ciphertextChunk.Slice(0, CHUNK_NONCE_SIZE)); // Encrypt - AesCtr128.Encrypt( + AesCtr256.Encrypt( plaintextChunk, header.GetHeaderContentKey(), ciphertextChunk.Slice(0, CHUNK_NONCE_SIZE), @@ -114,7 +114,7 @@ public override unsafe bool DecryptChunk(ReadOnlySpan ciphertextChunk, lon } // Decrypt - AesCtr128.Decrypt( + AesCtr256.Decrypt( ciphertextChunk.GetChunkPayload(), header.GetHeaderContentKey(), ciphertextChunk.GetChunkNonce(), diff --git a/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/AesGcmContentCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/AesGcmContentCrypt.cs index 6b6938c5e..fbf63bf0d 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/AesGcmContentCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/AesGcmContentCrypt.cs @@ -34,7 +34,7 @@ public override void EncryptChunk(ReadOnlySpan plaintextChunk, long chunkN CryptHelpers.FillAssociatedDataBigEndian(associatedData, header.GetHeaderNonce(), chunkNumber); // Encrypt - AesGcm128.Encrypt( + AesGcm256.Encrypt( plaintextChunk, header.GetHeaderContentKey(), ciphertextChunk.Slice(0, CHUNK_NONCE_SIZE), @@ -52,7 +52,7 @@ public override bool DecryptChunk(ReadOnlySpan ciphertextChunk, long chunk CryptHelpers.FillAssociatedDataBigEndian(associatedData, header.GetHeaderNonce(), chunkNumber); // Decrypt - return AesGcm128.TryDecrypt( + return AesGcm256.TryDecrypt( ciphertextChunk.GetChunkPayload(), header.GetHeaderContentKey(), ciphertextChunk.GetChunkNonce(), diff --git a/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/BaseContentCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/BaseContentCrypt.cs index 477d90fbc..9a1b82c5e 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/BaseContentCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/BaseContentCrypt.cs @@ -35,7 +35,7 @@ public virtual long CalculateCiphertextSize(long plaintextSize) /// public virtual long CalculatePlaintextSize(long ciphertextSize) { - if (ciphertextSize == 0L) + if (ciphertextSize <= 0L) return 0L; var chunkOverhead = ChunkCiphertextSize - ChunkPlaintextSize; diff --git a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesCtrHmacHeaderCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesCtrHmacHeaderCrypt.cs index f22bc458d..2122b04a7 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesCtrHmacHeaderCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesCtrHmacHeaderCrypt.cs @@ -50,7 +50,7 @@ public override unsafe void EncryptHeader(ReadOnlySpan plaintextHeader, Sp var pt = new ReadOnlySpan((byte*)s.ptPtr, s.ptLen); var ct = new Span((byte*)s.ctPtr, s.ctLen); - AesCtr128.Encrypt( + AesCtr256.Encrypt( pt.GetHeaderContentKey(), dekKey, pt.GetHeaderNonce(), @@ -113,7 +113,7 @@ public override unsafe bool DecryptHeader(ReadOnlySpan ciphertextHeader, S var ct = new ReadOnlySpan((byte*)s.ctPtr, s.ctLen); var pt = new Span((byte*)s.ptPtr, s.ptLen); - AesCtr128.Decrypt( + AesCtr256.Decrypt( ct.GetHeaderContentKey(), dekKey, ct.GetHeaderNonce(), diff --git a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesGcmHeaderCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesGcmHeaderCrypt.cs index 23b1f16dd..da5330f47 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesGcmHeaderCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesGcmHeaderCrypt.cs @@ -48,7 +48,7 @@ public override unsafe void EncryptHeader(ReadOnlySpan plaintextHeader, Sp var ct = new Span((byte*)s.ctPtr, s.ctLen); // Encrypt - AesGcm128.Encrypt( + AesGcm256.Encrypt( pt.GetHeaderContentKey(), dekKey, pt.GetHeaderNonce(), @@ -76,7 +76,7 @@ public override unsafe bool DecryptHeader(ReadOnlySpan ciphertextHeader, S var pt = new Span((byte*)s.ptPtr, s.ptLen); // Decrypt - return AesGcm128.TryDecrypt( + return AesGcm256.TryDecrypt( ct.GetHeaderContentKey(), dekKey, ct.GetHeaderNonce(), diff --git a/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs index 2cef11dac..94d40ad3d 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs @@ -8,21 +8,22 @@ namespace SecureFolderFS.Core.Cryptography.NameCrypt /// internal sealed class AesSivNameCrypt : BaseNameCrypt { - private readonly AesSiv128 _aesSiv128; + private readonly AesSiv256 _aesSiv256; public AesSivNameCrypt(KeyPair keyPair, string fileNameEncodingId) : base(fileNameEncodingId) { - _aesSiv128 = keyPair.UseKeys((dekKey, macKey) => + _aesSiv256 = keyPair.UseKeys((dekKey, macKey) => { - return AesSiv128.CreateInstance(dekKey.ToArray(), macKey.ToArray()); // Note: AesSiv128 requires a byte[] key. + // Note: AesSiv256 requires a byte[] key. + return AesSiv256.CreateInstance(dekKey.ToArray(), macKey.ToArray()); }); } /// protected override byte[] EncryptFileName(ReadOnlySpan plaintextFileNameBuffer, ReadOnlySpan directoryId) { - return _aesSiv128.Encrypt(plaintextFileNameBuffer, directoryId); + return _aesSiv256.Encrypt(plaintextFileNameBuffer, directoryId); } /// @@ -30,7 +31,7 @@ protected override byte[] EncryptFileName(ReadOnlySpan plaintextFileNameBu { try { - return _aesSiv128.Decrypt(ciphertextFileNameBuffer, directoryId); + return _aesSiv256.Decrypt(ciphertextFileNameBuffer, directoryId); } catch (CryptographicException) { @@ -41,7 +42,7 @@ protected override byte[] EncryptFileName(ReadOnlySpan plaintextFileNameBu /// public override void Dispose() { - _aesSiv128.Dispose(); + _aesSiv256.Dispose(); } } } diff --git a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs index 67f506033..98fbeef5f 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs @@ -18,14 +18,13 @@ namespace SecureFolderFS.Core.Dokany.Callbacks { internal abstract class BaseDokanyCallbacks : IDokanOperationsUnsafe, IDisposable { + protected readonly FileSystemSpecifics specifics; protected readonly BaseHandlesManager handlesManager; protected readonly VolumeModel volumeModel; - public FileSystemSpecifics Specifics { get; } - protected BaseDokanyCallbacks(FileSystemSpecifics specifics, BaseHandlesManager handlesManager, VolumeModel volumeModel) { - Specifics = specifics; + this.specifics = specifics; this.handlesManager = handlesManager; this.volumeModel = volumeModel; } @@ -83,7 +82,7 @@ public virtual NtStatus FindFiles(string fileName, out IList fi /// public virtual NtStatus SetEndOfFile(string fileName, long length, IDokanFileInfo info) { - if (Specifics.Options.IsReadOnly) + if (specifics.Options.IsReadOnly) return Trace(DokanResult.AccessDenied, fileName, info); if (handlesManager.GetHandle(GetContextValue(info)) is not { } fileHandle) @@ -207,7 +206,7 @@ public virtual unsafe NtStatus ReadFile(string fileName, IntPtr buffer, uint buf [MethodImpl(MethodImplOptions.Synchronized)] public virtual unsafe NtStatus WriteFile(string fileName, IntPtr buffer, uint bufferLength, out int bytesWritten, long offset, IDokanFileInfo info) { - if (Specifics.Options.IsReadOnly) + if (specifics.Options.IsReadOnly) { bytesWritten = 0; return Trace(DokanResult.AccessDenied, fileName, info); @@ -349,7 +348,7 @@ protected void CloseHandle(IDokanFileInfo info) /// public virtual void Dispose() { - Specifics.Dispose(); + specifics.Dispose(); handlesManager.Dispose(); } diff --git a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs index 729098cce..4d1308fc1 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs @@ -75,7 +75,7 @@ public override NtStatus CreateFile(string fileName, FileAccess access, FileShar case FileMode.CreateNew: { - if (Specifics.Options.IsReadOnly) + if (specifics.Options.IsReadOnly) throw FileSystemExceptions.FileSystemReadOnly; if (Directory.Exists(ciphertextPath)) @@ -102,7 +102,7 @@ public override NtStatus CreateFile(string fileName, FileAccess access, FileShar directoryIdStream.Write(directoryId); // Set DirectoryID to known IDs - Specifics.DirectoryIdCache.CacheSet(directoryIdPath, new(directoryId)); + specifics.DirectoryIdCache.CacheSet(directoryIdPath, new(directoryId)); break; } } @@ -169,7 +169,7 @@ public override NtStatus CreateFile(string fileName, FileAccess access, FileShar try { - if (Specifics.Options.IsReadOnly && mode.IsWriteFlag()) + if (specifics.Options.IsReadOnly && mode.IsWriteFlag()) throw FileSystemExceptions.FileSystemReadOnly; var openAccess = readAccess ? System.IO.FileAccess.Read : System.IO.FileAccess.ReadWrite; @@ -234,7 +234,7 @@ public override void Cleanup(string fileName, IDokanFileInfo info) InvalidateContext(info); // Make sure we delete redirected items from DeleteDirectory() and DeleteFile() here. - if (info.DeletePending && !Specifics.Options.IsReadOnly) + if (info.DeletePending && !specifics.Options.IsReadOnly) { var ciphertextPath = GetCiphertextPath(fileName); if (ciphertextPath is null) @@ -245,12 +245,12 @@ public override void Cleanup(string fileName, IDokanFileInfo info) if (info.IsDirectory) { var directoryIdPath = Path.Combine(ciphertextPath, FileSystem.Constants.Names.DIRECTORY_ID_FILENAME); - Specifics.DirectoryIdCache.CacheRemove(directoryIdPath); - NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, Specifics, StorableType.Folder); + specifics.DirectoryIdCache.CacheRemove(directoryIdPath); + NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.Folder); } else { - NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, Specifics, StorableType.File); + NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.File); } } catch (UnauthorizedAccessException) @@ -283,7 +283,7 @@ public override NtStatus GetFileInformation(string fileName, out FileInformation LastAccessTime = fsInfo.LastAccessTime, LastWriteTime = fsInfo.LastWriteTime, Length = fsInfo is FileInfo fileInfo2 - ? Specifics.Security.ContentCrypt.CalculatePlaintextSize(Math.Max(0L, fileInfo2.Length - Specifics.Security.HeaderCrypt.HeaderCiphertextSize)) + ? specifics.Security.ContentCrypt.CalculatePlaintextSize(Math.Max(0L, fileInfo2.Length - specifics.Security.HeaderCrypt.HeaderCiphertextSize)) : 0L }; @@ -322,9 +322,9 @@ public override NtStatus GetDiskFreeSpace(out long freeBytesAvailable, out long if (_vaultDriveInfo is null && _vaultDriveInfoTries < Constants.Dokan.MAX_DRIVE_INFO_CALLS_UNTIL_GIVE_UP) { _vaultDriveInfoTries++; - _vaultDriveInfo ??= DriveInfo.GetDrives().SingleOrDefault(di => + _vaultDriveInfo ??= DriveInfo.GetDrives().SingleOrDefault(di => di.IsReady && - di.RootDirectory.Name.Equals(Path.GetPathRoot(Specifics.ContentFolder.Id), StringComparison.OrdinalIgnoreCase)); + di.RootDirectory.Name.Equals(Path.GetPathRoot(specifics.ContentFolder.Id), StringComparison.OrdinalIgnoreCase)); } freeBytesAvailable = _vaultDriveInfo?.TotalFreeSpace ?? 0L; @@ -349,21 +349,18 @@ public override NtStatus FindFilesWithPattern(string fileName, string searchPatt var directory = new DirectoryInfo(ciphertextPath); List? fileList = null; - var directoryId = AbstractPathHelpers.AllocateDirectoryId(Specifics.Security, fileName); - var itemsEnumerable = Specifics.Security.NameCrypt is null ? directory.EnumerateFileSystemInfos(searchPattern) : directory.EnumerateFileSystemInfos(); - + var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, fileName); + var itemsEnumerable = specifics.Security.NameCrypt is null ? directory.EnumerateFileSystemInfos(searchPattern) : directory.EnumerateFileSystemInfos(); foreach (var item in itemsEnumerable) { if (PathHelpers.IsCoreName(item.Name)) continue; - var plaintextName = NativePathHelpers.GetPlaintextPath(item.FullName, Specifics, directoryId); - plaintextName = plaintextName is not null ? Path.GetFileName(plaintextName) : null; - + var plaintextName = NativePathHelpers.DecryptName(item.Name, directory.FullName, specifics, directoryId); if (string.IsNullOrEmpty(plaintextName)) continue; - if (Specifics.Security.NameCrypt is not null && !UnsafeNativeApis.PathMatchSpec(plaintextName, searchPattern)) + if (specifics.Security.NameCrypt is not null && !UnsafeNativeApis.PathMatchSpec(plaintextName, searchPattern)) continue; fileList ??= new(); @@ -375,7 +372,7 @@ public override NtStatus FindFilesWithPattern(string fileName, string searchPatt LastAccessTime = item.LastAccessTime, LastWriteTime = item.LastWriteTime, Length = item is FileInfo fileInfo - ? Specifics.Security.ContentCrypt.CalculatePlaintextSize(Math.Max(0L, fileInfo.Length - Specifics.Security.HeaderCrypt.HeaderCiphertextSize)) + ? specifics.Security.ContentCrypt.CalculatePlaintextSize(Math.Max(0L, fileInfo.Length - specifics.Security.HeaderCrypt.HeaderCiphertextSize)) : 0L }); } @@ -393,7 +390,7 @@ public override NtStatus FindFilesWithPattern(string fileName, string searchPatt /// public override NtStatus SetFileAttributes(string fileName, FileAttributes attributes, IDokanFileInfo info) { - if (Specifics.Options.IsReadOnly) + if (specifics.Options.IsReadOnly) return Trace(DokanResult.AccessDenied, fileName, info); try @@ -489,7 +486,7 @@ public override NtStatus DeleteFile(string fileName, IDokanFileInfo info) { // Just check if we can delete the file - the true deletion is done in Cleanup() - if (Specifics.Options.IsReadOnly) + if (specifics.Options.IsReadOnly) return Trace(DokanResult.AccessDenied, fileName, info); // Get ciphertext path @@ -513,7 +510,7 @@ public override NtStatus DeleteFile(string fileName, IDokanFileInfo info) /// public override NtStatus DeleteDirectory(string fileName, IDokanFileInfo info) { - if (Specifics.Options.IsReadOnly) + if (specifics.Options.IsReadOnly) return Trace(DokanResult.AccessDenied, fileName, info); var canDelete = true; @@ -546,7 +543,7 @@ public override NtStatus MoveFile(string oldName, string newName, bool replace, if (oldCiphertextPath is null || newCiphertextPath is null) return Trace(NtStatus.ObjectPathInvalid, fileNameCombined, info); - if (Specifics.Options.IsReadOnly) + if (specifics.Options.IsReadOnly) return Trace(DokanResult.AccessDenied, fileNameCombined, info); CloseHandle(info); @@ -720,7 +717,7 @@ public override NtStatus GetFileSecurity(string fileName, out FileSystemSecurity /// public override NtStatus SetFileSecurity(string fileName, FileSystemSecurity security, AccessControlSections sections, IDokanFileInfo info) { - if (Specifics.Options.IsReadOnly) + if (specifics.Options.IsReadOnly) return Trace(DokanResult.AccessDenied, fileName, info); try @@ -825,7 +822,7 @@ public override NtStatus FindStreams(string fileName, out IList /// protected override string? GetCiphertextPath(string plaintextName) { - return NativePathHelpers.GetCiphertextPath(plaintextName, Specifics); + return NativePathHelpers.GetCiphertextPath(plaintextName, specifics); } } } diff --git a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/BaseFuseCallbacks.cs b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/BaseFuseCallbacks.cs index 98defcf8c..5beb985bf 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/BaseFuseCallbacks.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/BaseFuseCallbacks.cs @@ -7,13 +7,12 @@ namespace SecureFolderFS.Core.FUSE.Callbacks { internal abstract class BaseFuseCallbacks : FuseFileSystemBase { + protected FileSystemSpecifics specifics; protected readonly FuseHandlesManager handlesManager; - public FileSystemSpecifics Specifics { get; } - protected BaseFuseCallbacks(FileSystemSpecifics specifics, FuseHandlesManager handlesManager) { - Specifics = specifics; + this.specifics = specifics; this.handlesManager = handlesManager; } diff --git a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs index f2a639a54..659bc0f7d 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs @@ -5,6 +5,7 @@ using SecureFolderFS.Core.FUSE.UnsafeNative; using System.Runtime.CompilerServices; using System.Text; +using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using Tmds.Fuse; using Tmds.Linux; using static SecureFolderFS.Core.FUSE.UnsafeNative.UnsafeNativeApis; @@ -198,7 +199,7 @@ public override unsafe int GetAttr(ReadOnlySpan path, ref stat stat, FuseF return -errno; if (File.Exists(ciphertextPath)) - stat.st_size = Math.Max(0, Specifics.Security.ContentCrypt.CalculatePlaintextSize(stat.st_size - Specifics.Security.HeaderCrypt.HeaderCiphertextSize)); + stat.st_size = Math.Max(0, specifics.Security.ContentCrypt.CalculatePlaintextSize(stat.st_size - specifics.Security.HeaderCrypt.HeaderCiphertextSize)); return 0; } @@ -277,7 +278,7 @@ public override unsafe int MkDir(ReadOnlySpan path, mode_t mode) directoryIdStream.Write(directoryId); // Set DirectoryID to known IDs - Specifics.DirectoryIdCache.CacheSet(directoryIdPath, new(directoryId)); + specifics.DirectoryIdCache.CacheSet(directoryIdPath, new(directoryId)); return 0; } @@ -359,14 +360,15 @@ public override int ReadDir(ReadOnlySpan path, ulong offset, ReadDirFlags content.AddEntry("."); content.AddEntry(".."); + var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security); foreach (var entry in Directory.GetFileSystemEntries(ciphertextPath)) { if (PathHelpers.IsCoreName(entry)) continue; - var directoryId = new byte[FileSystem.Constants.DIRECTORY_ID_SIZE]; - var plaintextPath = NativePathHelpers.GetPlaintextPath(entry, Specifics, directoryId); - content.AddEntry(Path.GetFileName(plaintextPath)); + var ciphertextName = Path.GetFileName(entry); + var plaintextName = NativePathHelpers.DecryptName(ciphertextName, ciphertextPath, specifics, directoryId); + content.AddEntry(plaintextName); } return 0; @@ -438,7 +440,7 @@ public override unsafe int RmDir(ReadOnlySpan path) // Remove DirectoryID File.Delete(directoryIdPath); - Specifics.DirectoryIdCache.CacheRemove(directoryIdPath); + specifics.DirectoryIdCache.CacheRemove(directoryIdPath); fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) { @@ -617,7 +619,7 @@ public override int Write(ReadOnlySpan path, ulong offset, ReadOnlySpan - internal sealed class CachingFileNameAccess : FileNameAccess - { - private readonly Dictionary _plaintextNames; - private readonly Dictionary _ciphertextNames; - - public CachingFileNameAccess(Security security, IFileSystemStatistics fileSystemStatistics) - : base(security, fileSystemStatistics) - { - _plaintextNames = new(FileSystem.Constants.Caching.RECOMMENDED_SIZE_PLAINTEXT_FILENAMES); - _ciphertextNames = new(FileSystem.Constants.Caching.RECOMMENDED_SIZE_CIPHERTEXT_FILENAMES); - } - - /// - public override string GetPlaintextName(ReadOnlySpan ciphertextName, ReadOnlySpan directoryId) - { - string plaintextName; - string stringCiphertext = ciphertextName.ToString(); - - lock (_plaintextNames) - { - if (!_plaintextNames.TryGetValue(new(directoryId.ToArray(), stringCiphertext), out plaintextName!)) - { - // Not found in cache - statistics.FileNameCache?.Report(CacheAccessType.CacheMiss); - - // Get new plaintext name - var newPlaintextName = base.GetPlaintextName(ciphertextName, directoryId); - if (newPlaintextName == string.Empty) - return string.Empty; - - // Update cache - SetPlaintextName(newPlaintextName, stringCiphertext, directoryId); - - return newPlaintextName; - } - } - - statistics.FileNameCache?.Report(CacheAccessType.CacheAccess); - statistics.FileNameCache?.Report(CacheAccessType.CacheHit); - - // Return existing plaintext name - return plaintextName; - } - - /// - public override string GetCiphertextName(ReadOnlySpan plaintextName, ReadOnlySpan directoryId) - { - string ciphertextName; - string stringPlaintext = plaintextName.ToString(); - - lock (_ciphertextNames) - { - if (!_ciphertextNames.TryGetValue(new(directoryId.ToArray(), stringPlaintext), out ciphertextName!)) - { - // Not found in cache - statistics.FileNameCache?.Report(CacheAccessType.CacheMiss); - - // Get new ciphertext name - var newCiphertextName = base.GetCiphertextName(plaintextName, directoryId); - if (newCiphertextName == string.Empty) - return string.Empty; - - // Update cache - SetCiphertextName(newCiphertextName, stringPlaintext, directoryId); - - return newCiphertextName; - } - } - - statistics.FileNameCache?.Report(CacheAccessType.CacheAccess); - statistics.FileNameCache?.Report(CacheAccessType.CacheHit); - - // Return existing ciphertext name - return ciphertextName; - } - - private void SetPlaintextName(string plaintextName, string ciphertextName, ReadOnlySpan directoryId) - { - if (_plaintextNames.Count >= FileSystem.Constants.Caching.RECOMMENDED_SIZE_PLAINTEXT_FILENAMES) - _plaintextNames.Remove(_plaintextNames.Keys.First(), out _); - - _plaintextNames[new(directoryId.ToArray(), ciphertextName)] = plaintextName; - } - - private void SetCiphertextName(string ciphertextName, string plaintextName, ReadOnlySpan directoryId) - { - if (_ciphertextNames.Count >= FileSystem.Constants.Caching.RECOMMENDED_SIZE_CIPHERTEXT_FILENAMES) - _ciphertextNames.Remove(_ciphertextNames.Keys.First(), out _); - - _ciphertextNames[new(directoryId.ToArray(), plaintextName)] = ciphertextName; - } - } -} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/FileNames/FileNameAccess.cs b/src/Core/SecureFolderFS.Core.FileSystem/FileNames/FileNameAccess.cs deleted file mode 100644 index a270e28b5..000000000 --- a/src/Core/SecureFolderFS.Core.FileSystem/FileNames/FileNameAccess.cs +++ /dev/null @@ -1,58 +0,0 @@ -using SecureFolderFS.Core.Cryptography; -using SecureFolderFS.Shared.Enums; -using SecureFolderFS.Storage.VirtualFileSystem; -using System; -using System.IO; - -namespace SecureFolderFS.Core.FileSystem.FileNames -{ - /// - /// Accesses plaintext and ciphertext names of files and folders found on the encrypting file system. - /// - internal class FileNameAccess - { - protected readonly Security security; - protected readonly IFileSystemStatistics statistics; - - public FileNameAccess(Security security, IFileSystemStatistics statistics) - { - this.security = security; - this.statistics = statistics; - } - - /// - /// Gets plaintext name from associated . - /// - /// The associated ciphertext name. - /// The ID of a directory where the item is stored. - /// If successful, returns a plaintext representation of the name; otherwise empty. - public virtual string GetPlaintextName(ReadOnlySpan ciphertextName, ReadOnlySpan directoryId) - { - statistics.FileNameCache?.Report(CacheAccessType.CacheAccess); - - var nameWithoutExt = Path.GetFileNameWithoutExtension(ciphertextName); - if (nameWithoutExt.IsEmpty) - return string.Empty; - - var plaintextName = security.NameCrypt!.DecryptName(nameWithoutExt, directoryId); - if (plaintextName is null) - return string.Empty; - - return plaintextName; - } - - /// - /// Gets ciphertext name from associated . - /// - /// The associated plaintext name. - /// The ID of a directory where the item is stored. - /// If successful, returns a ciphertext representation of the name; otherwise empty. - public virtual string GetCiphertextName(ReadOnlySpan plaintextName, ReadOnlySpan directoryId) - { - statistics.FileNameCache?.Report(CacheAccessType.CacheAccess); - - var ciphertextName = security.NameCrypt!.EncryptName(plaintextName, directoryId); - return Path.ChangeExtension(ciphertextName, Constants.Names.ENCRYPTED_FILE_EXTENSION); - } - } -} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Directory.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Directory.cs index 5c5af42a1..5e32a8f7f 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Directory.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Directory.cs @@ -38,7 +38,7 @@ public static async Task RepairDirectoryAsync(IFolder affected, Securit if (PathHelpers.IsCoreName(item.Name)) continue; - // Encrypt new name + // Encrypt a new name var encryptedName = security.NameCrypt.EncryptName(item.Name, directoryId); encryptedName = $"{encryptedName}{Constants.Names.ENCRYPTED_FILE_EXTENSION}"; diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Name.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Name.cs index 25611670f..c6d0587ad 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Name.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Name.cs @@ -8,6 +8,8 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; +using SecureFolderFS.Shared.Helpers; namespace SecureFolderFS.Core.FileSystem.Helpers.Health { @@ -15,14 +17,28 @@ public static partial class HealthHelpers { public static async Task RepairNameAsync(IStorableChild affected, FileSystemSpecifics specifics, string newName, CancellationToken cancellationToken) { - return await RepairNameAsync(affected, specifics.Security, specifics.ContentFolder, newName, cancellationToken); + var repairResult = await RepairNameAsync(affected, specifics.Security, specifics.ContentFolder, newName, cancellationToken); + if (!repairResult.Successful || !specifics.CiphertextFileNameCache.IsAvailable) + return repairResult; - // TODO: Update caches in FileSystemSpecifics + // Update cache + await SafetyHelpers.NoFailureAsync(async () => + { + var parent = await affected.GetParentAsync(cancellationToken); + if (parent is null) + return; + + var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security); + var isAllocated = await AbstractPathHelpers.GetDirectoryIdAsync(parent, specifics, directoryId, cancellationToken); + specifics.CiphertextFileNameCache.CacheRemove(new(isAllocated ? directoryId : [], affected.Name)); + }); + + return repairResult; } - public static async Task RepairNameAsync(IStorableChild affected, Security security, IFolder contentFolder, string newName, CancellationToken cancellationToken) + private static async Task RepairNameAsync(IStorableChild affected, Security security, IFolder contentFolder, string newName, CancellationToken cancellationToken) { - // Return success, if no encryption is used + // Return success if no encryption is used if (security.NameCrypt is null) return Result.Success; diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs new file mode 100644 index 000000000..70b168b9d --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs @@ -0,0 +1,122 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; +using SecureFolderFS.Core.FileSystem.FileNames; + +namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract +{ + public static partial class AbstractPathHelpers + { + /// + /// Encrypts the provided . + /// + /// The name to encrypt. + /// The ciphertext parent folder. + /// The instance associated with the item. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is an encrypted name. + public static async Task EncryptNameAsync(string plaintextName, IFolder ciphertextParentFolder, + FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + var directoryId = AllocateDirectoryId(specifics.Security, plaintextName); + var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, directoryId, cancellationToken); + + return specifics.Security.NameCrypt.EncryptName(plaintextName, result ? directoryId : ReadOnlySpan.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION; + } + + /// + /// Decrypts the provided . + /// + /// The name to decrypt. + /// The ciphertext parent folder. + /// The instance associated with the item. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is a decrypted name. + public static async Task DecryptNameAsync(string ciphertextName, IFolder ciphertextParentFolder, + FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + { + try + { + if (specifics.Security.NameCrypt is null) + return ciphertextName; + + var directoryId = AllocateDirectoryId(specifics.Security, ciphertextName); + var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, directoryId, cancellationToken); + + var normalizedName = RemoveCiphertextExtension(ciphertextName); + return specifics.Security.NameCrypt.DecryptName(normalizedName, result ? directoryId : ReadOnlySpan.Empty); + } + catch (Exception) + { + return null; + } + } + + /// + /// Decrypts the provided by utilizing a plaintext name cache, or directly if the cache is unavailable. + /// + /// The encrypted name to decrypt. + /// The ciphertext parent folder. + /// The instance associated with the item. + /// A that cancels this action. + /// A that represents the asynchronous operation. The result is the decrypted name, retrieved from the cache if available, or null if it is not found and decryption fails. + public static async Task CacheDecryptNameAsync(string ciphertextName, IFolder ciphertextParentFolder, + FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + { + if (specifics.Security.NameCrypt is null) + return ciphertextName; + + if (!specifics.PlaintextFileNameCache.IsAvailable) + return await DecryptNameAsync(ciphertextName, ciphertextParentFolder, specifics, cancellationToken); + + var directoryId = AllocateDirectoryId(specifics.Security, ciphertextName); + var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, directoryId, cancellationToken); + + var cacheKey = new NameWithDirectoryId(result ? directoryId : [], ciphertextName); + var cachedName = specifics.PlaintextFileNameCache.CacheGet(cacheKey); + if (!string.IsNullOrEmpty(cachedName)) + return cachedName; + + var decryptedName = await DecryptNameAsync(ciphertextName, ciphertextParentFolder, specifics, cancellationToken); + if (string.IsNullOrEmpty(decryptedName)) + return null; + + specifics.PlaintextFileNameCache.CacheSet(cacheKey, decryptedName); + return decryptedName; + } + + /// + /// Encrypts the provided by utilizing a ciphertext name cache, or directly if the cache is unavailable. + /// + /// The name to encrypt. + /// The ciphertext parent folder. + /// The instance associated with the item. + /// A that cancels this action. + /// A that represents the asynchronous operation. The result is an encrypted name, retrieved from the cache if available, or newly encrypted if not. + public static async Task CacheEncryptNameAsync(string plaintextName, IFolder ciphertextParentFolder, + FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + if (!specifics.CiphertextFileNameCache.IsAvailable) + return await EncryptNameAsync(plaintextName, ciphertextParentFolder, specifics, cancellationToken); + + var directoryId = AllocateDirectoryId(specifics.Security, plaintextName); + var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, directoryId, cancellationToken); + + var cacheKey = new NameWithDirectoryId(result ? directoryId : [], plaintextName); + var cachedName = specifics.CiphertextFileNameCache.CacheGet(cacheKey); + if (!string.IsNullOrEmpty(cachedName)) + return cachedName; + + var encryptedName = await EncryptNameAsync(plaintextName, ciphertextParentFolder, specifics, cancellationToken); + specifics.CiphertextFileNameCache.CacheSet(cacheKey, encryptedName); + return encryptedName; + } + } +} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Cipher.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Paths.cs similarity index 50% rename from src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Cipher.cs rename to src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Paths.cs index 424c42271..cf1a4d967 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Cipher.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Paths.cs @@ -1,5 +1,4 @@ using OwlCore.Storage; -using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Storage.Extensions; using System; using System.Collections.Generic; @@ -9,9 +8,6 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract { - /// - /// A set of file system path management helpers that work on any platform including constrained environments with limited file system access. - /// public static partial class AbstractPathHelpers { public static async Task GetCiphertextItemAsync(string plaintextPath, FileSystemSpecifics specifics, CancellationToken cancellationToken) @@ -22,7 +18,7 @@ public static partial class AbstractPathHelpers IStorable finalItem = specifics.ContentFolder; var currentParent = specifics.ContentFolder; - foreach (var plaintextName in plaintextPath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) + foreach (var plaintextName in plaintextPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) { var ciphertextName = await EncryptNameAsync(plaintextName, currentParent, specifics, cancellationToken); finalItem = await currentParent.GetFirstByNameAsync(ciphertextName, cancellationToken); @@ -34,31 +30,6 @@ public static partial class AbstractPathHelpers return finalItem; } - // TODO: Add a comment that this method assumes that items have a backing store - public static async Task GetCiphertextPathAsync(IStorableChild plaintextStorable, FileSystemSpecifics specifics, CancellationToken cancellationToken) - { - if (specifics.Security.NameCrypt is null) - return plaintextStorable.Id; - - var finalPath = string.Empty; - var currentStorable = plaintextStorable; - var expendableDirectoryId = new byte[Constants.DIRECTORY_ID_SIZE]; - - while (await currentStorable.GetParentAsync(cancellationToken).ConfigureAwait(false) is IChildFolder currentParent) - { - if (currentParent is not IWrapper { Inner: { } ciphertextParent }) - return null; - - var result = await GetDirectoryIdAsync(ciphertextParent, specifics, expendableDirectoryId, cancellationToken).ConfigureAwait(false); - var ciphertextName = specifics.Security.NameCrypt.EncryptName(currentStorable.Name, result ? expendableDirectoryId : ReadOnlySpan.Empty); - - finalPath = Path.Combine($"{ciphertextName}{Constants.Names.ENCRYPTED_FILE_EXTENSION}", finalPath); - currentStorable = currentParent; - } - - return Path.Combine(specifics.ContentFolder.Id, finalPath); - } - public static async Task GetCiphertextItemAsync(IStorableChild plaintextStorable, IFolder virtualizedRoot, FileSystemSpecifics specifics, CancellationToken cancellationToken) { if (specifics.Security.NameCrypt is null) @@ -123,50 +94,5 @@ public static partial class AbstractPathHelpers return $"/{finalPath}"; } - - /// - /// Encrypts the provided . - /// - /// The name to encrypt. - /// The ciphertext parent folder. - /// The instance associated with the item. - /// A that cancels this action. - /// A that represents the asynchronous operation. Value is an encrypted name. - public static async Task EncryptNameAsync(string plaintextName, IFolder ciphertextParentFolder, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) - { - if (specifics.Security.NameCrypt is null) - return plaintextName; - - var directoryId = AllocateDirectoryId(specifics.Security, plaintextName); - var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, directoryId, cancellationToken); - - return specifics.Security.NameCrypt.EncryptName(plaintextName, result ? directoryId : ReadOnlySpan.Empty) + FileSystem.Constants.Names.ENCRYPTED_FILE_EXTENSION; - } - - /// - /// Decrypts the provided . - /// - /// The name to decrypt. - /// The ciphertext parent folder. - /// The instance associated with the item. - /// A that cancels this action. - /// A that represents the asynchronous operation. Value is a decrypted name. - public static async Task DecryptNameAsync(string ciphertextName, IFolder ciphertextParentFolder, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) - { - try - { - if (specifics.Security.NameCrypt is null) - return ciphertextName; - - var directoryId = AllocateDirectoryId(specifics.Security, ciphertextName); - var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, directoryId, cancellationToken); - - return specifics.Security.NameCrypt.DecryptName(Path.GetFileNameWithoutExtension(ciphertextName), result ? directoryId : ReadOnlySpan.Empty); - } - catch (Exception) - { - return null; - } - } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shared.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shared.cs deleted file mode 100644 index 1b5a2e99e..000000000 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shared.cs +++ /dev/null @@ -1,23 +0,0 @@ -using SecureFolderFS.Core.Cryptography; -using System; -using System.IO; - -namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract -{ - /// - /// A set of file system path management helpers that work on any platform including constrained environments with limited file system access. - /// - public static partial class AbstractPathHelpers - { - public static byte[] AllocateDirectoryId(Security security, string? path = null) - { - if (security.NameCrypt is null) - return Array.Empty(); - - if (path == Path.DirectorySeparatorChar.ToString()) - return Array.Empty(); - - return new byte[Constants.DIRECTORY_ID_SIZE]; - } - } -} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.cs new file mode 100644 index 000000000..06ffb53a2 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Text; +using SecureFolderFS.Core.Cryptography; + +namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract +{ + /// + /// A set of file system path management helpers that work on any platform including constrained environments with limited file system access. + /// + public static partial class AbstractPathHelpers + { + public static byte[] AllocateDirectoryId(Security security, string? path = null) + { + if (security.NameCrypt is null) + return Array.Empty(); + + if (path == Path.DirectorySeparatorChar.ToString()) + return Array.Empty(); + + return new byte[Constants.DIRECTORY_ID_SIZE]; + } + + public static ReadOnlySpan RemoveCiphertextExtension(string ciphertextName) + { + // Do NOT use Path.GetFileNameWithoutExtension - after APFS NFD-decomposes Base4K + // codepoints, the string may contain spurious dot-like characters that confuse the + // path parser, causing it to truncate mid-ciphertext. + // Strip the known extension manually instead. + var nameWithoutExtension = ciphertextName.EndsWith(Constants.Names.ENCRYPTED_FILE_EXTENSION, StringComparison.Ordinal) + ? ciphertextName.AsSpan(0, ciphertextName.Length - Constants.Names.ENCRYPTED_FILE_EXTENSION.Length) + : ciphertextName.AsSpan(); + + // Only normalize if needed. APFS layers NFD-decompose Base4K codepoints, + // but Dokany/WinFsp deliver names in a form where NFC recomposition breaks lookup. + if (OperatingSystem.IsIOS() || OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst()) + { + // IsNormalized() lets the string itself determine whether normalization is safe. + // TODO: Fix normalization in Vault V4 + if (!nameWithoutExtension.IsNormalized(NormalizationForm.FormC)) + { + var normalizedLength = nameWithoutExtension.GetNormalizedLength(NormalizationForm.FormC); + var normalized = new char[normalizedLength]; + + // NFC-normalize before decoding + if (nameWithoutExtension.TryNormalize(normalized, out var written, NormalizationForm.FormC)) + return normalized.AsSpan(0, written); + } + } + + return nameWithoutExtension; + } + } +} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs new file mode 100644 index 000000000..7555c35e4 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; + +namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native +{ + public static partial class NativePathHelpers + { + public static string? GetDirectoryIdPathOfParent(string ciphertextChildPath, string rootPath) + { + if (ciphertextChildPath == Path.DirectorySeparatorChar.ToString() || ciphertextChildPath.Equals(rootPath)) + return string.Empty; + + var parentPath = Path.GetDirectoryName(ciphertextChildPath); + if (parentPath is null) + return null; + + // Parent path is the same as rootPath where the DirectoryID should be empty + if (parentPath == Path.DirectorySeparatorChar.ToString() || parentPath.Equals(rootPath)) + return string.Empty; + + return Path.Combine(parentPath, Constants.Names.DIRECTORY_ID_FILENAME); + } + + public static bool GetDirectoryId(string ciphertextFolderPath, FileSystemSpecifics specifics, Span directoryId) + { + // Check if we're at the root + if (ciphertextFolderPath == Path.DirectorySeparatorChar.ToString() || ciphertextFolderPath.Equals(specifics.ContentFolder.Id)) + return false; + + // Check directoryId size once we actually get to the reading + if (directoryId.Length < Constants.DIRECTORY_ID_SIZE) + throw new ArgumentException($"The {nameof(directoryId)} is of incorrect size: {directoryId.Length}."); + + // Try to get the Directory ID from the cache first + var directoryIdPath = Path.Combine(ciphertextFolderPath, Constants.Names.DIRECTORY_ID_FILENAME); + var cachedDirectoryId = specifics.DirectoryIdCache.CacheGet(directoryIdPath); + if (cachedDirectoryId is not null) + { + cachedDirectoryId.Buffer.CopyTo(directoryId); + return true; + } + + // Read the Directory ID from the file + using var directoryIdStream = File.Open(directoryIdPath, FileMode.Open, FileAccess.Read, FileShare.Read); + if (specifics.DirectoryIdCache.IsAvailable) + { + cachedDirectoryId = new(Constants.DIRECTORY_ID_SIZE); + var read = directoryIdStream.Read(cachedDirectoryId); + if (read < Constants.DIRECTORY_ID_SIZE) + throw new IOException($"The data inside the Directory ID file is of incorrect size: {read}."); + + cachedDirectoryId.Buffer.CopyTo(directoryId); + specifics.DirectoryIdCache.CacheSet(directoryIdPath, cachedDirectoryId); + } + else + { + var read = directoryIdStream.Read(directoryId); + if (read < Constants.DIRECTORY_ID_SIZE) + throw new IOException($"The data inside the Directory ID file is of incorrect size: {read}."); + } + + // The Directory ID is not empty - return true + return true; + } + + [Obsolete($"Use {nameof(GetDirectoryId)} instead.")] + public static bool GetDirectoryIdOfChild(string ciphertextChildPath, FileSystemSpecifics specifics, Span directoryId) + { + var directoryIdPath = GetDirectoryIdPathOfParent(ciphertextChildPath, specifics.ContentFolder.Id); + if (directoryIdPath is null) + throw new InvalidOperationException("Could not get the Directory ID path."); + + // If the Directory ID path is root, return false + if (directoryIdPath == string.Empty) + return false; + + // Check directoryId size once we actually get to the reading + if (directoryId.Length < Constants.DIRECTORY_ID_SIZE) + throw new ArgumentException($"The {nameof(directoryId)} is of incorrect size: {directoryId.Length}."); + + var cachedId = specifics.DirectoryIdCache.CacheGet(directoryIdPath); + if (cachedId is not null) + { + cachedId.Buffer.CopyTo(directoryId); + return true; + } + + int read; + using var directoryIdStream = File.Open(directoryIdPath, FileMode.Open, FileAccess.Read, FileShare.Read); + if (specifics.DirectoryIdCache.IsAvailable) + { + cachedId = new(Constants.DIRECTORY_ID_SIZE); + read = directoryIdStream.Read(cachedId); + specifics.DirectoryIdCache.CacheSet(directoryIdPath, cachedId); + } + else + read = directoryIdStream.Read(directoryId); + + if (read < Constants.DIRECTORY_ID_SIZE) + throw new IOException($"The data inside the Directory ID file is of incorrect size: {read}."); + + // The Directory ID is not empty - return true + return true; + } + } +} \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Names.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Names.cs new file mode 100644 index 000000000..0deb34ff0 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Names.cs @@ -0,0 +1,50 @@ +using System; +using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; + +namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native +{ + public static partial class NativePathHelpers + { + public static string EncryptName(string plaintextName, string plaintextParentFolder, + FileSystemSpecifics specifics) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, plaintextName); + return EncryptName(plaintextName, plaintextParentFolder, specifics, directoryId); + } + + public static string EncryptName(string plaintextName, string plaintextParentFolder, + FileSystemSpecifics specifics, Span expendableDirectoryId) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + var result = GetDirectoryId(plaintextParentFolder, specifics, expendableDirectoryId); + return specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION; + } + + public static string? DecryptName(string ciphertextName, string ciphertextParentFolder, + FileSystemSpecifics specifics) + { + if (specifics.Security.NameCrypt is null) + return ciphertextName; + + var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, ciphertextName); + return DecryptName(ciphertextName, ciphertextParentFolder, specifics, directoryId); + } + + public static string? DecryptName(string ciphertextName, string ciphertextParentFolder, + FileSystemSpecifics specifics, Span expendableDirectoryId) + { + if (specifics.Security.NameCrypt is null) + return ciphertextName; + + var result = GetDirectoryId(ciphertextParentFolder, specifics, expendableDirectoryId); + var normalizedName = AbstractPathHelpers.RemoveCiphertextExtension(ciphertextName); + + return specifics.Security.NameCrypt.DecryptName(normalizedName, result ? expendableDirectoryId : ReadOnlySpan.Empty); + } + } +} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Cipher.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Paths.cs similarity index 71% rename from src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Cipher.cs rename to src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Paths.cs index df272ff1f..415aac9ea 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Cipher.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Paths.cs @@ -3,9 +3,6 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native { - /// - /// A set of file system path management helpers that only work in a native environment with unlimited file system access. - /// public static partial class NativePathHelpers { /// @@ -61,16 +58,11 @@ public static string GetCiphertextPath(string plaintextRelativePath, FileSystemS var finalPath = specifics.ContentFolder.Id; foreach (var namePart in plaintextRelativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) { - // Get the Directory ID - // Important: We cannot just combine the ciphertext (final) path with DIRECTORY_ID_FILENAME since the directory may be the storage root. - // If the directory is in turn storage root, the Directory ID is empty, thus we must use GetDirectoryId - var result = GetDirectoryId(Path.Combine(finalPath, namePart), specifics, expendableDirectoryId); + // Encrypt the name part + var ciphertextName = EncryptName(namePart, finalPath, specifics, expendableDirectoryId); - // Encrypt the name. Use ReadOnlySpan.Empty only when we are in the storage root directory - var ciphertextName = specifics.Security.NameCrypt.EncryptName(namePart, result ? expendableDirectoryId : ReadOnlySpan.Empty); - - // Combine the final path and append extension - finalPath = Path.Combine(finalPath, $"{ciphertextName}{Constants.Names.ENCRYPTED_FILE_EXTENSION}"); + // Combine the final path + finalPath = Path.Combine(finalPath, ciphertextName); } return finalPath; @@ -97,22 +89,12 @@ public static string GetCiphertextPath(string plaintextRelativePath, FileSystemS if (specifics.Security.NameCrypt is null) return ciphertextPath; - var finalPath = specifics.ContentFolder.Id; + var finalPath = string.Empty; var finalCiphertextPath = specifics.ContentFolder.Id; - foreach (var namePart in ciphertextPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) { - // Remove encrypted file extension - var clearedNamePart = Path.GetFileNameWithoutExtension(namePart); - - // Get the Directory ID - // Important: The ciphertext path must be used with a file name to retrieve the Directory ID. - // In addition, we cannot just combine the ciphertext path with DIRECTORY_ID_FILENAME since the directory may be the storage root. - // If the directory is in turn storage root, the Directory ID is empty, thus we must use GetDirectoryId - var result = GetDirectoryId(Path.Combine(finalCiphertextPath, namePart), specifics, expendableDirectoryId); - - // Decrypt the name. Use ReadOnlySpan.Empty only when we are in the storage root directory - var plaintextName = specifics.Security.NameCrypt.DecryptName(clearedNamePart, result ? expendableDirectoryId : ReadOnlySpan.Empty); + // Decrypt the name part + var plaintextName = DecryptName(namePart, finalCiphertextPath, specifics, expendableDirectoryId); if (plaintextName is null) return null; diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Shared.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Shared.cs deleted file mode 100644 index 69e0107c8..000000000 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Shared.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.IO; - -namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native -{ - /// - /// A set of file system path management helpers that only work in a native environment with unlimited file system access. - /// - public static partial class NativePathHelpers - { - public static string? GetDirectoryIdPathOfParent(string ciphertextChildPath, string rootPath) - { - if (ciphertextChildPath == Path.DirectorySeparatorChar.ToString() || ciphertextChildPath.Equals(rootPath)) - return string.Empty; - - var parentPath = Path.GetDirectoryName(ciphertextChildPath); - if (parentPath is null) - return null; - - // Parent path is the same as rootPath where the DirectoryID should be empty - if (parentPath == Path.DirectorySeparatorChar.ToString() || parentPath.Equals(rootPath)) - return string.Empty; - - return Path.Combine(parentPath, Constants.Names.DIRECTORY_ID_FILENAME); - } - - public static string MakeRelative(string fullPath, string basePath) - { - return PathHelpers.EnsureNoLeadingPathSeparator(Path.DirectorySeparatorChar + fullPath.Replace(basePath, string.Empty)); - } - - public static bool GetDirectoryId(string ciphertextChildPath, FileSystemSpecifics specifics, Span directoryId) - { - var directoryIdPath = GetDirectoryIdPathOfParent(ciphertextChildPath, specifics.ContentFolder.Id); - if (directoryIdPath is null) - throw new InvalidOperationException("Could not get Directory ID path."); - - // If the Directory ID path is root, return false - if (directoryIdPath == string.Empty) - return false; - - // Check directoryId size once we actually get to the reading - if (directoryId.Length < Constants.DIRECTORY_ID_SIZE) - throw new ArgumentException($"The {nameof(directoryId)} is of incorrect size: {directoryId.Length}."); - - var cachedId = specifics.DirectoryIdCache.CacheGet(directoryIdPath); - if (cachedId is not null) - { - cachedId.Buffer.CopyTo(directoryId); - return true; - } - - int read; - using var directoryIdStream = File.Open(directoryIdPath, FileMode.Open, FileAccess.Read, FileShare.Read); - if (specifics.DirectoryIdCache.IsAvailable) - { - cachedId = new(Constants.DIRECTORY_ID_SIZE); - read = directoryIdStream.Read(cachedId); - specifics.DirectoryIdCache.CacheSet(directoryIdPath, cachedId); - } - else - read = directoryIdStream.Read(directoryId); - - if (read < Constants.DIRECTORY_ID_SIZE) - throw new IOException($"The data inside Directory ID file is of incorrect size: {read}."); - - // The Directory ID is not empty - return true - return true; - } - } -} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs new file mode 100644 index 000000000..63805511c --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs @@ -0,0 +1,15 @@ +using System.IO; + +namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native +{ + /// + /// A set of file system path management helpers that only work in a native environment with unlimited file system access. + /// + public static partial class NativePathHelpers + { + public static string MakeRelative(string fullPath, string basePath) + { + return PathHelpers.EnsureNoLeadingPathSeparator(Path.DirectorySeparatorChar + fullPath.Replace(basePath, string.Empty)); + } + } +} 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 646878fde..48a823262 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 @@ -90,7 +90,7 @@ public static async Task RestoreAsync(IStorableChild recycleBinItem, IModifiable { // Destination folder is the same as the original destination // The same name could be used since the Directory IDs match - var ciphertextName = Path.ChangeExtension(await AbstractPathHelpers.EncryptNameAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken), Constants.Names.ENCRYPTED_FILE_EXTENSION); + 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); @@ -235,7 +235,7 @@ private static async Task GetAvailableDestinationNameAsync(IFolder ciphe do { var newPlaintextName = $"{nameWithoutExtension} ({suffix}){extension}"; - ciphertextName = Path.ChangeExtension(await AbstractPathHelpers.EncryptNameAsync(newPlaintextName, ciphertextDestinationFolder, specifics, cancellationToken), Constants.Names.ENCRYPTED_FILE_EXTENSION); + ciphertextName = await AbstractPathHelpers.EncryptNameAsync(newPlaintextName, ciphertextDestinationFolder, specifics, cancellationToken); existing = await ciphertextDestinationFolder.TryGetFirstByNameAsync(ciphertextName, cancellationToken); suffix++; } while (existing is not null); 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 9232d7683..810f3105e 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 @@ -27,9 +27,9 @@ public static void DeleteOrRecycle(string ciphertextPath, FileSystemSpecifics sp if (OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst()) { - var plaintextPath = NativePathHelpers.GetPlaintextPath(ciphertextPath, specifics); - var plaintextName = Path.GetFileName(plaintextPath) ?? string.Empty; - if (plaintextName == ".DS_Store" || plaintextName.StartsWith("._", StringComparison.Ordinal)) + var ciphertextParentPath = Path.GetDirectoryName(ciphertextPath); + var plaintextName = NativePathHelpers.DecryptName(Path.GetFileName(ciphertextPath), ciphertextParentPath ?? string.Empty, specifics); + if (plaintextName == ".DS_Store" || (plaintextName?.StartsWith("._", StringComparison.Ordinal) ?? false)) { // .DS_Store and Apple Double files are not supported by the recycle bin, delete immediately DeleteImmediately(ciphertextPath, storableType); @@ -60,12 +60,15 @@ public static void DeleteOrRecycle(string ciphertextPath, FileSystemSpecifics sp // Get source Directory ID var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security); - var directoryIdResult = NativePathHelpers.GetDirectoryId(ciphertextPath, specifics, directoryId); + var directoryIdResult = NativePathHelpers.GetDirectoryIdOfChild(ciphertextPath, specifics, directoryId); // Move and rename item var guid = Guid.NewGuid().ToString(); var destinationPath = Path.Combine(recycleBinPath, guid); - Directory.Move(ciphertextPath, destinationPath); + if (storableType == StorableType.Folder) + Directory.Move(ciphertextPath, destinationPath); + else + File.Move(ciphertextPath, destinationPath); // Create the configuration file using (var configurationStream = File.Create($"{destinationPath}.json")) @@ -117,7 +120,7 @@ public static void DeleteOrRecycle(string ciphertextPath, FileSystemSpecifics sp StorableType AlignStorableType(string path) { var type = storableType is StorableType.File or StorableType.Folder ? storableType : GetStorableType(path); - if (type == StorableType.None) + if (type is not (StorableType.File or StorableType.Folder)) throw new FileNotFoundException("The item could not be determined."); return type; diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFile.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFile.cs index 349fb30ec..043cb3b72 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFile.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFile.cs @@ -1,5 +1,4 @@ -using System; -using System.IO; +using System.IO; using System.Threading; using System.Threading.Tasks; using OwlCore.Storage; diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs index 956baf1f9..c035cd07b 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs @@ -3,7 +3,6 @@ using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Models; using System; -using System.IO; using System.Threading; using System.Threading.Tasks; @@ -57,22 +56,15 @@ protected async Task ValidateNameResultAsync(IStorableChild storable, C if (parentFolder is null) return null; - var ciphertextName = storable.Name; - var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, ciphertextName); + var decryptedName = await AbstractPathHelpers.DecryptNameAsync(storable.Name, parentFolder, specifics, cancellationToken); + if (!string.IsNullOrEmpty(decryptedName)) + return decryptedName; - try - { - var result = await AbstractPathHelpers.GetDirectoryIdAsync(parentFolder, specifics, directoryId, cancellationToken).ConfigureAwait(false); - return specifics.Security.NameCrypt.DecryptName(Path.GetFileNameWithoutExtension(ciphertextName), result ? directoryId : ReadOnlySpan.Empty); - } - catch (FileNotFoundException) - { - // We want to suppress FileNotFoundException that might be raised when the DirectoryID file is not found. - // This case should be already handled in the folder validator + // We want to suppress failures that might be raised when the Directory ID file is not found. + // This case should be already handled in the folder validator - // Return an empty string to prevent raising exceptions due to the name being null - return string.Empty; - } + // Return an empty string to prevent raising exceptions due to the name being null + return string.Empty; } } } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs index f3c27dd50..cdb2660fe 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs @@ -90,16 +90,9 @@ void AddFlags() } void AddMimeType() => row.Add(Document.ColumnMimeType, GetMimeForStorable(storable)); void AddDocumentId() => row.Add(Document.ColumnDocumentId, documentId); - void AddDisplayName() - { - if (string.IsNullOrEmpty(storable.Name)) - { - var safRoot = _rootCollection?.GetSafRootForRootId(documentId?.Split(':')[0] ?? string.Empty); - row.Add(Document.ColumnDisplayName, safRoot?.StorageRoot.Options.VolumeName ?? storable.Name); - } - else - row.Add(Document.ColumnDisplayName, storable.Name); - } + void AddDisplayName() => row.Add(Document.ColumnDisplayName, string.IsNullOrEmpty(storable.Name) + ? safRoot.StorageRoot.Options.VolumeName + : storable.Name); } private string? GetDocumentIdForStorable(IStorable storable, string? rootId) @@ -111,10 +104,9 @@ void AddDisplayName() if (safRoot is null) return null; - if (storable.Id == safRoot.StorageRoot.PlaintextRoot.Id) - return $"{safRoot.RootId}:"; - - return $"{safRoot.RootId}:{storable.Id}"; + return storable.Id == safRoot.StorageRoot.PlaintextRoot.Id + ? $"{safRoot.RootId}:" + : $"{safRoot.RootId}:{storable.Id}"; } private IStorable? GetStorableForDocumentId(string documentId) @@ -142,7 +134,7 @@ void AddDisplayName() if (string.IsNullOrEmpty(path)) return safRoot.StorageRoot.PlaintextRoot; - return safRoot.StorageRoot.PlaintextRoot.GetItemByRelativePathAsync(path).ConfigureAwait(false).GetAwaiter().GetResult(); + return SafetyHelpers.NoFailureResult(() => safRoot.StorageRoot.PlaintextRoot.GetItemByRelativePathAsync(path).ConfigureAwait(false).GetAwaiter().GetResult()); } private static string GetMimeForStorable(IStorable storable) diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs index d2d291711..f1a6682aa 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs @@ -53,9 +53,7 @@ public override bool OnCreate() rid = Constants.Android.Saf.IC_LOCK_LOCK; foreach (var item in _rootCollection?.Roots ?? Enumerable.Empty()) - { AddRoot(matrix, item, rid); - } return matrix; } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj b/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj index 7472887a0..dfc1bf271 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj +++ b/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj @@ -11,7 +11,7 @@ enable 17.0 - 28.0 + 29.0 diff --git a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs index 5d1ba42d7..12b123ffb 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs @@ -158,14 +158,13 @@ public override bool ReadDirectoryEntry( { // Add directory entries var directoryId = AbstractPathHelpers.AllocateDirectoryId(_specifics.Security, dirInfo.FullName); - foreach (var item in dirInfo.EnumerateFileSystemInfos(Pattern)) + var itemsEnumerable = _specifics.Security.NameCrypt is null ? dirInfo.EnumerateFileSystemInfos(Pattern) : dirInfo.EnumerateFileSystemInfos(); + foreach (var item in itemsEnumerable) { if (PathHelpers.IsCoreName(item.Name)) continue; - var plaintextName = NativePathHelpers.GetPlaintextPath(item.FullName, _specifics, directoryId); - plaintextName = plaintextName is not null ? Path.GetFileName(plaintextName) : null; - + var plaintextName = NativePathHelpers.DecryptName(item.Name, dirInfo.FullName, _specifics, directoryId); if (string.IsNullOrEmpty(plaintextName)) continue; diff --git a/src/Platforms/Directory.Packages.props b/src/Platforms/Directory.Packages.props index cdf9fc188..eeeede04f 100644 --- a/src/Platforms/Directory.Packages.props +++ b/src/Platforms/Directory.Packages.props @@ -2,7 +2,8 @@ - + + @@ -11,12 +12,12 @@ - + - + @@ -31,7 +32,7 @@ - + @@ -40,12 +41,10 @@ - - - - + + - + \ No newline at end of file diff --git a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs new file mode 100644 index 000000000..2f2d4bc78 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs @@ -0,0 +1,81 @@ +using System.Runtime.Serialization; +using System.Security.Cryptography; +using CliFx.Infrastructure; +using OwlCore.Storage; +using SecureFolderFS.Core; +using SecureFolderFS.Sdk.Helpers; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage.SystemStorageEx; +using SecureFolderFS.Storage.VirtualFileSystem; + +namespace SecureFolderFS.Cli; + +internal static class CliCommandHelpers +{ + public static IFolder GetVaultFolder(string path) + { + var fullPath = Path.GetFullPath(path); + return new SystemFolderEx(fullPath); + } + + public static Dictionary BuildMountOptions(string volumeName, bool readOnly, string? mountPoint) + { + var options = new Dictionary + { + [nameof(VirtualFileSystemOptions.VolumeName)] = FormattingHelpers.SanitizeVolumeName(volumeName, "Vault"), + [nameof(VirtualFileSystemOptions.IsReadOnly)] = readOnly + }; + + if (!string.IsNullOrWhiteSpace(mountPoint)) + options["MountPoint"] = Path.GetFullPath(mountPoint); + + return options; + } + + public static VaultOptions BuildVaultOptions(string[] methods, string vaultId, string? contentCipher, string? fileNameCipher) + { + return new VaultOptions + { + UnlockProcedure = new AuthenticationMethod(methods, null), + VaultId = vaultId, + ContentCipherId = string.IsNullOrWhiteSpace(contentCipher) + ? Core.Cryptography.Constants.CipherId.AES_GCM + : contentCipher, + FileNameCipherId = string.IsNullOrWhiteSpace(fileNameCipher) + ? Core.Cryptography.Constants.CipherId.AES_SIV + : fileNameCipher, + }; + } + + public static int HandleException(Exception ex, IConsole console, CliGlobalOptions options) + { + switch (ex) + { + case CryptographicException: + case FormatException: + CliOutput.Error(console, options, ex.Message); + return CliExitCodes.AuthenticationFailure; + + case FileNotFoundException: + case DirectoryNotFoundException: + case SerializationException: + CliOutput.Error(console, options, ex.Message); + return CliExitCodes.VaultUnreadable; + + case NotSupportedException: + CliOutput.Error(console, options, ex.Message); + return CliExitCodes.MountFailure; + + case InvalidOperationException: + case ArgumentException: + CliOutput.Error(console, options, ex.Message); + return CliExitCodes.BadArguments; + + default: + CliOutput.Error(console, options, ex.ToString()); + return CliExitCodes.GeneralError; + } + } +} + + diff --git a/src/Platforms/SecureFolderFS.Cli/CliExitCodes.cs b/src/Platforms/SecureFolderFS.Cli/CliExitCodes.cs new file mode 100644 index 000000000..403fa2808 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CliExitCodes.cs @@ -0,0 +1,13 @@ +namespace SecureFolderFS.Cli; + +internal static class CliExitCodes +{ + public const int Success = 0; + public const int GeneralError = 1; + public const int BadArguments = 2; + public const int AuthenticationFailure = 3; + public const int VaultUnreadable = 4; + public const int MountFailure = 5; + public const int MountStateError = 6; +} + diff --git a/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs b/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs new file mode 100644 index 000000000..197ceaf8c --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs @@ -0,0 +1,17 @@ +using CliFx.Attributes; +using CliFx.Infrastructure; + +namespace SecureFolderFS.Cli; + +public abstract class CliGlobalOptions : CliFx.ICommand +{ + [CommandOption("quiet", 'q', Description = "Suppress decorative/info output. Errors always go to stderr.")] + public bool Quiet { get; init; } + + [CommandOption("no-color", Description = "Disable ANSI color output.")] + public bool NoColor { get; init; } + + public abstract ValueTask ExecuteAsync(IConsole console); +} + + diff --git a/src/Platforms/SecureFolderFS.Cli/CliLifecycleHelper.cs b/src/Platforms/SecureFolderFS.Cli/CliLifecycleHelper.cs new file mode 100644 index 000000000..7e41ea5a2 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CliLifecycleHelper.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OwlCore.Storage; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.UI.Helpers; +using SecureFolderFS.UI.ServiceImplementation; +using AddService = Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions; + +namespace SecureFolderFS.Cli +{ + internal sealed class CliLifecycleHelper : BaseLifecycleHelper + { + /// + public override string AppDirectory { get; } = Directory.GetCurrentDirectory(); + + /// + public override void LogExceptionToFile(Exception? ex) + { + _ = ex; // No-op + } + + /// + protected override IServiceCollection ConfigureServices(IModifiableFolder settingsFolder) + { + return base.ConfigureServices(settingsFolder) + .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton); + } + + /// + protected override IServiceCollection WithLogging(IServiceCollection serviceCollection) + { + return serviceCollection + .AddLogging(builder => + { + builder.ClearProviders(); + builder.SetMinimumLevel(LogLevel.Information); + builder.AddSimpleConsole(options => + { + options.SingleLine = true; + options.TimestampFormat = "HH:mm:ss "; + }); + }); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Cli/CliOutput.cs b/src/Platforms/SecureFolderFS.Cli/CliOutput.cs new file mode 100644 index 000000000..43de10d27 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CliOutput.cs @@ -0,0 +1,53 @@ +using System.Text.RegularExpressions; +using CliFx.Infrastructure; + +namespace SecureFolderFS.Cli; + +internal static partial class CliOutput +{ + private const string Reset = "\u001b[0m"; + private const string Red = "\u001b[31m"; + private const string Yellow = "\u001b[33m"; + private const string Green = "\u001b[32m"; + + public static void Info(IConsole console, CliGlobalOptions options, string message) + { + if (options.Quiet) + return; + + console.Output.WriteLine(StripIfNeeded(options, message)); + } + + public static void Success(IConsole console, CliGlobalOptions options, string message) + { + if (options.Quiet) + return; + + var line = options.NoColor ? message : $"{Green}{message}{Reset}"; + console.Output.WriteLine(StripIfNeeded(options, line)); + } + + public static void Warning(IConsole console, CliGlobalOptions options, string message) + { + if (options.Quiet) + return; + + var line = options.NoColor ? $"warning: {message}" : $"{Yellow}warning:{Reset} {message}"; + console.Output.WriteLine(StripIfNeeded(options, line)); + } + + public static void Error(IConsole console, CliGlobalOptions options, string message) + { + var line = options.NoColor ? $"error: {message}" : $"{Red}error:{Reset} {message}"; + console.Error.WriteLine(StripIfNeeded(options, line)); + } + + public static string StripIfNeeded(CliGlobalOptions options, string value) + { + return options.NoColor ? AnsiRegex().Replace(value, string.Empty) : value; + } + + [GeneratedRegex("\\x1B\\[[0-9;]*[A-Za-z]")] + private static partial Regex AnsiRegex(); +} + diff --git a/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs b/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs new file mode 100644 index 000000000..c823da7f3 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace SecureFolderFS.Cli; + +internal static class CliTypeActivator +{ + public static object CreateInstance(IServiceProvider serviceProvider, Type type) + { + return ActivatorUtilities.CreateInstance(serviceProvider, type); + } +} + + diff --git a/src/Platforms/SecureFolderFS.Cli/CliVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Cli/CliVaultCredentialsService.cs new file mode 100644 index 000000000..ba7d70996 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CliVaultCredentialsService.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading; +using OwlCore.Storage; +using SecureFolderFS.Sdk.ViewModels.Controls.Authentication; +using SecureFolderFS.UI.ServiceImplementation; + +namespace SecureFolderFS.Cli; + +internal sealed class CliVaultCredentialsService : BaseVaultCredentialsService +{ + public override async IAsyncEnumerable GetLoginAsync(IFolder vaultFolder, CancellationToken cancellationToken = default) + { + _ = vaultFolder; + await Task.CompletedTask; + yield break; + } + + public override async IAsyncEnumerable GetCreationAsync(IFolder vaultFolder, string vaultId, + CancellationToken cancellationToken = default) + { + _ = vaultFolder; + _ = vaultId; + await Task.CompletedTask; + yield break; + } +} + diff --git a/src/Platforms/SecureFolderFS.Cli/CliVaultFileSystemService.cs b/src/Platforms/SecureFolderFS.Cli/CliVaultFileSystemService.cs new file mode 100644 index 000000000..c6c017695 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CliVaultFileSystemService.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Core.FUSE; +using SecureFolderFS.Core.WebDav; +using SecureFolderFS.Sdk.Enums; +using SecureFolderFS.Sdk.Models; +using SecureFolderFS.Sdk.ViewModels.Views.Wizard.DataSources; +using SecureFolderFS.Storage.VirtualFileSystem; +using SecureFolderFS.UI.ServiceImplementation; + +namespace SecureFolderFS.Cli; + +internal sealed class CliVaultFileSystemService : BaseVaultFileSystemService +{ + public override async IAsyncEnumerable GetFileSystemsAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + + // Keep ordering aligned with desktop targets: WebDAV first, then native adapters. + yield return new CliWebDavFileSystem(); + yield return new FuseFileSystem(); + +#if SFFS_WINDOWS_FS + yield return new SecureFolderFS.Core.WinFsp.WinFspFileSystem(); + yield return new SecureFolderFS.Core.Dokany.DokanyFileSystem(); +#endif + } + + public override async IAsyncEnumerable GetSourcesAsync(IVaultCollectionModel vaultCollectionModel, NewVaultMode mode, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = vaultCollectionModel; + _ = mode; + await Task.CompletedTask; + yield break; + } +} + diff --git a/src/Platforms/SecureFolderFS.Cli/CliWebDavFileSystem.cs b/src/Platforms/SecureFolderFS.Cli/CliWebDavFileSystem.cs new file mode 100644 index 000000000..06f698cc9 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CliWebDavFileSystem.cs @@ -0,0 +1,31 @@ +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using NWebDav.Server.Dispatching; +using OwlCore.Storage.Memory; +using SecureFolderFS.Core.FileSystem; +using SecureFolderFS.Core.FileSystem.Storage; +using SecureFolderFS.Core.WebDav; +using SecureFolderFS.Core.WebDav.AppModels; +using SecureFolderFS.Storage.VirtualFileSystem; + +namespace SecureFolderFS.Cli; + +internal sealed class CliWebDavFileSystem : WebDavFileSystem +{ + protected override async Task MountAsync(FileSystemSpecifics specifics, HttpListener listener, WebDavOptions options, + IRequestDispatcher requestDispatcher, CancellationToken cancellationToken) + { + await Task.CompletedTask; + + var remotePath = $"{options.Protocol}://{options.Domain}:{options.Port}/"; + var webDavWrapper = new WebDavWrapper(listener, requestDispatcher, remotePath); + webDavWrapper.StartFileSystem(); + + var virtualizedRoot = new MemoryFolder(remotePath, options.VolumeName); + var plaintextRoot = new CryptoFolder(Path.DirectorySeparatorChar.ToString(), specifics.ContentFolder, specifics); + return new WebDavVfsRoot(webDavWrapper, virtualizedRoot, plaintextRoot, specifics); + } +} + diff --git a/src/Platforms/SecureFolderFS.Cli/CommandOptions.cs b/src/Platforms/SecureFolderFS.Cli/CommandOptions.cs new file mode 100644 index 000000000..498b757d6 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CommandOptions.cs @@ -0,0 +1,55 @@ +using CliFx.Attributes; + +namespace SecureFolderFS.Cli; + +public abstract partial class VaultAuthOptions : CliGlobalOptions +{ + [CommandOption("password", Description = "Prompt for password interactively (masked input).")] + public bool Password { get; init; } + + [CommandOption("password-stdin", Description = "Read password from stdin.")] + public bool PasswordStdin { get; init; } + + [CommandOption("keyfile", Description = "Use an existing keyfile path.")] + public string? KeyFile { get; init; } + + [CommandOption("twofa-password", Description = "Prompt for second-factor password interactively.")] + public bool TwoFactorPassword { get; init; } + + [CommandOption("twofa-password-stdin", Description = "Read second-factor password from stdin.")] + public bool TwoFactorPasswordStdin { get; init; } + + [CommandOption("twofa-keyfile", Description = "Use an existing second-factor keyfile path.")] + public string? TwoFactorKeyFile { get; init; } + + [CommandOption("recovery-key", Description = "Unlock via recovery key instead of credentials.")] + public string? RecoveryKey { get; init; } +} + +public abstract class CreateAuthOptions : CliGlobalOptions +{ + [CommandOption("password", Description = "Prompt for password interactively (masked input).")] + public bool Password { get; init; } + + [CommandOption("password-stdin", Description = "Read password from stdin.")] + public bool PasswordStdin { get; init; } + + [CommandOption("keyfile", Description = "Use an existing keyfile path.")] + public string? KeyFile { get; init; } + + [CommandOption("keyfile-generate", Description = "Generate a new keyfile and write it to this path.")] + public string? KeyFileGenerate { get; init; } + + [CommandOption("twofa-password", Description = "Prompt for second-factor password interactively.")] + public bool TwoFactorPassword { get; init; } + + [CommandOption("twofa-password-stdin", Description = "Read second-factor password from stdin.")] + public bool TwoFactorPasswordStdin { get; init; } + + [CommandOption("twofa-keyfile", Description = "Use an existing second-factor keyfile path.")] + public string? TwoFactorKeyFile { get; init; } + + [CommandOption("twofa-keyfile-generate", Description = "Generate a second-factor keyfile and write it to this path.")] + public string? TwoFactorKeyFileGenerate { get; init; } +} + diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/CredsAddCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/CredsAddCommand.cs new file mode 100644 index 000000000..af5dbcd8f --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/Commands/CredsAddCommand.cs @@ -0,0 +1,121 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; +using static SecureFolderFS.Core.Constants.Vault.Authentication; + +namespace SecureFolderFS.Cli.Commands; + +[Command("creds add", Description = "Add a second-factor credential to an existing vault.")] +public sealed partial class CredsAddCommand(IVaultManagerService vaultManagerService, IVaultService vaultService, CredentialReader credentialReader) + : VaultAuthOptions, ICommand +{ + [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")] + public required string Path { get; init; } + + [CommandOption("twofa-password", Description = "Prompt for a new second-factor password.")] + public bool NewTwoFactorPassword { get; init; } + + [CommandOption("twofa-password-stdin", Description = "Read new second-factor password from stdin.")] + public bool NewTwoFactorPasswordStdin { get; init; } + + [CommandOption("twofa-keyfile", Description = "Use an existing second-factor keyfile.")] + public string? NewTwoFactorKeyFile { get; init; } + + [CommandOption("twofa-keyfile-generate", Description = "Generate a new second-factor keyfile.")] + public string? NewTwoFactorKeyFileGenerate { get; init; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + if (!string.IsNullOrWhiteSpace(RecoveryKey)) + { + // TODO: verify - recovery-key based re-auth for factor-preserving updates is currently not wired. + CliOutput.Error(console, this, "--recovery-key is not yet supported for creds add."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + var vaultFolder = CliCommandHelpers.GetVaultFolder(Path); + var vaultOptions = await vaultService.GetVaultOptionsAsync(vaultFolder); + if (vaultOptions.UnlockProcedure.Methods.Length > 1) + { + CliOutput.Error(console, this, "The vault already has a second factor."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + using var currentAuth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader); + if (currentAuth is null) + { + CliOutput.Error(console, this, "Current credentials are required."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + using var unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, currentAuth.Passkey); + var secondPassword = await credentialReader.ReadPasswordAsync(NewTwoFactorPassword, NewTwoFactorPasswordStdin, + "New second-factor password: ", null); + + IDisposable? secondFactor = null; + string? secondMethod = null; + if (!string.IsNullOrWhiteSpace(secondPassword)) + { + secondFactor = new DisposablePassword(secondPassword); + secondMethod = AUTH_PASSWORD; + } + else if (!string.IsNullOrWhiteSpace(NewTwoFactorKeyFileGenerate)) + { + if (string.IsNullOrWhiteSpace(vaultOptions.VaultId)) + throw new InvalidOperationException("Vault ID is unavailable."); + + secondFactor = (IDisposable)await credentialReader.GenerateKeyFileAsync(NewTwoFactorKeyFileGenerate, vaultOptions.VaultId); + secondMethod = AUTH_KEYFILE; + } + else if (!string.IsNullOrWhiteSpace(NewTwoFactorKeyFile)) + { + secondFactor = (IDisposable)await credentialReader.ReadKeyFileAsKeyAsync(NewTwoFactorKeyFile); + secondMethod = AUTH_KEYFILE; + } + + if (secondFactor is not IKeyUsage secondKey || secondMethod is null) + { + CliOutput.Error(console, this, "A new second-factor credential is required."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + using (secondFactor) + { + var sequence = new KeySequence(); + sequence.Add(currentAuth.Passkey); + sequence.Add(secondKey); + + using (sequence) + { + var updatedOptions = vaultOptions with + { + UnlockProcedure = new AuthenticationMethod([vaultOptions.UnlockProcedure.Methods[0], secondMethod], null) + }; + + await vaultManagerService.ModifyAuthenticationAsync(vaultFolder, unlockContract, sequence, updatedOptions); + } + } + + CliOutput.Success(console, this, "Second-factor credential added."); + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } +} + + + + + diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/CredsChangeCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/CredsChangeCommand.cs new file mode 100644 index 000000000..5ecde6cb7 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/Commands/CredsChangeCommand.cs @@ -0,0 +1,130 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; +using static SecureFolderFS.Core.Constants.Vault.Authentication; + +namespace SecureFolderFS.Cli.Commands; + +[Command("creds change", Description = "Replace an authentication factor.")] +public sealed partial class CredsChangeCommand(IVaultManagerService vaultManagerService, IVaultService vaultService, CredentialReader credentialReader) + : VaultAuthOptions, ICommand +{ + [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")] + public required string Path { get; init; } + + [CommandOption("new-password", Description = "Prompt for the replacement password.")] + public bool NewPassword { get; init; } + + [CommandOption("new-password-stdin", Description = "Read replacement password from stdin.")] + public bool NewPasswordStdin { get; init; } + + [CommandOption("new-keyfile-generate", Description = "Generate a replacement keyfile at this path.")] + public string? NewKeyFileGenerate { get; init; } + + [CommandOption("factor", Description = "Factor to replace: primary|2fa.")] + public string Factor { get; init; } = "primary"; + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + if (!string.IsNullOrWhiteSpace(RecoveryKey)) + { + // TODO: verify - recovery-key based re-auth for factor-preserving updates is currently not wired. + CliOutput.Error(console, this, "--recovery-key is not yet supported for creds change."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + var vaultFolder = CliCommandHelpers.GetVaultFolder(Path); + var vaultOptions = await vaultService.GetVaultOptionsAsync(vaultFolder); + + using var auth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader); + if (auth is null) + { + CliOutput.Error(console, this, "Current credentials are required."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + using var unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, auth.Passkey); + + var replacementPassword = await credentialReader.ReadPasswordAsync(NewPassword, NewPasswordStdin, "New password: ", null); + IKeyUsage? replacement = null; + string? replacementMethod = null; + + if (!string.IsNullOrWhiteSpace(replacementPassword)) + { + replacement = new DisposablePassword(replacementPassword); + replacementMethod = AUTH_PASSWORD; + } + else if (!string.IsNullOrWhiteSpace(NewKeyFileGenerate)) + { + if (string.IsNullOrWhiteSpace(vaultOptions.VaultId)) + throw new InvalidOperationException("Vault ID is unavailable."); + + replacement = await credentialReader.GenerateKeyFileAsync(NewKeyFileGenerate, vaultOptions.VaultId); + replacementMethod = AUTH_KEYFILE; + } + + if (replacement is null || replacementMethod is null) + { + CliOutput.Error(console, this, "Provide --new-password/--new-password-stdin or --new-keyfile-generate."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + using (replacement) + { + var methods = vaultOptions.UnlockProcedure.Methods.ToArray(); + var replaceIndex = string.Equals(Factor, "2fa", StringComparison.OrdinalIgnoreCase) ? 1 : 0; + if (replaceIndex >= methods.Length) + { + CliOutput.Error(console, this, $"Factor '{Factor}' is not configured on this vault."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + methods[replaceIndex] = replacementMethod; + var updatedOptions = vaultOptions with + { + UnlockProcedure = new AuthenticationMethod(methods, null) + }; + + IKeyUsage updatedPasskey = replacement; + if (methods.Length > 1) + { + if (auth.Passkey is not KeySequence authSequence || authSequence.Keys.Count < methods.Length) + throw new InvalidOperationException("Both authentication factors must be supplied for this vault."); + + var next = new KeySequence(); + for (var i = 0; i < methods.Length; i++) + { + next.Add(i == replaceIndex ? replacement : authSequence.Keys.ElementAt(i)); + } + + updatedPasskey = next; + } + + using (updatedPasskey) + { + await vaultManagerService.ModifyAuthenticationAsync(vaultFolder, unlockContract, updatedPasskey, updatedOptions); + } + } + + CliOutput.Success(console, this, $"Credential factor '{Factor}' updated."); + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } +} + + + + diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/CredsRemoveCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/CredsRemoveCommand.cs new file mode 100644 index 000000000..7a3d2c949 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/Commands/CredsRemoveCommand.cs @@ -0,0 +1,71 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; + +namespace SecureFolderFS.Cli.Commands; + +[Command("creds remove", Description = "Remove the second-factor credential from a vault.")] +public sealed partial class CredsRemoveCommand(IVaultManagerService vaultManagerService, IVaultService vaultService, CredentialReader credentialReader) + : VaultAuthOptions, ICommand +{ + [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")] + public required string Path { get; init; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + if (!string.IsNullOrWhiteSpace(RecoveryKey)) + { + // TODO: verify - recovery-key based re-auth for factor-preserving updates is currently not wired. + CliOutput.Error(console, this, "--recovery-key is not yet supported for creds remove."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + var vaultFolder = CliCommandHelpers.GetVaultFolder(Path); + var vaultOptions = await vaultService.GetVaultOptionsAsync(vaultFolder); + if (vaultOptions.UnlockProcedure.Methods.Length <= 1) + { + CliOutput.Error(console, this, "The vault has no second factor to remove."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + using var auth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader); + if (auth is null) + { + CliOutput.Error(console, this, "Current credentials are required."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + using var unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, auth.Passkey); + var primaryOnlyOptions = vaultOptions with + { + UnlockProcedure = new AuthenticationMethod([vaultOptions.UnlockProcedure.Methods[0]], null) + }; + + // TODO: verify - assumes the first item in the current auth chain corresponds to the primary factor. + IKeyUsage newPrimary = auth.Passkey; + if (auth.Passkey is KeySequence sequence && sequence.Keys.FirstOrDefault() is IKeyUsage key) + newPrimary = key; + + await vaultManagerService.ModifyAuthenticationAsync(vaultFolder, unlockContract, newPrimary, primaryOnlyOptions); + + CliOutput.Success(console, this, "Second-factor credential removed."); + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } +} + + + + diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultCreateCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultCreateCommand.cs new file mode 100644 index 000000000..a39c45002 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultCreateCommand.cs @@ -0,0 +1,72 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using OwlCore.Storage; +using SecureFolderFS.Core; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Storage.Extensions; + +namespace SecureFolderFS.Cli.Commands; + +[Command("vault create", Description = "Create a new vault at the specified path.")] +public sealed partial class VaultCreateCommand(IVaultManagerService vaultManagerService, CredentialReader credentialReader) : CreateAuthOptions, ICommand +{ + [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")] + public required string Path { get; init; } + + [CommandOption("name", Description = "Display name for the vault.")] + public string? Name { get; init; } + + [CommandOption("content-cipher", Description = "Content cipher id (for example: AES-GCM, XChaCha20-Poly1305, none).")] + public string? ContentCipher { get; init; } + + [CommandOption("filename-cipher", Description = "Filename cipher id (for example: AES-SIV, none).")] + public string? FileNameCipher { get; init; } + + [CommandOption("overwrite", Description = "Allow creation when a vault already exists.")] + public bool Overwrite { get; init; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + var vaultFolder = CliCommandHelpers.GetVaultFolder(Path); + var modifiableFolder = vaultFolder as IModifiableFolder; + if (modifiableFolder is null) + { + CliOutput.Error(console, this, "The vault folder is not writable."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + var existingConfig = await vaultFolder.TryGetFirstByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME); + if (existingConfig is not null && !Overwrite) + { + CliOutput.Error(console, this, "A vault already exists at this path. Use --overwrite to continue."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + var vaultId = Guid.NewGuid().ToString("N"); + using var auth = await CredentialResolver.ResolveCreateAuthenticationAsync(this, credentialReader, vaultId); + if (auth is null) + { + CliOutput.Error(console, this, "At least one primary credential is required."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + var vaultOptions = CliCommandHelpers.BuildVaultOptions(auth.Methods, vaultId, ContentCipher, FileNameCipher); + using var recoveryKey = await vaultManagerService.CreateAsync(vaultFolder, auth.Passkey, vaultOptions); + + var displayName = string.IsNullOrWhiteSpace(Name) ? System.IO.Path.GetFileName(vaultFolder.Id) : Name; + CliOutput.Success(console, this, $"Vault created: {displayName}"); + console.Output.WriteLine($"Recovery key: {recoveryKey}"); + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultInfoCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultInfoCommand.cs new file mode 100644 index 000000000..a941274dc --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultInfoCommand.cs @@ -0,0 +1,47 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Storage.SystemStorageEx; + +namespace SecureFolderFS.Cli.Commands +{ + [Command("vault info", Description = "Read vault metadata without unlocking.")] + public sealed partial class VaultInfoCommand(IVaultService vaultService) : CliGlobalOptions, ICommand + { + [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")] + public required string Path { get; init; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + var vaultFolder = new SystemFolderEx(Path); + var options = await vaultService.GetVaultOptionsAsync(vaultFolder); + + if (!Quiet) + { + console.Output.WriteLine($"Vault path: {vaultFolder.Id}"); + console.Output.WriteLine($"Version: {options.Version}"); + console.Output.WriteLine($"Vault ID: {options.VaultId}"); + console.Output.WriteLine($"Content cipher: {options.ContentCipherId}"); + console.Output.WriteLine($"Filename cipher: {options.FileNameCipherId}"); + console.Output.WriteLine($"Name encoding: {options.NameEncodingId}"); + console.Output.WriteLine( + $"Authentication methods: {string.Join(", ", options.UnlockProcedure.Methods)}"); + console.Output.WriteLine($"2FA configured: {options.UnlockProcedure.Methods.Length > 1}"); + } + + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } + } +} + + + + diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultMountCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultMountCommand.cs new file mode 100644 index 000000000..8d68c75a2 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultMountCommand.cs @@ -0,0 +1,164 @@ +using System.Runtime.InteropServices; +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using SecureFolderFS.Core.WebDav; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Helpers; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Storage.Enums; +using SecureFolderFS.Storage.VirtualFileSystem; + +namespace SecureFolderFS.Cli.Commands; + +[Command("vault mount", Description = "Unlock a vault and mount it as a live filesystem.")] +public sealed partial class VaultMountCommand(IVaultManagerService vaultManagerService, IVaultFileSystemService vaultFileSystemService, CredentialReader credentialReader) + : VaultAuthOptions, ICommand +{ + [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")] + public required string Path { get; init; } + + [CommandOption("mount-point", Description = "Filesystem mount point.")] + public string? MountPoint { get; init; } + + [CommandOption("fs", Description = "Filesystem adapter: auto|webdav|dokany|winfsp.")] + public string FileSystem { get; init; } = "auto"; + + [CommandOption("read-only", Description = "Mount in read-only mode.")] + public bool ReadOnly { get; init; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + IDisposable? unlockContract = null; + IVfsRoot? mountedRoot = null; + + try + { + var usingRecovery = !string.IsNullOrWhiteSpace(RecoveryKey) || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SFFS_RECOVERY_KEY")); + if (usingRecovery && (Password || PasswordStdin || !string.IsNullOrWhiteSpace(KeyFile) || TwoFactorPassword || TwoFactorPasswordStdin || !string.IsNullOrWhiteSpace(TwoFactorKeyFile))) + { + CliOutput.Error(console, this, "--recovery-key is mutually exclusive with credential flags."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + var vaultFolder = CliCommandHelpers.GetVaultFolder(Path); + + if (usingRecovery) + { + var recovery = credentialReader.ReadRecoveryKey(RecoveryKey, Environment.GetEnvironmentVariable("SFFS_RECOVERY_KEY")); + if (string.IsNullOrWhiteSpace(recovery)) + { + CliOutput.Error(console, this, "No recovery key provided."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + unlockContract = await vaultManagerService.RecoverAsync(vaultFolder, recovery); + } + else + { + using var auth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader); + if (auth is null) + { + CliOutput.Error(console, this, "No credentials provided. Use password/keyfile flags or environment variables."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, auth.Passkey); + } + + var fileSystem = await ResolveFileSystemAsync(vaultFileSystemService, FileSystem, console); + var contentFolder = await VaultHelpers.GetContentFolderAsync(vaultFolder); + var mountOptions = CliCommandHelpers.BuildMountOptions(vaultFolder.Name, ReadOnly, MountPoint); + mountedRoot = await fileSystem.MountAsync(contentFolder, unlockContract, mountOptions); + + CliOutput.Success(console, this, $"Mounted using {fileSystem.Name}: {mountedRoot.VirtualizedRoot.Id}"); + + var shutdownSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ConsoleCancelEventHandler onCancel = (_, args) => + { + args.Cancel = true; + shutdownSignal.TrySetResult(); + }; + + EventHandler onExit = (_, _) => shutdownSignal.TrySetResult(); + Console.CancelKeyPress += onCancel; + AppDomain.CurrentDomain.ProcessExit += onExit; + + IDisposable? sigTerm = null; + try + { +#if NET7_0_OR_GREATER + sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => shutdownSignal.TrySetResult()); +#endif + await shutdownSignal.Task; + } + finally + { + Console.CancelKeyPress -= onCancel; + AppDomain.CurrentDomain.ProcessExit -= onExit; + sigTerm?.Dispose(); + } + + await mountedRoot.DisposeAsync(); + unlockContract.Dispose(); + mountedRoot = null; + unlockContract = null; + Environment.ExitCode = CliExitCodes.Success; + } + catch (NotSupportedException ex) + { + CliOutput.Error(console, this, ex.Message); + Environment.ExitCode = CliExitCodes.MountFailure; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + finally + { + if (mountedRoot is not null) + await mountedRoot.DisposeAsync(); + + unlockContract?.Dispose(); + } + } + + private async Task ResolveFileSystemAsync(IVaultFileSystemService service, string requested, IConsole console) + { + if (string.Equals(requested, "auto", StringComparison.OrdinalIgnoreCase)) + return await service.GetBestFileSystemAsync(); + + var wantedId = requested.ToLowerInvariant() switch + { + "webdav" => Constants.FileSystem.FS_ID, + "dokany" => "DOKANY", + "winfsp" => "WINFSP", + _ => string.Empty + }; + + if (string.IsNullOrEmpty(wantedId)) + throw new ArgumentException($"Unknown filesystem '{requested}'."); + + await foreach (var candidate in service.GetFileSystemsAsync()) + { + if (!string.Equals(candidate.Id, wantedId, StringComparison.OrdinalIgnoreCase)) + continue; + + if (await candidate.GetStatusAsync() == FileSystemAvailability.Available) + return candidate; + + CliOutput.Warning(console, this, $"Adapter '{requested}' is unavailable. Falling back to auto."); + return await service.GetBestFileSystemAsync(); + } + + CliOutput.Warning(console, this, $"Adapter '{requested}' not found. Falling back to auto."); + return await service.GetBestFileSystemAsync(); + } +} + + + + diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultRunCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultRunCommand.cs new file mode 100644 index 000000000..d724a0fe2 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultRunCommand.cs @@ -0,0 +1,146 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using OwlCore.Storage; +using SecureFolderFS.Sdk.Helpers; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Storage.Extensions; +using SecureFolderFS.Storage.SystemStorageEx; +using SecureFolderFS.Storage.VirtualFileSystem; + +namespace SecureFolderFS.Cli.Commands; + +[Command("vault run", Description = "Unlock a vault, perform one file operation, then lock.")] +public sealed partial class VaultRunCommand(IVaultManagerService vaultManagerService, IVaultFileSystemService vaultFileSystemService, CredentialReader credentialReader) + : VaultAuthOptions, ICommand +{ + [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")] + public required string Path { get; init; } + + [CommandOption("read", Description = "Read a file from the vault.")] + public string? ReadPath { get; init; } + + [CommandOption("write", Description = "Write stdin to a file in the vault.")] + public string? WritePath { get; init; } + + [CommandOption("out", Description = "When reading, write output to local file instead of stdout.")] + public string? OutPath { get; init; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + IDisposable? unlockContract = null; + IVfsRoot? localRoot = null; + + try + { + var hasRead = !string.IsNullOrWhiteSpace(ReadPath); + var hasWrite = !string.IsNullOrWhiteSpace(WritePath); + if (hasRead == hasWrite) + { + CliOutput.Error(console, this, "Exactly one of --read or --write must be specified."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + var vaultFolder = new SystemFolderEx(Path); + var recovery = credentialReader.ReadRecoveryKey(RecoveryKey, Environment.GetEnvironmentVariable("SFFS_RECOVERY_KEY")); + + if (!string.IsNullOrWhiteSpace(recovery)) + { + unlockContract = await vaultManagerService.RecoverAsync(vaultFolder, recovery); + } + else + { + using var auth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader); + if (auth is null) + { + CliOutput.Error(console, this, "No credentials provided. Use password/keyfile flags or environment variables."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, auth.Passkey); + } + + var localFileSystem = await vaultFileSystemService.GetLocalFileSystemAsync(); + var contentFolder = await VaultHelpers.GetContentFolderAsync(vaultFolder); + var options = CliCommandHelpers.BuildMountOptions(vaultFolder.Name, readOnly: false, mountPoint: null); + localRoot = await localFileSystem.MountAsync(contentFolder, unlockContract, options); + + if (hasRead) + await ReadFromVaultAsync(localRoot.PlaintextRoot, ReadPath!, OutPath); + else + await WriteToVaultAsync(localRoot.PlaintextRoot, WritePath!); + + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + finally + { + if (localRoot is not null) + await localRoot.DisposeAsync(); + + unlockContract?.Dispose(); + } + } + + private static async Task ReadFromVaultAsync(IFolder root, string path, string? outputPath) + { + var item = await root.GetItemByRelativePathAsync(path); + if (item is not IFile file) + throw new FileNotFoundException($"Vault file not found: {path}"); + + await using var input = await file.OpenReadAsync(); + if (string.IsNullOrWhiteSpace(outputPath)) + { + await input.CopyToAsync(Console.OpenStandardOutput()); + return; + } + + var fullPath = System.IO.Path.GetFullPath(outputPath); + var parent = System.IO.Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(parent)) + Directory.CreateDirectory(parent); + + await using var output = File.Open(fullPath, FileMode.Create, FileAccess.Write, FileShare.None); + await input.CopyToAsync(output); + } + + private static async Task WriteToVaultAsync(IFolder root, string path) + { + if (root is not IModifiableFolder modifiableRoot) + throw new InvalidOperationException("The vault is not writable."); + + var normalized = path.Replace('\\', '/').Trim('/'); + var parts = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + throw new ArgumentException("Invalid vault path."); + + IModifiableFolder current = modifiableRoot; + for (var i = 0; i < parts.Length - 1; i++) + { + var existing = await current.TryGetFolderByNameAsync(parts[i]); + if (existing is IModifiableFolder existingFolder) + { + current = existingFolder; + continue; + } + + var created = await current.CreateFolderAsync(parts[i], false); + current = created as IModifiableFolder ?? throw new InvalidOperationException("Created folder is not modifiable."); + } + + var destination = await current.CreateFileAsync(parts[^1], overwrite: true); + await using var destinationStream = await destination.OpenWriteAsync(); + await using var stdin = Console.OpenStandardInput(); + await stdin.CopyToAsync(destinationStream); + } +} + + + + + diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultShellCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultShellCommand.cs new file mode 100644 index 000000000..b681a6ece --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultShellCommand.cs @@ -0,0 +1,361 @@ +using System.Text; +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using OwlCore.Storage; +using SecureFolderFS.Sdk.Helpers; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Storage.Extensions; +using SecureFolderFS.Storage.VirtualFileSystem; + +namespace SecureFolderFS.Cli.Commands; + +[Command("vault shell", Description = "Unlock a vault and enter an interactive shell.")] +public sealed partial class VaultShellCommand(IVaultManagerService vaultManagerService, IVaultFileSystemService vaultFileSystemService, CredentialReader credentialReader) + : VaultAuthOptions, ICommand +{ + [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")] + public required string Path { get; init; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + IDisposable? unlockContract = null; + IVfsRoot? localRoot = null; + + try + { + var vaultFolder = CliCommandHelpers.GetVaultFolder(Path); + var recovery = credentialReader.ReadRecoveryKey(RecoveryKey, Environment.GetEnvironmentVariable("SFFS_RECOVERY_KEY")); + if (!string.IsNullOrWhiteSpace(recovery)) + { + unlockContract = await vaultManagerService.RecoverAsync(vaultFolder, recovery); + } + else + { + using var auth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader); + if (auth is null) + { + CliOutput.Error(console, this, "No credentials provided. Use password/keyfile flags or environment variables."); + Environment.ExitCode = CliExitCodes.BadArguments; + return; + } + + unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, auth.Passkey); + } + + var localFileSystem = await vaultFileSystemService.GetLocalFileSystemAsync(); + var contentFolder = await VaultHelpers.GetContentFolderAsync(vaultFolder); + var options = CliCommandHelpers.BuildMountOptions(vaultFolder.Name, readOnly: false, mountPoint: null); + localRoot = await localFileSystem.MountAsync(contentFolder, unlockContract, options); + + await RunShellAsync(localRoot.PlaintextRoot); + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + finally + { + if (localRoot is not null) + await localRoot.DisposeAsync(); + + unlockContract?.Dispose(); + } + } + + private static async Task RunShellAsync(IFolder root) + { + if (root is not IModifiableFolder modifiableRoot) + throw new InvalidOperationException("The vault is not writable."); + + var current = root; + + while (true) + { + Console.Write($"sffs:{current.Id}> "); + var line = await Console.In.ReadLineAsync(); + if (string.IsNullOrWhiteSpace(line)) + continue; + + var args = Tokenize(line); + var command = args[0].ToLowerInvariant(); + + switch (command) + { + case "exit": + return; + + case "help": + Console.WriteLine("ls [path], cd , cat , cp , mv , rm [-r], mkdir , pwd, help, exit"); + break; + + case "pwd": + Console.WriteLine(current.Id); + break; + + case "ls": + { + var target = args.Count > 1 ? await ResolveFolderAsync(root, current, args[1]) : current; + await foreach (var item in target.GetItemsAsync()) + Console.WriteLine($"{item.Name}\t\t{(item is IFolder ? "[Directory]" : "[File]")}"); + break; + } + + case "cd": + if (args.Count < 2) + throw new ArgumentException("cd requires a path."); + + current = await ResolveFolderAsync(root, current, args[1]); + break; + + case "cat": + if (args.Count < 2) + throw new ArgumentException("cat requires a path."); + + var catItem = await ResolveStorableAsync(root, current, args[1]); + if (catItem is not IFile catFile) + throw new FileNotFoundException("The selected path is not a file."); + + await using (var input = await catFile.OpenReadAsync()) + { + await input.CopyToAsync(Console.OpenStandardOutput()); + } + Console.WriteLine(); + break; + + case "mkdir": + if (args.Count < 2) + throw new ArgumentException("mkdir requires a path."); + + _ = await EnsureFolderAsync(modifiableRoot, current, args[1]); + break; + + case "rm": + if (args.Count < 2) + throw new ArgumentException("rm requires a path."); + + var recursive = args.Count > 2 && string.Equals(args[2], "-r", StringComparison.OrdinalIgnoreCase); + var toDelete = await ResolveStorableAsync(root, current, args[1]); + if (toDelete is IFolder && !recursive) + throw new InvalidOperationException("Use -r to remove directories."); + + if (toDelete is IStorableChild child) + await modifiableRoot.DeleteAsync(child, deleteImmediately: true); + break; + + case "cp": + if (args.Count < 3) + throw new ArgumentException("cp requires source and destination."); + + await CopyAsync(modifiableRoot, root, current, args[1], args[2]); + break; + + case "mv": + if (args.Count < 3) + throw new ArgumentException("mv requires source and destination."); + + await MoveAsync(modifiableRoot, root, current, args[1], args[2]); + break; + + default: + Console.WriteLine($"Unknown command '{command}'. Type 'help'."); + break; + } + } + } + + private static async Task CopyAsync(IModifiableFolder modifiableRoot, IFolder root, IFolder current, string src, string dst) + { + var srcLocal = TryParseLocal(src, out var srcLocalPath); + var dstLocal = TryParseLocal(dst, out var dstLocalPath); + + if (srcLocal && dstLocal) + { + File.Copy(srcLocalPath!, dstLocalPath!, overwrite: true); + return; + } + + if (srcLocal) + { + var destinationFile = await CreateOrGetVaultFileAsync(modifiableRoot, current, dst); + await using var inStream = File.OpenRead(srcLocalPath!); + await using var outStream = await destinationFile.OpenWriteAsync(); + await inStream.CopyToAsync(outStream); + return; + } + + var sourceItem = await ResolveStorableAsync(root, current, src); + if (dstLocal) + { + if (sourceItem is not IFile sourceFile) + throw new InvalidOperationException("Only file copies are supported for vault -> local."); + + var parent = System.IO.Path.GetDirectoryName(dstLocalPath!); + if (!string.IsNullOrWhiteSpace(parent)) + Directory.CreateDirectory(parent); + + await using var inStream = await sourceFile.OpenReadAsync(); + await using var outStream = File.Open(dstLocalPath!, FileMode.Create, FileAccess.Write, FileShare.None); + await inStream.CopyToAsync(outStream); + return; + } + + var (destinationFolder, destinationName) = await ResolveDestinationFolderAsync(modifiableRoot, current, dst); + _ = await destinationFolder.CreateCopyOfStorableAsync((IStorable)sourceItem, overwrite: true, destinationName, reporter: null); + } + + private static async Task MoveAsync(IModifiableFolder modifiableRoot, IFolder root, IFolder current, string src, string dst) + { + var srcLocal = TryParseLocal(src, out var srcLocalPath); + var dstLocal = TryParseLocal(dst, out var dstLocalPath); + + if (srcLocal && dstLocal) + { + File.Move(srcLocalPath!, dstLocalPath!, overwrite: true); + return; + } + + if (srcLocal || dstLocal) + { + // TODO: verify - cross-provider mv semantics are currently copy+delete. + await CopyAsync(modifiableRoot, root, current, src, dst); + if (srcLocal) + File.Delete(srcLocalPath!); + else if (await ResolveStorableAsync(root, current, src) is IStorableChild child) + await modifiableRoot.DeleteAsync(child, deleteImmediately: true); + + return; + } + + var sourceItem = await ResolveStorableAsync(root, current, src); + if (sourceItem is not IStorableChild sourceChild) + throw new InvalidOperationException("Source item is not movable."); + + var sourceParent = await ResolveParentFolderAsync(modifiableRoot, root, current, src); + var (destinationFolder, destinationName) = await ResolveDestinationFolderAsync(modifiableRoot, current, dst); + _ = await destinationFolder.MoveStorableFromAsync(sourceChild, sourceParent, overwrite: true, destinationName, reporter: null); + } + + private static async Task ResolveParentFolderAsync(IModifiableFolder root, IFolder absoluteRoot, IFolder current, string path) + { + var normalized = NormalizeVaultPath(path); + var parentPath = normalized.Contains('/') ? normalized[..normalized.LastIndexOf('/')] : string.Empty; + if (string.IsNullOrWhiteSpace(parentPath)) + return current as IModifiableFolder ?? root; + + var folder = await ResolveFolderAsync(absoluteRoot, current, parentPath); + return folder as IModifiableFolder ?? throw new InvalidOperationException("Parent folder is not writable."); + } + + private static async Task<(IModifiableFolder folder, string name)> ResolveDestinationFolderAsync(IModifiableFolder root, IFolder current, string path) + { + var normalized = NormalizeVaultPath(path); + var split = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (split.Length == 0) + throw new ArgumentException("Invalid destination path."); + + var destinationName = split[^1]; + var parent = split.Length == 1 ? string.Empty : string.Join('/', split[..^1]); + + var folder = await EnsureFolderAsync(root, current, parent); + return (folder, destinationName); + } + + private static async Task CreateOrGetVaultFileAsync(IModifiableFolder root, IFolder current, string path) + { + var (folder, name) = await ResolveDestinationFolderAsync(root, current, path); + return await folder.CreateFileAsync(name, overwrite: true); + } + + private static async Task EnsureFolderAsync(IModifiableFolder root, IFolder current, string path) + { + var normalized = NormalizeVaultPath(path); + if (string.IsNullOrWhiteSpace(normalized)) + return current as IModifiableFolder ?? root; + + var parts = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); + IModifiableFolder folder = normalized.StartsWith('/') ? root : current as IModifiableFolder ?? root; + + foreach (var part in parts) + { + var next = await folder.TryGetFolderByNameAsync(part); + if (next is IModifiableFolder nextFolder) + { + folder = nextFolder; + continue; + } + + var created = await folder.CreateFolderAsync(part, overwrite: false); + folder = created as IModifiableFolder ?? throw new InvalidOperationException("Created folder is not modifiable."); + } + + return folder; + } + + private static async Task ResolveStorableAsync(IFolder root, IFolder current, string path) + { + var normalized = NormalizeVaultPath(path); + if (normalized.StartsWith('/')) + return await root.GetItemByRelativePathAsync(normalized.TrimStart('/')); + + return await current.GetItemByRelativePathAsync(normalized); + } + + private static async Task ResolveFolderAsync(IFolder root, IFolder current, string path) + { + var item = await ResolveStorableAsync(root, current, path); + return item as IFolder ?? throw new InvalidOperationException("Path is not a folder."); + } + + private static string NormalizeVaultPath(string path) + { + return path.Replace('\\', '/').Trim(); + } + + private static bool TryParseLocal(string value, out string? localPath) + { + if (value.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + localPath = System.IO.Path.GetFullPath(value[7..]); + return true; + } + + localPath = null; + return false; + } + + private static List Tokenize(string input) + { + var result = new List(); + var current = new StringBuilder(); + var inQuotes = false; + + foreach (var ch in input) + { + if (ch == '"') + { + inQuotes = !inQuotes; + continue; + } + + if (char.IsWhiteSpace(ch) && !inQuotes) + { + if (current.Length > 0) + { + result.Add(current.ToString()); + current.Clear(); + } + + continue; + } + + current.Append(ch); + } + + if (current.Length > 0) + result.Add(current.ToString()); + + return result; + } +} diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultUnmountCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultUnmountCommand.cs new file mode 100644 index 000000000..0c13be6d8 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultUnmountCommand.cs @@ -0,0 +1,71 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using SecureFolderFS.Core.FileSystem; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Storage.VirtualFileSystem; + +namespace SecureFolderFS.Cli.Commands; + +[Command("vault unmount", Description = "Unmount a mounted vault.")] +public sealed partial class VaultUnmountCommand : CliGlobalOptions, ICommand +{ + [CommandParameter(0, Name = "path", Description = "Vault path or mount path.")] + public required string Path { get; init; } + + [CommandOption("force", Description = "Force unmount even if files are open.")] + public bool Force { get; init; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + var requestedPath = System.IO.Path.GetFullPath(Path); + var mounted = FileSystemManager.Instance.FileSystems.ToArray(); + + var target = mounted.FirstOrDefault(root => IsMatch(root, requestedPath)); + if (target is null) + { + CliOutput.Error(console, this, "No mounted vault matches the provided path."); + Environment.ExitCode = CliExitCodes.MountStateError; + return; + } + + try + { + await target.DisposeAsync(); + } + catch when (Force) + { + target.Dispose(); + } + + CliOutput.Success(console, this, "Vault unmounted."); + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } + + private static bool IsMatch(IVfsRoot root, string requestedPath) + { + if (string.Equals(System.IO.Path.GetFullPath(root.VirtualizedRoot.Id), requestedPath, StringComparison.OrdinalIgnoreCase)) + return true; + + if (root is not IWrapper wrapper) + return false; + + var contentPath = System.IO.Path.GetFullPath(wrapper.Inner.ContentFolder.Id); + if (string.Equals(contentPath, requestedPath, StringComparison.OrdinalIgnoreCase)) + return true; + + var parent = Directory.GetParent(contentPath)?.FullName; + return parent is not null && string.Equals(parent, requestedPath, StringComparison.OrdinalIgnoreCase); + } +} + + + + diff --git a/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs b/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs new file mode 100644 index 000000000..b8ca26ff8 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs @@ -0,0 +1,102 @@ +using System.Security.Cryptography; +using System.Text; +using SecureFolderFS.Core.Cryptography.SecureStore; +using SecureFolderFS.Shared.ComponentModel; + +namespace SecureFolderFS.Cli; + +public sealed class CredentialReader +{ + private const int KeyFileRandomLength = 128; + + public async Task ReadPasswordAsync(bool prompt, bool readFromStdin, string promptText, string? environmentVariable) + { + if (readFromStdin) + { + var stdinValue = await Console.In.ReadLineAsync(); + return string.IsNullOrEmpty(stdinValue) ? null : stdinValue; + } + + if (prompt) + return ReadMaskedPassword(promptText); + + return string.IsNullOrWhiteSpace(environmentVariable) ? null : environmentVariable; + } + + public string? ReadRecoveryKey(string? explicitValue, string? environmentValue) + { + return !string.IsNullOrWhiteSpace(explicitValue) + ? explicitValue + : string.IsNullOrWhiteSpace(environmentValue) ? null : environmentValue; + } + + public string? ReadKeyFilePath(string? explicitValue, string? environmentValue) + { + return !string.IsNullOrWhiteSpace(explicitValue) + ? explicitValue + : string.IsNullOrWhiteSpace(environmentValue) ? null : environmentValue; + } + + public async Task ReadKeyFileAsKeyAsync(string path) + { + var expandedPath = Path.GetFullPath(path); + var keyBytes = await File.ReadAllBytesAsync(expandedPath); + if (keyBytes.Length == 0) + throw new InvalidDataException("The key file is empty."); + + return ManagedKey.TakeOwnership(keyBytes); + } + + public async Task GenerateKeyFileAsync(string outputPath, string vaultId) + { + var fullPath = Path.GetFullPath(outputPath); + var parent = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(parent)) + Directory.CreateDirectory(parent); + + var idBytes = Encoding.ASCII.GetBytes(vaultId); + var keyBytes = new byte[KeyFileRandomLength + idBytes.Length]; + + RandomNumberGenerator.Fill(keyBytes.AsSpan(0, KeyFileRandomLength)); + idBytes.CopyTo(keyBytes.AsSpan(KeyFileRandomLength)); + + await File.WriteAllBytesAsync(fullPath, keyBytes); + return ManagedKey.TakeOwnership(keyBytes); + } + + private static string? ReadMaskedPassword(string prompt) + { + Console.Write(prompt); + var buffer = new StringBuilder(); + + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + break; + } + + if (key.Key == ConsoleKey.Backspace) + { + if (buffer.Length <= 0) + continue; + + buffer.Length -= 1; + Console.Write("\b \b"); + continue; + } + + if (char.IsControl(key.KeyChar)) + continue; + + buffer.Append(key.KeyChar); + Console.Write('*'); + } + + return buffer.Length == 0 ? null : buffer.ToString(); + } +} + + diff --git a/src/Platforms/SecureFolderFS.Cli/CredentialResolver.cs b/src/Platforms/SecureFolderFS.Cli/CredentialResolver.cs new file mode 100644 index 000000000..856195c25 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/CredentialResolver.cs @@ -0,0 +1,137 @@ +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; +using static SecureFolderFS.Core.Constants.Vault.Authentication; + +namespace SecureFolderFS.Cli; + +internal sealed class ResolvedAuthentication : IDisposable +{ + public required IKeyUsage Passkey { get; init; } + public required string[] Methods { get; init; } + + public void Dispose() + { + Passkey.Dispose(); + } +} + +internal static class CredentialResolver +{ + public static async Task ResolveCreateAuthenticationAsync(CreateAuthOptions options, CredentialReader reader, string vaultId) + { + var primary = await ResolveFactorAsync( + options.Password, + options.PasswordStdin, + options.KeyFile, + options.KeyFileGenerate, + "SFFS_PASSWORD", + "SFFS_KEYFILE", + "Primary password: ", + reader, + vaultId); + + if (primary is null) + return null; + + var secondary = await ResolveFactorAsync( + options.TwoFactorPassword, + options.TwoFactorPasswordStdin, + options.TwoFactorKeyFile, + options.TwoFactorKeyFileGenerate, + null, + null, + "Second-factor password: ", + reader, + vaultId); + + return Build(primary.Value, secondary); + } + + public static async Task ResolveAuthenticationAsync(VaultAuthOptions options, CredentialReader reader) + { + var primary = await ResolveFactorAsync( + options.Password, + options.PasswordStdin, + options.KeyFile, + null, + "SFFS_PASSWORD", + "SFFS_KEYFILE", + "Password: ", + reader, + null); + + if (primary is null) + return null; + + var secondary = await ResolveFactorAsync( + options.TwoFactorPassword, + options.TwoFactorPasswordStdin, + options.TwoFactorKeyFile, + null, + null, + null, + "Second-factor password: ", + reader, + null); + + return Build(primary.Value, secondary); + } + + private static ResolvedAuthentication Build((string method, IKeyUsage key) primary, (string method, IKeyUsage key)? secondary) + { + if (secondary is null) + { + return new ResolvedAuthentication + { + Passkey = primary.key, + Methods = [primary.method] + }; + } + + var keySequence = new KeySequence(); + keySequence.Add(primary.key); + keySequence.Add(secondary.Value.key); + + return new ResolvedAuthentication + { + Passkey = keySequence, + Methods = [primary.method, secondary.Value.method] + }; + } + + private static async Task<(string method, IKeyUsage key)?> ResolveFactorAsync( + bool usePromptPassword, + bool useStdinPassword, + string? keyFile, + string? generateKeyFile, + string? passwordEnvironmentName, + string? keyFileEnvironmentName, + string prompt, + CredentialReader reader, + string? vaultId) + { + var envPassword = passwordEnvironmentName is null ? null : Environment.GetEnvironmentVariable(passwordEnvironmentName); + var password = await reader.ReadPasswordAsync(usePromptPassword, useStdinPassword, prompt, envPassword); + if (!string.IsNullOrEmpty(password)) + return (AUTH_PASSWORD, new DisposablePassword(password)); + + if (!string.IsNullOrWhiteSpace(generateKeyFile)) + { + if (string.IsNullOrWhiteSpace(vaultId)) + { + // TODO: verify - keyfile generation for login/update flows may need explicit vault id derivation from config. + throw new InvalidOperationException("Keyfile generation requires a known vault identifier."); + } + + return (AUTH_KEYFILE, await reader.GenerateKeyFileAsync(generateKeyFile, vaultId)); + } + + var envKeyFile = keyFileEnvironmentName is null ? null : Environment.GetEnvironmentVariable(keyFileEnvironmentName); + var keyFilePath = reader.ReadKeyFilePath(keyFile, envKeyFile); + if (!string.IsNullOrWhiteSpace(keyFilePath)) + return (AUTH_KEYFILE, await reader.ReadKeyFileAsKeyAsync(keyFilePath)); + + return null; + } +} + diff --git a/src/Platforms/SecureFolderFS.Cli/Program.cs b/src/Platforms/SecureFolderFS.Cli/Program.cs index c607437f3..76085a11f 100644 --- a/src/Platforms/SecureFolderFS.Cli/Program.cs +++ b/src/Platforms/SecureFolderFS.Cli/Program.cs @@ -1,12 +1,55 @@ +using CliFx; +using Microsoft.Extensions.DependencyInjection; +using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Extensions; + namespace SecureFolderFS.Cli { - internal class Program + internal static class Program { - static void Main(string[] args) + public static CliLifecycleHelper Lifecycle { get; } = new(); + + private static async Task Main(string[] args) { - Console.WriteLine("Hello SecureFolderFS!"); - Console.WriteLine("Nothing here yet."); - Console.ReadKey(); + await Lifecycle.InitAsync(); + DI.Default.SetServiceProvider(Lifecycle.ServiceCollection.BuildServiceProvider()); + +#if DEBUG + if (args.IsEmpty()) + { + // Initialize settings once, outside the loop + Console.WriteLine("Interactive CLI mode. Press Enter with no input to exit."); + while (true) + { + Console.Write("> "); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) + break; + + var loopArgs = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var normalizedLoopArgs = loopArgs + .Select(static x => x.Replace("--2fa-", "--twofa-", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + var app2 = new CliApplicationBuilder() + .AddCommandsFromThisAssembly() + .UseTypeActivator(type => ActivatorUtilities.CreateInstance(DI.Default, type)) + .Build(); + + await app2.RunAsync(normalizedLoopArgs); + Console.WriteLine(); + } + + return 0; + } +#endif + var normalizedArgs = args.Select(static x => x.Replace("--2fa-", "--twofa-", StringComparison.OrdinalIgnoreCase)).ToArray(); + var app = new CliApplicationBuilder() + .AddCommandsFromThisAssembly() + .UseTypeActivator(type => ActivatorUtilities.CreateInstance(DI.Default, type)) + .Build(); + + return await app.RunAsync(normalizedArgs); } } } diff --git a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj index 1bbadf973..ff9ced8fa 100644 --- a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj +++ b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj @@ -6,4 +6,27 @@ enable + + + + + + + + + + + + + + + + $(DefineConstants);SFFS_WINDOWS_FS + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs deleted file mode 100644 index c1c04f375..000000000 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs +++ /dev/null @@ -1,41 +0,0 @@ -using SecureFolderFS.Shared.ComponentModel; -using SecureFolderFS.Shared.Models; -using SecureFolderFS.Storage.Streams; -using IImage = SecureFolderFS.Shared.ComponentModel.IImage; - -namespace SecureFolderFS.Maui.AppModels -{ - /// - internal sealed class ImageStream : IImageStream - { - public Stream Stream { get; } - - public StreamImageSource Source { get; } - - public ImageStream(Stream stream) - { - Stream = stream; - Source = new(); - Source.Stream = _ => Task.FromResult(stream); - } - - /// - public async Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default) - { - var savedPosition = Stream.Position; - await Stream.CopyToAsync(destination, cancellationToken); - - if (Stream.CanSeek) - Stream.Position = savedPosition; - } - - /// - public void Dispose() - { - if (Stream is NonDisposableStream nonDisposableStream) - nonDisposableStream.ForceClose(); - else - Stream.Dispose(); - } - } -} diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs new file mode 100644 index 000000000..62c313e43 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs @@ -0,0 +1,40 @@ +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Storage.Streams; + +namespace SecureFolderFS.Maui.AppModels +{ + /// + internal sealed class ImageStreamSource : IImageStream + { + /// + /// Gets the streamed image source. + /// + public StreamImageSource Source { get; } + + /// + public Stream Inner { get; } + + public ImageStreamSource(Stream inner) + { + Inner = inner; + Source = new StreamImageSource + { + Stream = _ => + { + Inner.TrySetPositionOrAdvance(0L); + return Task.FromResult(Inner); + } + }; + } + + /// + public void Dispose() + { + if (Inner is NonDisposableStream nonDisposableStream) + nonDisposableStream.ForceClose(); + else + Inner.Dispose(); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs index a50e6dcbd..c491de181 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs @@ -29,7 +29,7 @@ public AppShell() /// public void Receive(VaultLockedMessage message) { - Vibration.Vibrate(200d); + Vibration.Vibrate(80d); } private async void AppShell_Loaded(object? sender, EventArgs e) diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml index 1dff33d81..d752fa978 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml @@ -13,10 +13,17 @@ + + + + diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs index e5fa9edc6..7299d0553 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs @@ -1,7 +1,11 @@ +using Android.Content; +using Android.OS; +using CommunityToolkit.Mvvm.Messaging; using OwlCore.Storage; using OwlCore.Storage.System.IO; using SecureFolderFS.Maui.Extensions; using SecureFolderFS.Maui.Platforms.Android.ServiceImplementation; +using SecureFolderFS.Sdk.Messages; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.UI; @@ -12,8 +16,10 @@ namespace SecureFolderFS.Maui.Platforms.Android.Helpers { /// - internal sealed class AndroidLifecycleHelper : BaseLifecycleHelper + internal sealed class AndroidLifecycleHelper : BaseLifecycleHelper, IRecipient { + private bool _isForegroundServiceStarted; + /// public override string AppDirectory { get; } = FileSystem.Current.AppDataDirectory; @@ -25,6 +31,7 @@ public override Task InitAsync(CancellationToken cancellationToken = default) var settingsFolder = new SystemFolder(Directory.CreateDirectory(settingsFolderPath)); ConfigureServices(settingsFolder); + WeakReferenceMessenger.Default.Register(this); return Task.CompletedTask; } @@ -33,6 +40,26 @@ public override void LogExceptionToFile(Exception? ex) { _ = ex; } + + /// + public async void Receive(VaultUnlockedMessage message) + { + if (_isForegroundServiceStarted || MainActivity.Instance is null) + return; + + // Start the vault foreground service so it's ready to receive messenger messages + var serviceIntent = new Intent(MainActivity.Instance, typeof(VaultForegroundService)); + if (Build.VERSION.SdkInt >= BuildVersionCodes.O) + MainActivity.Instance.StartForegroundService(serviceIntent); + else + MainActivity.Instance.StartService(serviceIntent); + + _isForegroundServiceStarted = true; + + // Add the initial vault + var foregroundService = await VaultForegroundService.GetInstanceAsync(); + foregroundService.UnlockedVaults.Add(message.VaultModel); + } /// protected override IServiceCollection ConfigureServices(IModifiableFolder settingsFolder) diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs index 014f23153..14bc7d122 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs @@ -28,7 +28,7 @@ public override async Task GenerateThumbnailAsync(IFile file, Type await using var stream = await file.OpenReadAsync(cancellationToken).ConfigureAwait(false); var imageStream = await ThumbnailHelpers.GenerateImageThumbnailAsync(stream, Constants.Browser.IMAGE_THUMBNAIL_MAX_SIZE).ConfigureAwait(false); - return new ImageStream(imageStream); + return new ImageStreamSource(imageStream); } case TypeHint.Media: @@ -36,7 +36,7 @@ public override async Task GenerateThumbnailAsync(IFile file, Type await using var stream = await file.OpenReadAsync(cancellationToken).ConfigureAwait(false); var imageStream = await GenerateVideoThumbnailAsync(stream, TimeSpan.FromSeconds(0)).ConfigureAwait(false); - return new ImageStream(imageStream); + return new ImageStreamSource(imageStream); } default: throw new InvalidOperationException("The provided file type is invalid."); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultFileSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultFileSystemService.cs index f7d92def5..6a66a1ff4 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultFileSystemService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultFileSystemService.cs @@ -10,6 +10,7 @@ using SecureFolderFS.Sdk.ViewModels.Views.Wizard; using SecureFolderFS.Sdk.ViewModels.Views.Wizard.DataSources; using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.VirtualFileSystem; using SecureFolderFS.UI.ServiceImplementation; @@ -34,7 +35,7 @@ public override async IAsyncEnumerable GetSources var fileExplorerService = DI.Service(); yield return new PickerSourceWizardViewModel(DATA_SOURCE_PICKER, fileExplorerService, mode, vaultCollectionModel) { - Icon = new ImageIcon(new MauiIcon() { Icon = MaterialIcons.Storage, IconColor = Colors.White }) + Icon = new ImageIcon(new MauiIcon() { Icon = MaterialIcons.Storage, IconColor = App.Instance.Resources.GetAs("ThemePrimaryColorBrush")?.Color ?? Colors.White }) }; yield return new AccountSourceWizardViewModel(DATA_SOURCE_FTP, "FTP".ToLocalized(), mode, vaultCollectionModel) diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/VaultForegroundService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/VaultForegroundService.cs new file mode 100644 index 000000000..e44850a10 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/VaultForegroundService.cs @@ -0,0 +1,156 @@ +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.OS; +using AndroidX.Core.App; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.Maui.Platform; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Messages; +using SecureFolderFS.Sdk.Models; + +namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation +{ + [Service( + ForegroundServiceType = ForegroundService.TypeDataSync, + Exported = false)] + public class VaultForegroundService : Service, IRecipient, IRecipient + { + private const string CHANNEL_ID = "securefolderfs_vault_foreground_service"; + private const int NOTIFICATION_ID = 4949; + public const string LockAll = "securefolderfs.action.LOCK_ALL"; + + private static TaskCompletionSource InstanceTcs { get; } = new(); + + public List UnlockedVaults { get; } = new(); + + public static async Task GetInstanceAsync() + { + return await InstanceTcs.Task; + } + + /// + public override IBinder? OnBind(Intent? intent) + { + return null; + } + + /// + public override void OnCreate() + { + InstanceTcs.TrySetResult(this); + + base.OnCreate(); + EnsureNotificationChannel(); + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + } + + /// + public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId) + { + if (intent?.Action == LockAll) + { + foreach (var vault in UnlockedVaults.ToList()) + WeakReferenceMessenger.Default.Send(new VaultLockRequestedMessage(vault)); + + return StartCommandResult.Sticky; + } + + var notification = BuildNotification(); + if (notification is null) + return StartCommandResult.NotSticky; + + StartForeground(NOTIFICATION_ID, notification, ForegroundService.TypeDataSync); + return StartCommandResult.Sticky; + } + + /// + public override void OnDestroy() + { + WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); + base.OnDestroy(); + } + + /// + public void Receive(VaultUnlockedMessage message) + { + UnlockedVaults.Add(message.VaultModel); + UpdateNotification(); + } + + /// + public void Receive(VaultLockedMessage message) + { + UnlockedVaults.Remove(message.VaultModel); + UpdateNotification(); + } + + private void UpdateNotification() + { + var manager = NotificationManagerCompat.From(this); + if (manager is null) + return; + + var notification = BuildNotification(); + manager.Notify(NOTIFICATION_ID, notification); + } + + private Notification? BuildNotification() + { + var count = UnlockedVaults.Count; + var title = count switch + { + 0 => "VaultUnlocked".ToLocalized(), + 1 => "OneVaultIsUnlocked".ToLocalized(), + _ => "MultipleVaultsAreUnlocked".ToLocalized(count) + }; + + // Tapping the notification brings the app back to foreground + var tapIntent = new Intent(this, typeof(MainActivity)); + tapIntent.SetFlags(ActivityFlags.SingleTop | ActivityFlags.ClearTop); + var tapPendingIntent = PendingIntent.GetActivity( + this, 0, tapIntent, + PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable); + + // "Lock all" action + var lockAllIntent = new Intent(this, typeof(VaultForegroundService)); + lockAllIntent.SetAction(LockAll); + var lockAllPendingIntent = PendingIntent.GetService( + this, 0, lockAllIntent, + PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable); + + var iconRid = MauiApplication.Current.GetDrawableId("app_icon.png"); + if (iconRid == 0) + iconRid = 0x0108002f; // ic_lock_lock + + return new NotificationCompat.Builder(this, CHANNEL_ID) + .SetContentTitle(title) + ?.SetContentText("TapToLockAll".ToLocalized()) + ?.SetSmallIcon(iconRid) + ?.SetContentIntent(tapPendingIntent) + ?.AddAction(0, "LockAll".ToLocalized(), lockAllPendingIntent) + ?.SetOngoing(true) + ?.SetOnlyAlertOnce(true) + ?.Build(); + } + + private void EnsureNotificationChannel() + { + if (Build.VERSION.SdkInt < BuildVersionCodes.O) + return; + + var channel = new NotificationChannel( + CHANNEL_ID, + "Vault Status", + NotificationImportance.Low) // Low = no sound, no heads-up + { + Description = "Shows which vaults are currently unlocked" + }; + + var manager = GetSystemService(NotificationService) as NotificationManager; + manager?.CreateNotificationChannel(channel); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs index 9467ebabf..0a1d7f843 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs @@ -2,6 +2,7 @@ using CoreGraphics; using CoreMedia; using Foundation; +using ImageIO; using OwlCore.Storage; using SecureFolderFS.Maui.AppModels; using SecureFolderFS.Maui.ServiceImplementation; @@ -51,37 +52,32 @@ private static async Task GenerateImageThumbnailAsync(Stream strea if (data is null) throw new Exception("Failed to load image data."); - using var image = UIImage.LoadFromData(data); - if (image?.CGImage is null) - throw new Exception("Failed to load image."); + using var source = CGImageSource.FromData(data); + if (source is null) + throw new Exception("Failed to create image source."); - // Apply EXIF orientation - var orientedImage = image.Orientation == UIImageOrientation.Up - ? image - : UIImage.FromImage(image.CGImage, 1.0f, image.Orientation); - - // Resize - var scale = Math.Min(maxSize / orientedImage.Size.Width, maxSize / orientedImage.Size.Height); - var newSize = new CGSize(orientedImage.Size.Width * scale, orientedImage.Size.Height * scale); - - UIGraphics.BeginImageContextWithOptions(newSize, false, 1.0f); - orientedImage.Draw(new CGRect(CGPoint.Empty, newSize)); - using var resizedImage = UIGraphics.GetImageFromCurrentImageContext(); - UIGraphics.EndImageContext(); - - if (resizedImage is null) - throw new Exception("Failed to resize image."); - - // Compress to JPEG - using var jpegData = resizedImage.AsJPEG(Constants.Browser.IMAGE_THUMBNAIL_QUALITY); + var options = new CGImageThumbnailOptions + { + MaxPixelSize = (int)maxSize, + ShouldAllowFloat = false, + CreateThumbnailWithTransform = true, // handles EXIF orientation + CreateThumbnailFromImageAlways = true + }; + + using var cgImage = source.CreateThumbnail(0, options); + if (cgImage is null) + throw new Exception("Failed to create thumbnail."); + + using var image = UIImage.FromImage(cgImage); + using var jpegData = image.AsJPEG(Constants.Browser.IMAGE_THUMBNAIL_QUALITY); if (jpegData is null) throw new FormatException("Failed to convert image to JPEG."); - var memoryStream = new MemoryStream(); + var memoryStream = new MemoryStream((int)jpegData.Length); await jpegData.AsStream().CopyToAsync(memoryStream).ConfigureAwait(false); memoryStream.Position = 0L; - return new ImageStream(new NonDisposableStream(memoryStream)); + return new ImageStreamSource(new NonDisposableStream(memoryStream)); } private static async Task GenerateVideoThumbnailAsync(Stream stream, string extension, TimeSpan captureTime) @@ -115,10 +111,18 @@ private static async Task GenerateVideoThumbnailAsync(Stream strea }; var actualTime = new CMTime((long)captureTime.TotalSeconds, 1); - var imageRef = generator.CopyCGImageAtTime(actualTime, out _, out var error); - if (imageRef is null || error != null) - throw new FormatException($"Failed to generate thumbnail: {error?.LocalizedDescription}"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var times = new[] { NSValue.FromCMTime(actualTime) }; + + generator.GenerateCGImagesAsynchronously(times, (_, image, _, _, error) => + { + if (error != null || image is null) + tcs.TrySetException(new FormatException($"Failed to generate thumbnail: {error?.LocalizedDescription}")); + else + tcs.TrySetResult(image); + }); + using var imageRef = await tcs.Task.ConfigureAwait(false); using var image = UIImage.FromImage(imageRef); using var jpegData = image.AsJPEG(Constants.Browser.IMAGE_THUMBNAIL_QUALITY); if (jpegData is null) @@ -128,7 +132,7 @@ private static async Task GenerateVideoThumbnailAsync(Stream strea await jpegData.AsStream().CopyToAsync(memoryStream).ConfigureAwait(false); memoryStream.Position = 0L; - return new ImageStream(new NonDisposableStream(memoryStream)); + return new ImageStreamSource(new NonDisposableStream(memoryStream)); } finally { diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultFileSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultFileSystemService.cs index 693f71989..ae702ba01 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultFileSystemService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultFileSystemService.cs @@ -10,6 +10,7 @@ using SecureFolderFS.Sdk.ViewModels.Views.Wizard; using SecureFolderFS.Sdk.ViewModels.Views.Wizard.DataSources; using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.VirtualFileSystem; using SecureFolderFS.UI.ServiceImplementation; @@ -34,7 +35,7 @@ public override async IAsyncEnumerable GetSources var fileExplorerService = DI.Service(); yield return new PickerSourceWizardViewModel(DATA_SOURCE_PICKER, fileExplorerService, mode, vaultCollectionModel) { - Icon = new ImageIcon(new MauiIcon() { Icon = CupertinoIcons.Tray2, IconColor = Colors.White }) + Icon = new ImageIcon(new MauiIcon() { Icon = CupertinoIcons.Tray2, IconColor = App.Instance.Resources.GetAs("ThemePrimaryColorBrush")?.Color ?? Colors.White }) }; yield return new AccountSourceWizardViewModel(DATA_SOURCE_FTP, "FTP".ToLocalized(), mode, vaultCollectionModel) diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs index 5ffb6b536..9b63faee8 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs @@ -31,11 +31,12 @@ public IOSFolder(NSUrl url, IOSFolder? parent = null, NSUrl? permissionRoot = nu /// public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + if (!permissionRoot.StartAccessingSecurityScopedResource()) + throw new UnauthorizedAccessException("Could not get iOS items."); + var coordinator = new NSFileCoordinator(); try { - permissionRoot.StartAccessingSecurityScopedResource(); - var tcs = new TaskCompletionSource>(); coordinator.CoordinateRead(Inner, NSFileCoordinatorReadingOptions.WithoutChanges, @@ -69,10 +70,10 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = /// public async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken = default) { + if (!permissionRoot.StartAccessingSecurityScopedResource()) + throw new UnauthorizedAccessException("Could not get iOS item."); try { - permissionRoot.StartAccessingSecurityScopedResource(); - var isDirectory = false; var itemPath = Path.Combine(Id, name); @@ -100,9 +101,10 @@ public async Task DeleteAsync(IStorableChild item, CancellationToken cancellatio if (item is not IWrapper iosWrapper) throw new ArgumentException("Storable item must wrap an NSUrl.", nameof(item)); + if (!permissionRoot.StartAccessingSecurityScopedResource()) + throw new UnauthorizedAccessException("Could not delete iOS item."); try { - permissionRoot.StartAccessingSecurityScopedResource(); if (!NSFileManager.DefaultManager.Remove(iosWrapper.Inner, out var error)) throw new NSErrorException(error); } @@ -116,10 +118,10 @@ public async Task DeleteAsync(IStorableChild item, CancellationToken cancellatio /// public async Task CreateFolderAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) { + if (!permissionRoot.StartAccessingSecurityScopedResource()) + throw new UnauthorizedAccessException("Could not create iOS folder."); try { - permissionRoot.StartAccessingSecurityScopedResource(); - var path = Path.Combine(Id, name); NSFileAttributes? attributes = null; @@ -141,10 +143,10 @@ public async Task CreateFolderAsync(string name, bool overwrite = /// public async Task CreateFileAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) { + if (!permissionRoot.StartAccessingSecurityScopedResource()) + throw new UnauthorizedAccessException("Could not create iOS file."); try { - if (!permissionRoot.StartAccessingSecurityScopedResource()) - throw new UnauthorizedAccessException("Could not create iOS file."); var path = Path.Combine(Id, name); NSFileAttributes? attributes = null; @@ -175,9 +177,10 @@ public async Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite if (fileToCopy is not IOSFile iosFile) return await fallback(this, fileToCopy, overwrite, newName, cancellationToken); + if (!permissionRoot.StartAccessingSecurityScopedResource()) + throw new UnauthorizedAccessException("Could not copy iOS file."); try { - permissionRoot.StartAccessingSecurityScopedResource(); var newPath = Path.Combine(Id, newName); var newUrl = new NSUrl(newPath, false); @@ -230,9 +233,10 @@ public async Task MoveFromAsync(IChildFile fileToMove, IModifiableFo if (fileToMove is not IOSFile iosFile) return await fallback(this, fileToMove, source, overwrite, newName, cancellationToken); + if (!permissionRoot.StartAccessingSecurityScopedResource()) + throw new UnauthorizedAccessException("Could not move iOS file."); try { - permissionRoot.StartAccessingSecurityScopedResource(); var newPath = Path.Combine(Id, newName); var newUrl = new NSUrl(newPath, false); @@ -273,11 +277,10 @@ public async Task MoveFromAsync(IChildFile fileToMove, IModifiableFo /// public async Task RenameAsync(IStorableChild storable, string newName, CancellationToken cancellationToken = default) { + if (!permissionRoot.StartAccessingSecurityScopedResource()) + throw new UnauthorizedAccessException("Could not rename iOS item."); try { - if (!permissionRoot.StartAccessingSecurityScopedResource()) - throw new UnauthorizedAccessException("Could not rename iOS item."); - if (storable is not IWrapper iosWrapper) throw new ArgumentException("Storable item must wrap an NSUrl.", nameof(storable)); diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml b/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml index e43d182f3..14e955315 100644 --- a/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml @@ -48,14 +48,16 @@ - + WidthRequest="40" /> @@ -124,6 +126,25 @@ IsLastItem="True" Subtitle="{Binding ViewModel.DateModifiedText, Mode=OneWay}" /> + + + + + + diff --git a/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml b/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml index fc5c565a8..d40bf987c 100644 --- a/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml @@ -14,6 +14,8 @@ + + diff --git a/src/Platforms/SecureFolderFS.Maui/SecureFolderFS.Maui.csproj b/src/Platforms/SecureFolderFS.Maui/SecureFolderFS.Maui.csproj index c8d3e2554..dd06f602c 100644 --- a/src/Platforms/SecureFolderFS.Maui/SecureFolderFS.Maui.csproj +++ b/src/Platforms/SecureFolderFS.Maui/SecureFolderFS.Maui.csproj @@ -5,13 +5,6 @@ - - - Exe SecureFolderFS.Maui true @@ -34,7 +27,7 @@ True 17.0 - 28.0 + 29.0 diff --git a/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs index 97f63d81b..71d52ff63 100644 --- a/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs +++ b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs @@ -51,7 +51,7 @@ public virtual Task GetImageFromUrlAsync(string url, CancellationToken c public virtual async Task ReadImageFileAsync(IFile file, CancellationToken cancellationToken) { var stream = await file.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken); - return new ImageStream(stream); + return new ImageStreamSource(stream); } /// diff --git a/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml b/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml index 050157f07..73139cd5a 100644 --- a/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml @@ -53,8 +53,8 @@ + IsVisible="{Binding ViewModel.AreSizeOptionsAvailable, Mode=OneWay, FallbackValue={x:Boolean False}}" + ProvideBackplate="False"> diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/BreadcrumbBar.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/BreadcrumbBar.xaml index cc66f6b2e..e211e99c6 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/BreadcrumbBar.xaml +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/BreadcrumbBar.xaml @@ -13,13 +13,12 @@ - + @@ -50,7 +49,7 @@ ().Where(f => f.CanLoadThumbnail()).ToList(); - if (items.Count == 0) - return; - - // Cancel any in-flight thumbnail work from the previous folder - _thumbnailCts?.Cancel(); - _thumbnailCts = new CancellationTokenSource(); - var ct = _thumbnailCts.Token; - - _ = Task.Run(async () => - { - var tasks = items.Select(async item => - { - await _thumbnailSemaphore.WaitAsync(ct); - try - { - await item.InitAsync(ct); - } - catch (OperationCanceledException) - { - // Navigation occurred, stop quietly - } - finally - { - _thumbnailSemaphore.Release(); - } - }); - - await Task.WhenAll(tasks); - }, ct); - } - - private void TryEnqueueThumbnail(object? sender) + private void TryEnqueueItem(object? sender) { if (!_settingsService.UserSettings.AreThumbnailsEnabled) return; - if (sender is not BindableObject { BindingContext: FileViewModel fileViewModel }) + if (sender is not BindableObject { BindingContext: IAsyncInitialize asyncInitialize }) return; - if (!fileViewModel.CanLoadThumbnail()) - return; - - // Reuse the current folder's cancellation token, so virtualized - // items are also canceled on navigation var ct = _thumbnailCts?.Token ?? CancellationToken.None; _ = Task.Run(async () => { await _thumbnailSemaphore.WaitAsync(ct); try { - await fileViewModel.InitAsync(ct); + await asyncInitialize.InitAsync(ct); + } + catch (OperationCanceledException) + { + // Navigation occurred or load was canceled } - catch (OperationCanceledException) { } finally { _thumbnailSemaphore.Release(); @@ -176,7 +134,7 @@ private void TryEnqueueThumbnail(object? sender) private void ItemContainer_Loaded(object? sender, EventArgs e) { - TryEnqueueThumbnail(sender); + TryEnqueueItem(sender); RegisterItemContainerPanGesture(sender); if (sender is View view) @@ -187,7 +145,7 @@ private void ItemContainer_BindingContextChanged(object? sender, EventArgs e) { #if IOS // Also handle BindingContextChanged for virtualized/recycled items on iOS - TryEnqueueThumbnail(sender); + TryEnqueueItem(sender); #endif } @@ -207,7 +165,7 @@ private void ItemsCollectionView_Loaded(object? sender, EventArgs e) // On other platforms, bind SelectionMode to IsSelecting _collectionView?.SetBinding(SelectableItemsView.SelectionModeProperty, new Binding(nameof(IsSelecting), mode: BindingMode.OneWay, source: this, - converter: GetConverter("BoolSelectionModeConverter"))); + converter: GetConverter(nameof(BoolSelectionModeConverter)))); #endif } diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml index ff6efa508..2f1d5f0f1 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml @@ -51,7 +51,7 @@ @@ -61,19 +61,44 @@ - + WidthRequest="40" /> - @@ -133,11 +158,12 @@ - + Aspect="{Binding Thumbnail, Converter={StaticResource ThumbnailToAspectConverter}}" + IsOpaque="True" + Source="{Binding Thumbnail, Converter={StaticResource FileIconConverter}, ConverterParameter={x:Reference SourceGrid}}" /> + - internal static class MacOsTitleBarHelper + internal static class MacOsWindowHelper { // NSWindowStyleMask values (for reference) private const ulong NSWindowStyleMaskFullSizeContentView = 1 << 15; @@ -66,6 +66,34 @@ public static (double Left, double Top) GetTrafficLightButtonsInset() return (64, 0); } + /// + /// Centers the specified window on the screen. + /// + /// The window to center. + /// True if the window was successfully centered; otherwise, false. + public static bool CenterWindow(Window window) + { + if (!OperatingSystem.IsMacOS()) + return false; + + try + { + var nsWindow = GetNativeWindowHandle(window); + if (nsWindow == IntPtr.Zero) + return false; + + // Call [nsWindow center] + var selector = sel_registerName("center"); + objc_msgSend_void(nsWindow, selector); + + return true; + } + catch + { + return false; + } + } + private static IntPtr GetNativeWindowHandle(Window window) { #if __UNO_SKIA_MACOS__ diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaSystemService.MacOS.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaSystemService.MacOS.cs index 173c62d42..9d461df52 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaSystemService.MacOS.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaSystemService.MacOS.cs @@ -24,8 +24,9 @@ public event EventHandler? DeviceLocked } remove { + var hadValue = _deviceLocked is not null; _deviceLocked -= value; - if (_deviceLocked is null) + if (_deviceLocked is null && hadValue) DetachLockObserver(); } } diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/SkiaWebDavFileSystem.MacOS.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/SkiaWebDavFileSystem.MacOS.cs index 9e2af66e2..411f454ba 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/SkiaWebDavFileSystem.MacOS.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/SkiaWebDavFileSystem.MacOS.cs @@ -15,7 +15,7 @@ namespace SecureFolderFS.Uno.Platforms.Desktop { - /// + /// internal sealed partial class SkiaWebDavFileSystem : WebDavFileSystem { #if __UNO_SKIA_MACOS__ diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/Helpers/WindowsBoundsManager.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/Helpers/WindowsBoundsManager.cs index b738f120b..667cdef73 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/Helpers/WindowsBoundsManager.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/Helpers/WindowsBoundsManager.cs @@ -8,9 +8,8 @@ using Microsoft.UI.Xaml; using SecureFolderFS.Uno.PInvoke; using SecureFolderFS.Uno.Platforms.Windows.Extensions; -using Vanara.PInvoke; using Windows.Storage; -using static Vanara.PInvoke.User32; +using static SecureFolderFS.Uno.PInvoke.UnsafeNative; namespace SecureFolderFS.Uno.Platforms.Windows.Helpers { @@ -87,10 +86,10 @@ public bool LoadWindowState(string windowId) var windowPlacement = Marshal.PtrToStructure(pWindowPlacementBuffer); Marshal.FreeHGlobal(pWindowPlacementBuffer); - if (windowPlacement is { showCmd: ShowWindowCommand.SW_SHOWMINIMIZED, flags: WindowPlacementFlags.WPF_RESTORETOMAXIMIZED }) - windowPlacement.showCmd = ShowWindowCommand.SW_MAXIMIZE; - else if (windowPlacement.showCmd != ShowWindowCommand.SW_MAXIMIZE) - windowPlacement.showCmd = ShowWindowCommand.SW_NORMAL; + if (windowPlacement is { showCmd: SW_SHOWMINIMIZED, flags: WPF_RESTORETOMAXIMIZED }) + windowPlacement.showCmd = SW_MAXIMIZE; + else if (windowPlacement.showCmd != SW_MAXIMIZE) + windowPlacement.showCmd = SW_NORMAL; _isRestoringWindowState = true; _ = SetWindowPlacement(_window.GetWindowHandle(), ref windowPlacement); @@ -151,9 +150,9 @@ public void SaveWindowState(string windowId) private void MessageReceiver_MessageReceived(object? sender, WindowMessageEventArgs e) { - switch ((WindowMessage)e.NativeMessage.uMsg) + switch (e.NativeMessage.uMsg) { - case WindowMessage.WM_GETMINMAXINFO: + case WM_GETMINMAXINFO: unsafe { if (_isRestoringWindowState) @@ -163,13 +162,13 @@ private void MessageReceiver_MessageReceived(object? sender, WindowMessageEventA var currentDpi = GetDpiForWindow(_hWnd); // Restrict min-size - rect2->minTrackSize.cx = (int)Math.Max(MinWidth * (currentDpi / 96f), rect2->minTrackSize.cx); - rect2->minTrackSize.cy = (int)Math.Max(MinHeight * (currentDpi / 96f), rect2->minTrackSize.cy); + rect2->ptMinTrackSize.X = (int)Math.Max(MinWidth * (currentDpi / 96f), rect2->ptMinTrackSize.X); + rect2->ptMinTrackSize.Y = (int)Math.Max(MinHeight * (currentDpi / 96f), rect2->ptMinTrackSize.Y); break; } - case WindowMessage.WM_DPICHANGED: + case WM_DPICHANGED: { if (_isRestoringWindowState) e.Handled = true; @@ -202,7 +201,7 @@ public static WindowsBoundsManager AddOrGet(Window window) internal sealed class Win32NativeWindowMessageReceiver : IDisposable { private readonly IntPtr _hWnd; - private ComCtl32.SUBCLASSPROC? _subclassprocCallback; + private SUBCLASSPROC? _subclassprocCallback; private event EventHandler? _messageReceived; public event EventHandler? MessageReceived @@ -227,14 +226,14 @@ public Win32NativeWindowMessageReceiver(IntPtr hWnd) _hWnd = hWnd; } - private IntPtr WindowProc(HWND hWnd, uint uMsg, IntPtr wParam, IntPtr lParam, UIntPtr uIdSubclass, IntPtr dwRefData) + private IntPtr WindowProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam, UIntPtr uIdSubclass, IntPtr dwRefData) { var args = new WindowMessageEventArgs(new(_hWnd, lParam, wParam, uMsg)); _messageReceived?.Invoke(this, args); if (args.Handled) return args.Result; - return ComCtl32.DefSubclassProc(hWnd, uMsg, wParam, lParam); + return DefSubclassProc(hWnd, uMsg, wParam, lParam); } private void NativeSubscribe() @@ -243,7 +242,7 @@ private void NativeSubscribe() return; _subclassprocCallback = new(WindowProc); - var result = ComCtl32.SetWindowSubclass(_hWnd, _subclassprocCallback, 101, 0); + SetWindowSubclass(_hWnd, _subclassprocCallback, 101, 0); } private void NativeUnsubscribe() @@ -251,7 +250,7 @@ private void NativeUnsubscribe() if (_subclassprocCallback is null) return; - ComCtl32.RemoveWindowSubclass(_hWnd, _subclassprocCallback, 101); + RemoveWindowSubclass(_hWnd, _subclassprocCallback, 101); _subclassprocCallback = null; } diff --git a/src/Platforms/SecureFolderFS.Uno/Program.cs b/src/Platforms/SecureFolderFS.Uno/Program.cs index ac25bb81d..4e14e3c12 100644 --- a/src/Platforms/SecureFolderFS.Uno/Program.cs +++ b/src/Platforms/SecureFolderFS.Uno/Program.cs @@ -1,6 +1,7 @@ #if WINDOWS using System; using System.Threading; +using System.Threading.Tasks; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; @@ -55,8 +56,19 @@ private static void Main(string[] args) private static void RedirectActivationTo(AppInstance targetInstance, AppActivationArguments args) { // Redirect on a background thread to avoid blocking - var redirectTask = targetInstance.RedirectActivationToAsync(args).AsTask(); - redirectTask.Wait(); + var redirectSemaphore = new SemaphoreSlim(0, 1); + Task.Run(async () => + { + try + { + await targetInstance.RedirectActivationToAsync(args); + } + finally + { + redirectSemaphore.Release(); + } + }); + redirectSemaphore.Wait(); } private static async void OnActivated(object? sender, AppActivationArguments args) diff --git a/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ConverterResources.xaml b/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ConverterResources.xaml index 46adc9acb..4967bba06 100644 --- a/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ConverterResources.xaml +++ b/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ConverterResources.xaml @@ -11,6 +11,7 @@ + diff --git a/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ImageResources.xaml b/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ImageResources.xaml index 2fe2ebc69..eb27d81c4 100644 --- a/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ImageResources.xaml +++ b/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ImageResources.xaml @@ -1,16 +1,55 @@ - + + + + + + + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj b/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj index 3f46f9d2e..a77f984d0 100644 --- a/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj +++ b/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj @@ -94,8 +94,6 @@ - - @@ -141,7 +139,7 @@ 4.2.2 - Language=en-US;pl-PL;de-DE;es-ES;da-DK;uk-UA;pt-PT;ro-RO;ms-MY;id-ID;hi-IN;zh-CN + Language=en-US;pl-PL;de-DE;es-ES;da-DK;uk-UA;pt-PT;ro-RO;ms-MY;id-ID;hi-IN;zh-CN;tr-TR en-US @@ -335,6 +333,10 @@ $(DefaultXamlRuntime) Designer + + $(DefaultXamlRuntime) + Designer + $(DefaultXamlRuntime) Designer @@ -502,6 +504,7 @@ + diff --git a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoDialogService.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoDialogService.cs index bdccf5d07..f0d34c902 100644 --- a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoDialogService.cs +++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoDialogService.cs @@ -34,6 +34,7 @@ protected override IOverlayControl GetOverlay(IViewable viewable) RecoveryOverlayViewModel => new RecoveryDialog(), MigrationOverlayViewModel => new MigrationDialog(), RecycleBinOverlayViewModel => new RecycleBinDialog(), + VaultItemInfoOverlayViewModel => new VaultItemInfoDialog(), // Unused PaymentOverlayViewModel => new PaymentDialog(), diff --git a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoMediaService.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoMediaService.cs index ec004f5a6..163109e18 100644 --- a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoMediaService.cs +++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoMediaService.cs @@ -9,16 +9,16 @@ using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Enums; -using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.Extensions; using SecureFolderFS.UI.Enums; using SecureFolderFS.Uno.AppModels; using SecureFolderFS.Uno.Helpers; +using SecureFolderFS.Uno.PInvoke; +using static SecureFolderFS.Uno.PInvoke.UnsafeNative; #if WINDOWS using System.Runtime.InteropServices; -using Vanara.PInvoke; #endif namespace SecureFolderFS.Uno.ServiceImplementation @@ -83,10 +83,6 @@ public async Task ReadImageFileAsync(IFile file, CancellationToken cance await bitmapImage.SetSourceAsync(winrtStream).AsTask(cancellationToken); return new ImageBitmap(bitmapImage, null); - - // TODO: Check if it works - var classification = FileTypeHelper.GetClassification(file); - return await ImagingHelpers.GetBitmapFromStreamAsync(winrtStream, classification.MimeType, cancellationToken); } /// @@ -125,15 +121,15 @@ public async Task TrySetFolderIconAsync(IModifiableFolder folder, Stream i // Notify Shell of the update File.SetAttributes(desktopIniFile.Id, FileAttributes.Hidden | FileAttributes.System); - var folderSettings = new Shell32.SHFOLDERCUSTOMSETTINGS() + var folderSettings = new SHFOLDERCUSTOMSETTINGS() { cchIconFile = 0, pszIconFile = iconFile.Name, - dwMask = Shell32.FOLDERCUSTOMSETTINGSMASK.FCSM_ICONFILE, - dwSize = (uint)Marshal.SizeOf() + dwMask = FCSM_ICONFILE, + dwSize = (uint)Marshal.SizeOf() }; - Shell32.SHGetSetFolderCustomSettings(ref folderSettings, folder.Id, Shell32.FCS.FCS_FORCEWRITE); - Shell32.SHChangeNotify(Shell32.SHCNE.SHCNE_UPDATEITEM, Shell32.SHCNF.SHCNF_PATHW, folder.Id); + SHGetSetFolderCustomSettings(ref folderSettings, folder.Id, FCS_FORCEWRITE); + SHChangeNotify(SHCNE_UPDATEITEM, SHCNF_PATHW, folder.Id, IntPtr.Zero); return true; } diff --git a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoStorageService.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoStorageService.cs index 5c93ebf25..d2e568105 100644 --- a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoStorageService.cs +++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoStorageService.cs @@ -6,7 +6,6 @@ using OwlCore.Storage.System.IO; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Storage.SystemStorageEx; -using Windows.Storage; namespace SecureFolderFS.Uno.ServiceImplementation { @@ -16,10 +15,10 @@ internal sealed class UnoStorageService : IStorageService /// public Task GetAppFolderAsync(CancellationToken cancellationToken = default) { -#if UNPACKAGED || DEBUG +#if UNPACKAGED || HAS_UNO_SKIA return Task.FromResult(new SystemFolder(Path.Combine(Directory.GetCurrentDirectory(), UI.Constants.FileNames.SETTINGS_FOLDER_NAME))); #else - return Task.FromResult(new SystemFolder(ApplicationData.Current.LocalFolder.Path)); + return Task.FromResult(new SystemFolder(Windows.Storage.ApplicationData.Current.LocalFolder.Path)); #endif } diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/BackButtonTitleControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/BackButtonTitleControl.xaml.cs index 97031b443..0c9c6e3c5 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/BackButtonTitleControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/BackButtonTitleControl.xaml.cs @@ -13,7 +13,7 @@ public sealed partial class BackButtonTitleControl : UserControl { private bool _isBackShown; - public event RoutedEventHandler Click; + public event RoutedEventHandler? Click; public BackButtonTitleControl() { diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/GraphControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/GraphControl.xaml index c8a9bc0dc..832c708c5 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/GraphControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/GraphControl.xaml @@ -53,7 +53,7 @@ @@ -146,20 +145,6 @@ - - - - - - diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs index 2185ad714..5dfe502c8 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs @@ -25,7 +25,7 @@ namespace SecureFolderFS.Uno.UserControls.InterfaceHost { - public sealed partial class MainAppHostControl : UserControl, IRecipient, IRecipient + public sealed partial class MainAppHostControl : UserControl, IRecipient #if WINDOWS , IRecipient #endif @@ -47,18 +47,6 @@ public void Receive(VaultRemovedMessage message) Navigation?.ClearContent(); } - /// - public void Receive(VaultAddedMessage message) - { -#if WINDOWS - if (ViewModel?.VaultListViewModel.Items.Count >= SecureFolderFS.Sdk.Constants.Vault.MAX_FREE_AMOUNT_OF_VAULTS - && !SettingsService.AppSettings.WasBetaNotificationShown1) - { - BetaTeachingTip.IsOpen = true; - } -#endif - } - #if WINDOWS /// public void Receive(VaultSelectionRequestedMessage message) @@ -160,7 +148,6 @@ private void RenameBox_LostFocus(object sender, RoutedEventArgs e) private async void MainAppHostControl_Loaded(object sender, RoutedEventArgs e) { WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); #if WINDOWS WeakReferenceMessenger.Default.Register(this); #endif @@ -176,7 +163,7 @@ private async void Sidebar_SelectionChanged(NavigationView sender, NavigationVie private async void SidebarSearchBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) { - var chosenItem = ViewModel!.VaultListViewModel.Items.FirstOrDefault(x => x.VaultViewModel.Title.Equals(args.ChosenSuggestion)); + var chosenItem = ViewModel!.VaultListViewModel.Items.FirstOrDefault(x => x.VaultViewModel.Title?.Equals(args.ChosenSuggestion) ?? false); if (chosenItem is null) return; @@ -190,12 +177,6 @@ private async void SidebarSearchBox_TextChanged(AutoSuggestBox sender, AutoSugge await ViewModel!.VaultListViewModel.SearchViewModel.SubmitQueryAsync(sender.Text); } - private async void TeachingTip_CloseButtonClick(TeachingTip sender, object args) - { - SettingsService.AppSettings.WasBetaNotificationShown1 = true; - await SettingsService.AppSettings.TrySaveAsync(); - } - #region Drag and Drop private void Sidebar_DragOver(object sender, DragEventArgs e) @@ -212,17 +193,25 @@ private async void Sidebar_Drop(object sender, DragEventArgs e) if (!e.DataView.Contains(StandardDataFormats.StorageItems)) return; - // We only want to get the first item - // as it is unlikely the user will want to add multiple vaults in batch. - var droppedItems = await e.DataView.GetStorageItemsAsync().AsTask(); - var item = droppedItems.FirstOrDefault(); - if (item is null) - return; - - // Recreate as SystemFolder for best performance. - // The logic can be changed to handle Platform Storage Items in the future, if needed. - var folder = new SystemFolderEx(item.Path); - await ViewModel.VaultListViewModel.AddNewVaultCommand.ExecuteAsync(folder); + var deferral = e.GetDeferral(); + try + { + // We only want to get the first item + // as it is unlikely the user will want to add multiple vaults in batch. + var droppedItems = await e.DataView.GetStorageItemsAsync().AsTask(); + var item = droppedItems.FirstOrDefault(); + if (item is null) + return; + + // Recreate as SystemFolder for best performance. + // The logic can be changed to handle Platform Storage Items in the future, if needed. + var folder = new SystemFolderEx(item.Path); + await ViewModel.VaultListViewModel.AddNewVaultCommand.ExecuteAsync(folder); + } + finally + { + deferral.Complete(); + } } #endregion diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml index 0202ed058..efc514287 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml @@ -4,6 +4,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:hni="using:H.NotifyIcon" + xmlns:l="using:SecureFolderFS.Uno.Localization" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:not_win="http://uno.ui/not_win" xmlns:svfs="using:SecureFolderFS.Storage.VirtualFileSystem" @@ -70,7 +71,7 @@ HorizontalAlignment="Right" VerticalAlignment="Bottom" x:FieldModifier="public" - x:Load="{x:Bind IsDebugging, Mode=OneWay}" + x:Load="{x:Bind IsDebugging}" Click="DebugButton_Click"> @@ -80,14 +81,14 @@ - + @@ -95,13 +96,13 @@ + Text="{l:ResourceString Rid=LockAll}"> - + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs index d0a0f0602..48e9a15cd 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs @@ -48,7 +48,7 @@ public MainViewModel? ViewModel public MainWindowRootControl(MainViewModel mainViewModel) { InitializeComponent(); - App.Instance.MainWindowSynchronizationContext = SynchronizationContext.Current; + App.Instance?.MainWindowSynchronizationContext = SynchronizationContext.Current; ViewModel = mainViewModel; } @@ -57,7 +57,7 @@ private void MainWindowRootControl_Loaded(object sender, RoutedEventArgs e) if (OperatingSystem.IsMacCatalyst()) RootGrid.Margin = new(0, 37, 0, 0); - ViewModel.RootNavigationService.SetupNavigation(Navigation); + ViewModel?.RootNavigationService.SetupNavigation(Navigation); _ = EnsureRootAsync(); } @@ -73,16 +73,6 @@ private async Task EnsureRootAsync() if (ViewModel is null) return; - if (!VaultListMigratorHelpers.IsMigrated()) - { - var file = await VaultListMigratorHelpers.TryGetVaultsFileAsync(CancellationToken.None); - if (file is not null) - { - await VaultListMigratorHelpers.TryMigrateVaultsAsync(file, StreamSerializer.Instance, CancellationToken.None); - VaultListMigratorHelpers.SetMigrated(); - } - } - // Initialize the root view model await ViewModel.InitAsync(); @@ -117,7 +107,7 @@ private async Task EnsureRootAsync() private async void MainWindowRootControl_PreviewKeyDown(object sender, KeyRoutedEventArgs e) { - if (XamlRoot is null) + if (ViewModel is null || XamlRoot is null) return; var focusedElement = FocusManager.GetFocusedElement(XamlRoot); diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml new file mode 100644 index 000000000..33ff5f964 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml.cs new file mode 100644 index 000000000..37f6b459d --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml.cs @@ -0,0 +1,145 @@ +using System; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Animation; + +// The User Control item template is documented at https://go.microsoft.com/fwlink/?LinkId=234236 + +namespace SecureFolderFS.Uno.UserControls.Introduction +{ + public sealed partial class AuthenticationSlide : UserControl + { + public AuthenticationSlide() + { + InitializeComponent(); + SetupInitialState(); + } + + private void SetupInitialState() + { + ResetAnimationState(); + } + + private void ResetAnimationState() + { + // Reset path to fully hidden + AuthenticationPath.Opacity = 0d; + + // Reset all icons to the initially hidden state + ResetIcon(PasswordBorder); + ResetIcon(DeviceLinkBorder); + ResetIcon(YubiKeyBorder); + ResetIcon(KeyFileBorder); + } + + private static void ResetIcon(Border icon) + { + icon.Opacity = 0d; + if (icon.RenderTransform is ScaleTransform scale) + { + scale.ScaleX = 0.4d; + scale.ScaleY = 0.4d; + } + } + + /// + /// Animates the four icons (tiles) to pop into view from top to bottom (staggered). + /// Once all icons have finished animating, the S-curve path fades in to 25% opacity. + /// Fully resets all values before starting. + /// + public async Task AnimateAsync() + { + // Always reset everything first so the animation can be replayed cleanly + ResetAnimationState(); + + var storyboard = new Storyboard(); + + // Icon pop animations + + // Timings chosen so the icons appear gradually down the S-curve + AddIconPopAnimation(storyboard, PasswordBorder, beginTimeMs: 50); + AddIconPopAnimation(storyboard, DeviceLinkBorder, beginTimeMs: 600); + AddIconPopAnimation(storyboard, YubiKeyBorder, beginTimeMs: 1150); + AddIconPopAnimation(storyboard, KeyFileBorder, beginTimeMs: 1650); + + // Path fade-in + + // Last icon ends at ~2000 ms (1650 + 350), so the path begins right after + var pathFade = new DoubleAnimation + { + From = 0d, + To = 0.25d, + Duration = new Duration(TimeSpan.FromMilliseconds(400)), + BeginTime = TimeSpan.FromMilliseconds(2050), // slight buffer after last icon + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } + }; + Storyboard.SetTarget(pathFade, AuthenticationPath); + Storyboard.SetTargetProperty(pathFade, "Opacity"); + storyboard.Children.Add(pathFade); + + // Awaitable completion + var tcs = new TaskCompletionSource(); + storyboard.Completed += Storyboard_Completed; + storyboard.Begin(); + + await tcs.Task; + return; + + void Storyboard_Completed(object? sender, object e) + { + storyboard.Completed -= Storyboard_Completed; + tcs.SetResult(null); + } + } + + private static void AddIconPopAnimation( + Storyboard storyboard, + UIElement icon, + double beginTimeMs) + { + var duration = TimeSpan.FromMilliseconds(350); + var easing = new CubicEase { EasingMode = EasingMode.EaseOut }; + + // Opacity fade-in + var opacityAnim = new DoubleAnimation + { + From = 0d, + To = 1d, + Duration = new Duration(duration), + BeginTime = TimeSpan.FromMilliseconds(beginTimeMs), + EasingFunction = easing + }; + Storyboard.SetTarget(opacityAnim, icon); + Storyboard.SetTargetProperty(opacityAnim, "Opacity"); + storyboard.Children.Add(opacityAnim); + + // Scale X + var scaleXAnim = new DoubleAnimation + { + From = 0.4d, + To = 1d, + Duration = new Duration(duration), + BeginTime = TimeSpan.FromMilliseconds(beginTimeMs), + EasingFunction = easing + }; + Storyboard.SetTarget(scaleXAnim, icon); + Storyboard.SetTargetProperty(scaleXAnim, "(UIElement.RenderTransform).(ScaleTransform.ScaleX)"); + storyboard.Children.Add(scaleXAnim); + + // Scale Y + var scaleYAnim = new DoubleAnimation + { + From = 0.4d, + To = 1d, + Duration = new Duration(duration), + BeginTime = TimeSpan.FromMilliseconds(beginTimeMs), + EasingFunction = easing + }; + Storyboard.SetTarget(scaleYAnim, icon); + Storyboard.SetTargetProperty(scaleYAnim, "(UIElement.RenderTransform).(ScaleTransform.ScaleY)"); + storyboard.Children.Add(scaleYAnim); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml new file mode 100644 index 000000000..8ede94e6f --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml.cs new file mode 100644 index 000000000..eb4c47bae --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml.cs @@ -0,0 +1,700 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.UI.Enums; +using SecureFolderFS.Uno.Helpers; +using SkiaSharp; +using SkiaSharp.Views.Windows; + +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 string UI_ASSEMBLY_NAME = $"{nameof(SecureFolderFS)}.UI"; + + private const float VELOCITY_SCALE = 0.00018f; // pixels/sec to deform ratio + private const float MAX_DEFORM = 0.1f; + private const float LERP_SPEED = 12f; + 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 + private const float MAGNIFIER_RADIUS = 60f; + private const float MOVEMENT_THRESHOLD = 1f; +#endif + + private bool _isInitialized; + + private SKBitmap? _wallpaperBitmap; + private SKBitmap? _hexBitmap; + private SKPoint? _pointerPosition; + + // 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; + + // Fluid pointer dynamics + private SKPoint _targetPosition; + private SKPoint _smoothPosition; + private SKPoint _positionVelocity; + + // Spring scale for press/release bounce + private float _lensScale = 1f; + private float _scaleVelocity = 0f; + private float _scaleTarget = 1f; + + // Animation timer (runs only while settling) + private readonly DispatcherTimer _animTimer; + private DateTime _lastTick; + private bool _isAnimating; + + public EncryptedFileSlide() + { + InitializeComponent(); + + // 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; + } + + /// + public Task InitAsync(CancellationToken cancellationToken = default) + { + if (_isInitialized) + return Task.CompletedTask; + + var assembly = AppDomain.CurrentDomain.GetAssemblies().SingleOrDefault(x => x.GetName().Name == UI_ASSEMBLY_NAME); + if (assembly is null) + return Task.CompletedTask; + + var rnd = Random.Shared.Next(1, 4); + _wallpaperBitmap = LoadBitmap(assembly, $"Introduction.intro_wallpaper{rnd}.jpg"); + _hexBitmap = LoadBitmap(assembly, "Introduction." + UnoThemeHelper.Instance.ActualTheme switch + { + ThemeType.Light => "intro_hex_light.png", + _ => "intro_hex_dark.png" + }); + if (_wallpaperBitmap is not null) + { + _wallpaperBitmap = RotateBitmap(_wallpaperBitmap, 180); + _wallpaperBitmap = FlipBitmap(_wallpaperBitmap, horizontal: true, vertical: true); + } + + _cachedEdgeColors = null; + _cachedSweepStops = null; + _cachedCoreColors = null; + SkiaCanvas.Invalidate(); + + _isInitialized = true; + return Task.CompletedTask; + } + + private static SKBitmap? LoadBitmap(Assembly assembly, string resourceSuffix) + { + var resourceName = assembly.GetManifestResourceNames() + .FirstOrDefault(x => x.Contains($"Assets.AppAssets.{resourceSuffix}")); + if (resourceName is null) + return null; + + using var stream = assembly.GetManifestResourceStream(resourceName); + return stream is null ? null : SKBitmap.Decode(stream); + } + + private static SKBitmap RotateBitmap(SKBitmap src, int degrees) + { + var isSwapped = degrees is 90 or 270; + var result = new SKBitmap(isSwapped ? src.Height : src.Width, isSwapped ? src.Width : src.Height); + + using var canvas = new SKCanvas(result); + canvas.Translate(result.Width / 2f, result.Height / 2f); + canvas.RotateDegrees(degrees); + canvas.Translate(-src.Width / 2f, -src.Height / 2f); + canvas.DrawBitmap(src, 0, 0); + + return result; + } + + private static SKBitmap FlipBitmap(SKBitmap src, bool horizontal, bool vertical) + { + var result = new SKBitmap(src.Width, src.Height); + using var canvas = new SKCanvas(result); + + var matrix = SKMatrix.CreateIdentity(); + if (horizontal) + matrix = matrix.PostConcat(SKMatrix.CreateScale(-1, 1, src.Width / 2f, 0)); + + if (vertical) + matrix = matrix.PostConcat(SKMatrix.CreateScale(1, -1, 0, src.Height / 2f)); + + canvas.SetMatrix(matrix); + canvas.DrawBitmap(src, 0, 0); + return result; + } + + private void SkiaCanvas_PaintSurface(object sender, SKPaintSurfaceEventArgs e) + { + var canvas = e.Surface.Canvas; + var info = e.Info; + canvas.Clear(SKColors.Transparent); + + var center = _smoothPosition != default ? _smoothPosition : _pointerPosition ?? new SKPoint(info.Width / 2f, info.Height / 2f); + var canvasRect = new SKRect(0, 0, info.Width, info.Height); + + // Layer 1: Hex (encrypted content) + if (_hexBitmap is not null) + { + var hexDest = ComputeUniformToFillRect(_hexBitmap.Width, _hexBitmap.Height, info.Width, info.Height); + canvas.Save(); + canvas.ClipRect(canvasRect); + canvas.DrawBitmap(_hexBitmap, hexDest); + canvas.Restore(); + } + + // Layer 2: Wallpaper + smooth reveal hole + if (_wallpaperBitmap is not null) + { + var wallpaperDest = ComputeUniformToFillRect(_wallpaperBitmap.Width, _wallpaperBitmap.Height, + info.Width, info.Height); + + canvas.SaveLayer(); + canvas.DrawBitmap(_wallpaperBitmap, wallpaperDest); + + 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], + SKShaderTileMode.Clamp); + + canvas.DrawCircle(center, MAGNIFIER_RADIUS, erasePaint); + canvas.Restore(); + } + + // Snapshot for the lens effect (only once per frame) + using var snapshot = e.Surface.Snapshot(); + + // 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; + } + + /// + /// Computes the ellipse radii for the glass ring, combining spring-scale squeeze + /// with velocity-driven directional squash-and-stretch. + /// + private (float rx, float ry) ComputeRingRadii(float baseRadius) + { + // Spring squeeze (uniform, from press/release) + // _lensScale < 1 = compressed, > 1 = expanded + // We invert slightly so a zoom-in squashes the ring outward at edges + var springSquash = 1f + (1f - _lensScale) * 0.55f; + var uniformR = baseRadius * springSquash; + + // Velocity squash-and-stretch + // Map speed to a deformation magnitude, capped so it doesn't go wild + var vx = _positionVelocity.X; + var vy = _positionVelocity.Y; + var speed = MathF.Sqrt(vx * vx + vy * vy); + + var deformX = 0f; + var deformY = 0f; + if (speed > 1f) + { + var raw = Math.Min(speed * VELOCITY_SCALE, MAX_DEFORM); + + // Direction: normalize velocity, project onto axes + var nx = vx / speed; + var ny = vy / speed; + + // Stretch along movement axis, squash perpendicular + deformX = raw * (nx * nx - ny * ny); // positive = wider when moving horizontally + deformY = raw * (ny * ny - nx * nx); // positive = taller when moving vertically + } + + 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); + var scaledW = srcWidth * scale; + var scaledH = srcHeight * scale; + var offsetX = (dstWidth - scaledW) / 2f; + var offsetY = (dstHeight - scaledH) / 2f; + + return new SKRect(offsetX, offsetY, offsetX + scaledW, offsetY + scaledH); + } + + private void AnimTimer_Tick(object? sender, object e) + { + var now = DateTime.UtcNow; + var dt = (float)(now - _lastTick).TotalSeconds; + _lastTick = now; + + // Clamp dt to avoid large jumps on frame drops + dt = Math.Min(dt, 0.05f); + + // Fluid pointer lerp + var lerpFactor = 1f - MathF.Exp(-LERP_SPEED * dt); + + var prevSmooth = _smoothPosition; + _smoothPosition = new SKPoint( + _smoothPosition.X + (_targetPosition.X - _smoothPosition.X) * lerpFactor, + _smoothPosition.Y + (_targetPosition.Y - _smoothPosition.Y) * lerpFactor + ); + + // Track velocity for directional ring deformation + if (dt > 0f) + { + _positionVelocity = new SKPoint( + (_smoothPosition.X - prevSmooth.X) / dt, + (_smoothPosition.Y - prevSmooth.Y) / dt + ); + } + + // Spring scale integration (damped harmonic oscillator) + var displacement = _lensScale - _scaleTarget; + var springForce = -SPRING_STIFFNESS * displacement; + var dampingForce = -SPRING_DAMPING * _scaleVelocity; + _scaleVelocity += (springForce + dampingForce) * dt; + _lensScale += _scaleVelocity * dt; + + // Determine if we've settled (stop timer to save CPU) + var positionSettled = MathF.Abs(_smoothPosition.X - _targetPosition.X) < 0.5f + && MathF.Abs(_smoothPosition.Y - _targetPosition.Y) < 0.5f; + var scaleSettled = MathF.Abs(_lensScale - _scaleTarget) < 0.001f + && MathF.Abs(_scaleVelocity) < 0.001f; + + if (positionSettled && scaleSettled) + { + _smoothPosition = _targetPosition; + _lensScale = _scaleTarget; + _scaleVelocity = 0f; + _positionVelocity = new SKPoint(0f, 0f); + 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; + + SkiaCanvas.Invalidate(); + } + + private void StartAnimation() + { + if (_isAnimating) + return; + + _lastTick = DateTime.UtcNow; + _isAnimating = true; + _animTimer.Start(); + } + + private void StopAnimation() + { + _isAnimating = false; + _animTimer.Stop(); + } + + private void SlotGrid_PointerPressed(object sender, PointerRoutedEventArgs e) + { + // Compress the lens on press + _scaleTarget = 0.9f; + _scaleVelocity = 0f; + StartAnimation(); + } + + private void SlotGrid_PointerReleased(object sender, PointerRoutedEventArgs e) + { + // Overshoot on release, then spring settles to 1.0 + _scaleTarget = 1.06f; + _scaleVelocity = 0f; + + // After a short delay, target moves to 1.0 - spring does the rest + Task.Delay(80).ContinueWith(_ => + { + _scaleTarget = 1f; + }); + StartAnimation(); + } + + private void SlotGrid_PointerMoved(object sender, PointerRoutedEventArgs e) + { + UpdatePointer(e); + } + + private void SlotGrid_PointerEntered(object sender, PointerRoutedEventArgs e) + { + UpdatePointer(e); + } + + private void SlotGrid_PointerExited(object sender, PointerRoutedEventArgs e) + { + _pointerPosition = null; + _lensScale = 1f; + _scaleVelocity = 0f; + _scaleTarget = 1f; + _positionVelocity = new SKPoint(0f, 0f); + StopAnimation(); + SkiaCanvas.Invalidate(); + } + + private void UpdatePointer(PointerRoutedEventArgs e) + { + var pos = e.GetCurrentPoint(SlotGrid).Position; + var width = (float)SlotGrid.ActualWidth; + var height = (float)SlotGrid.ActualHeight; + + if (width <= 0 || height <= 0) + return; + + var scaleX = SkiaCanvas.CanvasSize.Width / width; + var scaleY = SkiaCanvas.CanvasSize.Height / height; + + 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 newPosition = new SKPoint(clampedX * scaleX, clampedY * scaleY); + _targetPosition = newPosition; + + // Seed smooth position on the first enter so there's no pop from (0,0) + if (_pointerPosition == null) + _smoothPosition = newPosition; + + _pointerPosition = newPosition; + StartAnimation(); + } + + /// + public new void Dispose() + { + _animTimer.Stop(); + _animTimer.Tick -= AnimTimer_Tick; + +#if HAS_UNO_SKIA + SkiaCanvas.Dispose(); +#endif + + _blurPaint.Dispose(); + _highlightPaint.Dispose(); + _shadowPaint.Dispose(); + + _invisiblePaint.Dispose(); + _outerGlowPaint.Dispose(); + _iridescentPaint.Dispose(); + _corePaint.Dispose(); + _additiveGlowPaint.Dispose(); + + _cachedEdgeColors = null; + _cachedSweepStops = null; + _cachedCoreColors = null; + } + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FeatureSlide.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FeatureSlide.xaml index a74d46efb..5de7926e8 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FeatureSlide.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FeatureSlide.xaml @@ -12,13 +12,14 @@ - + HorizontalContentAlignment="Stretch" + VerticalContentAlignment="Stretch" + Content="{x:Bind LeftSlot, Mode=OneWay}" + Visibility="{x:Bind LeftSlot, Mode=OneWay, Converter={StaticResource NullToVisibilityConverter}}" /> + Content="{x:Bind RightSlot, Mode=OneWay}" + Visibility="{x:Bind RightSlot, Mode=OneWay, Converter={StaticResource NullToVisibilityConverter}}" /> diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FeatureSlide.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FeatureSlide.xaml.cs index 24d99b083..35f17c6c3 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FeatureSlide.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FeatureSlide.xaml.cs @@ -30,20 +30,20 @@ public string? Description public static readonly DependencyProperty DescriptionProperty = DependencyProperty.Register(nameof(Description), typeof(string), typeof(FeatureSlide), new PropertyMetadata(null)); - public ImageSource? ImageSource + public FrameworkElement? LeftSlot { - get => (ImageSource?)GetValue(ImageSourceProperty); - set => SetValue(ImageSourceProperty, value); + get => (FrameworkElement?)GetValue(LeftSlotProperty); + set => SetValue(LeftSlotProperty, value); } - public static readonly DependencyProperty ImageSourceProperty = - DependencyProperty.Register(nameof(ImageSource), typeof(ImageSource), typeof(FeatureSlide), new PropertyMetadata(null)); + public static readonly DependencyProperty LeftSlotProperty = + DependencyProperty.Register(nameof(LeftSlot), typeof(FrameworkElement), typeof(FeatureSlide), new PropertyMetadata(null)); - public FrameworkElement? Slot + public FrameworkElement? RightSlot { - get => (FrameworkElement?)GetValue(SlotProperty); - set => SetValue(SlotProperty, value); + get => (FrameworkElement?)GetValue(RightSlotProperty); + set => SetValue(RightSlotProperty, value); } - public static readonly DependencyProperty SlotProperty = - DependencyProperty.Register(nameof(Slot), typeof(FrameworkElement), typeof(FeatureSlide), new PropertyMetadata(null)); + public static readonly DependencyProperty RightSlotProperty = + DependencyProperty.Register(nameof(RightSlot), typeof(FrameworkElement), typeof(FeatureSlide), new PropertyMetadata(null)); } } diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml new file mode 100644 index 000000000..5f121b4e2 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml.cs new file mode 100644 index 000000000..170bd2da2 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Foundation.Collections; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; +using SecureFolderFS.Sdk.ViewModels.Controls.Components; +using SecureFolderFS.Sdk.ViewModels.Views.Overlays; +using SecureFolderFS.Shared.Extensions; + +// The User Control item template is documented at https://go.microsoft.com/fwlink/?LinkId=234236 + +namespace SecureFolderFS.Uno.UserControls.Introduction +{ + public sealed partial class FileSystemSlide : UserControl + { + public FileSystemSlide() + { + InitializeComponent(); + } + + private void FileSystems_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (OverlayViewModel is null) + return; + + if (sender is not ListView listView) + return; + + foreach (var item in OverlayViewModel.FileSystems) + item.IsSelected = false; + + var selectedItem = e.AddedItems.FirstOrDefault(); + if (selectedItem is ItemInstallationViewModel installation) + { + if (installation.IsInstalled) + { + installation.IsSelected = true; + OverlayViewModel.SelectedFileSystem = installation; + } + else + { + var oldSelected = e.RemovedItems.FirstOrDefault()?.TryCast(); + oldSelected?.IsSelected = true; + OverlayViewModel.SelectedFileSystem = oldSelected; + listView.SelectedItem = oldSelected; + } + } + else if (selectedItem is PickerOptionViewModel itemViewModel) + { + itemViewModel.IsSelected = true; + OverlayViewModel.SelectedFileSystem = itemViewModel; + } + } + + public IntroductionOverlayViewModel? OverlayViewModel + { + get => (IntroductionOverlayViewModel?)GetValue(OverlayViewModelProperty); + set => SetValue(OverlayViewModelProperty, value); + } + public static readonly DependencyProperty OverlayViewModelProperty = + DependencyProperty.Register(nameof(OverlayViewModel), typeof(IntroductionOverlayViewModel), typeof(FileSystemSlide), new PropertyMetadata(null)); + } +} + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml index 6acb0759c..760aa28a7 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml @@ -3,82 +3,15 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:ext="using:SecureFolderFS.Uno.Extensions" xmlns:l="using:SecureFolderFS.Uno.Localization" xmlns:local="using:SecureFolderFS.Uno.UserControls.Introduction" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:ts="using:SecureFolderFS.Uno.TemplateSelectors" - xmlns:tui="using:CommunityToolkit.WinUI" - xmlns:ucab="using:SecureFolderFS.Uno.UserControls.ActionBlocks" - xmlns:vm="using:SecureFolderFS.Sdk.ViewModels.Controls.Components" + xmlns:not_win="http://uno.ui/skia" + xmlns:win="http://schemas.microsoft.com/winfx/2006/xaml/presentation" PreviewKeyDown="IntroductionControl_KeyDown" - mc:Ignorable="d"> + mc:Ignorable="d not_win"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - + + - - - - - - - - + + - - - - - - - - - + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs index 35d5b2265..5efc980e9 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using Windows.System; @@ -7,10 +8,10 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; -using SecureFolderFS.Sdk.ViewModels.Controls.Components; using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Shared.Models; using SecureFolderFS.UI; using SecureFolderFS.UI.Enums; @@ -27,6 +28,7 @@ namespace SecureFolderFS.Uno.UserControls.Introduction public sealed partial class IntroductionControl : UserControl, IOverlayControl { private Grid? _overlayContainer; + private readonly FirstTimeHelper _firstTime = new(1); public IntroductionOverlayViewModel? ViewModel { @@ -76,6 +78,7 @@ public async Task ShowAsync() public void SetView(IViewable viewable) { ViewModel = (IntroductionOverlayViewModel)viewable; + ViewModel.PropertyChanged += ViewModel_PropertyChanged; if (ViewModel is { SlidesCount: < 0 }) ViewModel.SlidesCount = SlidesFlipView.Items.Count; } @@ -84,6 +87,9 @@ public void SetView(IViewable viewable) [RelayCommand] public async Task HideAsync() { + ViewModel?.PropertyChanged -= ViewModel_PropertyChanged; + EncryptedFileSlide.Dispose(); + // Play the hide animation await HideOverlayStoryboard.BeginAsync(); HideOverlayStoryboard.Stop(); @@ -98,6 +104,18 @@ public async Task HideAsync() ViewModel?.TaskCompletion.SetResult(Result.Success); } + + private async void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(IntroductionOverlayViewModel.CurrentIndex)) + return; + + if (ViewModel?.CurrentIndex == 2 && _firstTime.IsFirstTime()) + { + await Task.Delay(250); + await AuthenticationSlide.AnimateAsync(); + } + } private async void BackgroundWebView_Loaded(object sender, RoutedEventArgs e) { @@ -115,6 +133,7 @@ private async void BackgroundWebView_Loaded(object sender, RoutedEventArgs e) await BackgroundWebView.EnsureCoreWebView2Async(); BackgroundWebView.NavigateToString(htmlString); + await EncryptedFileSlide.InitAsync(); } private void IntroductionControl_KeyDown(object sender, KeyRoutedEventArgs e) @@ -151,39 +170,5 @@ private void IntroductionControl_KeyDown(object sender, KeyRoutedEventArgs e) } } } - - private void Selector_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (ViewModel is null) - return; - - if (sender is not ListView listView) - return; - - foreach (var item in ViewModel.FileSystems) - item.IsSelected = false; - - var selectedItem = e.AddedItems.FirstOrDefault(); - if (selectedItem is ItemInstallationViewModel installation) - { - if (installation.IsInstalled) - { - installation.IsSelected = true; - ViewModel.SelectedFileSystem = installation; - } - else - { - var oldSelected = e.RemovedItems.FirstOrDefault()?.TryCast(); - oldSelected?.IsSelected = true; - ViewModel.SelectedFileSystem = oldSelected; - listView.SelectedItem = oldSelected; - } - } - else if (selectedItem is PickerOptionViewModel itemViewModel) - { - itemViewModel.IsSelected = true; - ViewModel.SelectedFileSystem = itemViewModel; - } - } } } diff --git a/src/Platforms/SecureFolderFS.Uno/ValueConverters/ImageToSourceConverter.cs b/src/Platforms/SecureFolderFS.Uno/ValueConverters/ImageToSourceConverter.cs new file mode 100644 index 000000000..45349a253 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/ValueConverters/ImageToSourceConverter.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media.Imaging; +using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.Uno.AppModels; + +namespace SecureFolderFS.Uno.ValueConverters +{ + public sealed class ImageToSourceConverter : IValueConverter + { + /// + public object? Convert(object value, Type targetType, object parameter, string language) + { + return value switch + { + ImageBitmap imageBitmap => imageBitmap.Source, + StreamImageModel imageStream => StreamToImageSource(imageStream.Inner), + ImageResource resourceImage => new BitmapImage(resourceImage.IsResource + ? new Uri($"ms-appx:///{resourceImage.Name}") + : new Uri(resourceImage.Name)), + + Uri uri => new BitmapImage(uri), + _ => null + }; + } + + /// + public object? ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + + private static BitmapImage? StreamToImageSource(Stream? stream) + { + if (stream is null) + return null; + + stream.TrySetPositionOrAdvance(0L); + var bitmapImage = new BitmapImage(); + var randomAccessStream = stream.AsRandomAccessStream(); + + // BitmapImage.SetSourceAsync is async but IValueConverter is sync. + // Fire-and-forget is intentional here — the image will load asynchronously. + _ = bitmapImage.SetSourceAsync(randomAccessStream).AsTask().ConfigureAwait(false); + + return bitmapImage; + } + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/ViewModels/DeviceLink/DeviceLinkLoginViewModel.cs b/src/Platforms/SecureFolderFS.Uno/ViewModels/DeviceLink/DeviceLinkLoginViewModel.cs index 36aa54088..7f96faf7c 100644 --- a/src/Platforms/SecureFolderFS.Uno/ViewModels/DeviceLink/DeviceLinkLoginViewModel.cs +++ b/src/Platforms/SecureFolderFS.Uno/ViewModels/DeviceLink/DeviceLinkLoginViewModel.cs @@ -2,7 +2,6 @@ using System.ComponentModel; using System.Threading; using System.Threading.Tasks; -using CommunityToolkit.Mvvm.ComponentModel; using OwlCore.Storage; using SecureFolderFS.Core.VaultAccess; using SecureFolderFS.Sdk.EventArguments; diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultOverviewPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultOverviewPage.xaml index 010a0ad45..3394a991e 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultOverviewPage.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultOverviewPage.xaml @@ -166,6 +166,9 @@ { Inner: var specifics }) + return; + + var deferral = e.GetDeferral(); + try + { + // We only want to get the first item + var droppedItems = await e.DataView.GetStorageItemsAsync().AsTask(); + var item = droppedItems.FirstOrDefault(); + if (item is null) + return; + + var itemPath = item.Path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var vaultFolderId = ViewModel.VaultViewModel.VaultModel.VaultFolder.Id.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var virtualizedRootId = storageRoot.VirtualizedRoot.Id.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + IStorable? ciphertextItem = null; + if (itemPath.Contains(vaultFolderId)) + { + // Ciphertext path + ciphertextItem = item switch + { + IStorageFile => new SystemFileEx(itemPath), + IStorageFolder => new SystemFolderEx(itemPath), + _ => null + }; + } + else if (itemPath.Contains(virtualizedRootId)) + { + var pathRoot = Path.GetPathRoot(itemPath) ?? string.Empty; + var relativePath = $"{Path.DirectorySeparatorChar}{itemPath.Replace(pathRoot, string.Empty)}"; + ciphertextItem = await storageRoot.PlaintextRoot.GetItemByRelativePathAsync(relativePath) switch + { + IWrapper fileWrapper => fileWrapper.Inner, + IWrapper folderWrapper => folderWrapper.Inner, + _ => null + }; + } + + if (ciphertextItem is not IStorableChild ciphertextChild) + return; + + var plaintextPath = await AbstractPathHelpers.GetPlaintextPathAsync(ciphertextChild, specifics, CancellationToken.None); + if (string.IsNullOrEmpty(plaintextPath)) + return; + + var plaintextItem = await storageRoot.PlaintextRoot.GetItemByRelativePathAsync(plaintextPath); + _ = ShowItemDialogAsync(); + + async Task ShowItemDialogAsync() + { + var viewModel = new VaultItemInfoOverlayViewModel(ciphertextItem, plaintextItem); + await viewModel.InitAsync(); + + var overlayService = DI.Service(); + _ = overlayService.ShowAsync(viewModel); + } + } + catch (Exception) + { + } + finally + { + e.Handled = true; + deferral.Complete(); + } + } } } diff --git a/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/MainWizardPage.xaml.cs b/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/MainWizardPage.xaml.cs index 942f51710..5b53fa291 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/MainWizardPage.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/Views/VaultWizard/MainWizardPage.xaml.cs @@ -25,7 +25,6 @@ public sealed partial class MainWizardPage : Page { private PickerSourceWizardViewModel? _createNewViewModel; private PickerSourceWizardViewModel? _addExistingViewModel; - private Button? _lastClickedButton; [ObservableProperty] private PickerSourceWizardViewModel? _CurrentViewModel; diff --git a/src/Sdk/SecureFolderFS.Sdk.Ftp/SecureFolderFS.Sdk.Ftp.csproj b/src/Sdk/SecureFolderFS.Sdk.Ftp/SecureFolderFS.Sdk.Ftp.csproj index fe4c93ab1..34f114bd9 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Ftp/SecureFolderFS.Sdk.Ftp.csproj +++ b/src/Sdk/SecureFolderFS.Sdk.Ftp/SecureFolderFS.Sdk.Ftp.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/SecureFolderFS.Sdk.GoogleDrive.csproj b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/SecureFolderFS.Sdk.GoogleDrive.csproj index f36974778..399581dac 100644 --- a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/SecureFolderFS.Sdk.GoogleDrive.csproj +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/SecureFolderFS.Sdk.GoogleDrive.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveStorable.cs b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveStorable.cs index 7c7a8bb09..4f04b1530 100644 --- a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveStorable.cs +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveStorable.cs @@ -8,6 +8,8 @@ namespace SecureFolderFS.Sdk.GoogleDrive.Storage { public abstract class GDriveStorable : IStorableChild { + public const string ROOT_ID = "root"; + /// /// Gets the ID of the object that can exist independently of the parent context. /// @@ -39,7 +41,13 @@ protected GDriveStorable(DriveService driveService, string id, string name, IFol Id = id; Name = name; ParentFolder = parent; - DetachedId = Path.GetFileName(Id); + + // Google Drive root can be represented as an empty path segment in app code. + // Normalize it so API queries always use the canonical root alias. + var normalizedId = Id.TrimEnd('/'); + DetachedId = string.IsNullOrEmpty(normalizedId) + ? ROOT_ID + : Path.GetFileName(normalizedId); } /// diff --git a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs index 18f4838e3..14a45a111 100644 --- a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/ViewModels/GDriveAccountViewModel.cs @@ -305,7 +305,7 @@ private async Task AuthorizeAsync(string userId, bool forcePromp private static IFolder GetRootGDriveFolder(DriveService driveService) { - return new GDriveFolder(driveService, string.Empty, "Drive"); + return new GDriveFolder(driveService, GDriveStorable.ROOT_ID, "Drive"); } public static void AssertApiAccess() diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs index 902f385d4..223c39560 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/ThumbnailCacheModel.cs @@ -20,7 +20,7 @@ public sealed class ThumbnailCacheModel : IDisposable /// /// Gets the default maximum number of cached thumbnails. /// - public const int DEFAULT_MAX_ENTRIES = 100; + public const int DEFAULT_MAX_ENTRIES = 150; private readonly IDatabaseModel _database; @@ -45,20 +45,18 @@ public ThumbnailCacheModel(int maxEntries) /// Tries to get a cached thumbnail for the specified file. /// The cache key includes the file's modification date, so modified files automatically get new thumbnails. /// - /// The file to get the cached thumbnail for. + /// A unique cache ID. /// A that cancels this action. /// A that represents the asynchronous operation. Value is the cached thumbnail stream if found, otherwise null. - public async Task TryGetCachedThumbnailAsync(IFile file, CancellationToken cancellationToken = default) + public async Task TryGetCachedThumbnailAsync(string cacheKey, CancellationToken cancellationToken = default) { try { - var cacheKey = await GetCacheKeyAsync(file, cancellationToken); var cachedData = await _database.GetValueAsync(cacheKey, cancellationToken: cancellationToken); - if (cachedData is null || cachedData.Length == 0) return null; - return new MemoryStream(cachedData); + return new MemoryStream(cachedData, writable: false); } catch { @@ -70,20 +68,22 @@ public ThumbnailCacheModel(int maxEntries) /// Caches the thumbnail for the specified file. /// The cache key includes the file's modification date, ensuring modified files get fresh thumbnails. /// - /// The file to cache the thumbnail for. + /// A unique cache ID. /// The thumbnail stream to cache. /// A that cancels this action. /// A that represents the asynchronous operation. - public async Task CacheThumbnailAsync(IFile file, IImageStream thumbnailStream, CancellationToken cancellationToken = default) + public async Task CacheThumbnailAsync(string cacheKey, IImageStream thumbnailStream, CancellationToken cancellationToken = default) { try { - var cacheKey = await GetCacheKeyAsync(file, cancellationToken); - // Copy thumbnail to byte array - using var memoryStream = new MemoryStream(); - await thumbnailStream.CopyToAsync(memoryStream, cancellationToken); - var data = memoryStream.ToArray(); + var data = new byte[thumbnailStream.Inner.Length]; + var savedPosition = thumbnailStream.Inner.Position; + thumbnailStream.Inner.Position = 0L; + var read = await thumbnailStream.Inner.ReadAsync(data, cancellationToken); + thumbnailStream.Inner.Position = savedPosition; + if (read != data.Length) + return; await _database.SetValueAsync(cacheKey, data, cancellationToken); } @@ -111,14 +111,13 @@ public Task ClearCacheAsync(CancellationToken cancellationToken = default) /// The file to generate a cache key for. /// A that cancels this action. /// A unique cache key string. - private static async Task GetCacheKeyAsync(IFile file, CancellationToken cancellationToken) + public static async Task GetCacheKeyAsync(IFile file, CancellationToken cancellationToken) { var pathHash = GetPathHash(file.Id); var dateModified = await file.GetDateModifiedAsync(cancellationToken); if (dateModified is null) return pathHash; - // Combine path hash with modification date for the cache key // Format: {pathHash}_{dateModifiedTicks} return $"{pathHash}_{dateModified.Value.Ticks}"; diff --git a/src/Sdk/SecureFolderFS.Sdk/Constants.cs b/src/Sdk/SecureFolderFS.Sdk/Constants.cs index df8de68c6..6ee498e7e 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Constants.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Constants.cs @@ -16,9 +16,15 @@ public static class Widgets public static class Graphs { - public const int MAX_GRAPH_POINTS = 30; - public const int GRAPH_UPDATE_INTERVAL_MS = 200; // 0.2s - public const int GRAPH_REFRESH_RATE = 1000 / GRAPH_UPDATE_INTERVAL_MS; + public const int MAX_POINTS = 30; + public const int UPDATE_INTERVAL_MS = 200; + public const int REFRESH_RATE = 1000 / UPDATE_INTERVAL_MS; + } + + public static class AggregatedData + { + public const int UPDATE_INTERVAL_MS = 400; + public const int REFRESH_RATE = 4; } public static class Health diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/IVaultService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/IVaultService.cs index 3f60ae457..ca5732c8f 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/IVaultService.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/IVaultService.cs @@ -22,9 +22,9 @@ public interface IVaultService // TODO: Move some of the methods to IVaultModel? string ContentFolderName { get; } /// - /// The file extension for vault shortcut files. + /// Gets the file extension for vault shortcut files. /// - public string ShortcutFileExtension { get; } + string ShortcutFileExtension { get; } /// /// Gets the of type used to validate vaults. diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IAppSettings.cs b/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IAppSettings.cs index 5a343b7c5..3a64ba6af 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IAppSettings.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IAppSettings.cs @@ -9,11 +9,6 @@ namespace SecureFolderFS.Sdk.Services.Settings /// public interface IAppSettings : IPersistable, INotifyPropertyChanged { - /// - /// Gets or sets the value that determines whether the (first) notification about the beta program was shown. - /// - bool WasBetaNotificationShown1 { get; set; } - /// /// Gets or sets the value that determines whether the explanation of vault folder was shown. /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/FileSystemItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/FileSystemItemViewModel.cs index 90c97d8b8..66a0df300 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/FileSystemItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/FileSystemItemViewModel.cs @@ -9,11 +9,6 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Components public sealed partial class FileSystemItemViewModel(IFileSystemInfo fileSystem) : PickerOptionViewModel(fileSystem.Id, fileSystem.Name) { - /// - /// Gets or sets the icon image associated with this instance. - /// - [ObservableProperty] private IImage? _Icon; - /// /// Gets or sets a value indicating whether this item should be interpreted as the default one. /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/ItemInstallationViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/ItemInstallationViewModel.cs index 2305085fd..764ead7e6 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/ItemInstallationViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/ItemInstallationViewModel.cs @@ -12,7 +12,6 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Components [Bindable(true)] public abstract partial class ItemInstallationViewModel(string id, string? title) : PickerOptionViewModel(id, title), IAsyncInitialize, IProgress, IProgress, INotifyStateChanged { - [ObservableProperty] private IImage? _Icon; [ObservableProperty] private bool _IsInstalled; [ObservableProperty] private bool _IsProgressing; [ObservableProperty] private bool _IsIndeterminate; @@ -39,7 +38,6 @@ public virtual void Report(IResult? value) /// public abstract Task InitAsync(CancellationToken cancellationToken = default); - [RelayCommand] protected abstract Task InstallAsync(CancellationToken cancellationToken); } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/PickerOptionViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/PickerOptionViewModel.cs index 2b2a94ecb..c7d97c890 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/PickerOptionViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Components/PickerOptionViewModel.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Sdk.ViewModels.Controls.Components @@ -9,6 +10,11 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Components [Bindable(true)] public partial class PickerOptionViewModel : SelectableItemViewModel { + /// + /// Gets or sets the icon image associated with this instance. + /// + [ObservableProperty] private IImage? _Icon; + /// /// Gets the unique ID associated with this option. /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs index 4ba66958f..9c6a7e964 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using OwlCore.Storage; using SecureFolderFS.Sdk.AppModels; @@ -29,6 +30,9 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser [Bindable(true)] public abstract partial class BrowserItemViewModel : StorageItemViewModel, IAsyncInitialize { + [ObservableProperty] private string? _SizeText; + [ObservableProperty] private DateTime? _LastModified; + /// /// Gets the instance, which this item belongs to. /// @@ -229,17 +233,19 @@ protected virtual async Task RenameAsync(CancellationToken cancellationToken) if (string.IsNullOrWhiteSpace(viewModel.NewName)) return; - var formattedName = CollisionHelpers.GetAvailableName( - FormattingHelpers.SanitizeItemName(viewModel.NewName, "Renamed item"), - ParentFolder.Items.Select(x => x.Inner.Name)); - + var formattedName = FormattingHelpers.SanitizeItemName(viewModel.NewName, "Renamed item"); if (!Path.HasExtension(formattedName)) formattedName = $"{formattedName}{Path.GetExtension(innerChild.Name)}"; var existingItem = await renamableFolder.TryGetFirstByNameAsync(formattedName, cancellationToken); if (existingItem is not null) { - // TODO: Report that the item already exists + await OverlayService.ShowAsync(new MessageOverlayViewModel() + { + Title = "InvalidItemName".ToLocalized(), + Message = "ItemAlreadyExists".ToLocalized(formattedName), + SecondaryText = "Close".ToLocalized() + }); return; } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs index e34f67eaa..4f0282e0f 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs @@ -1,8 +1,11 @@ -using System.ComponentModel; +using System; +using System.ComponentModel; using System.IO; using System.Threading; using System.Threading.Tasks; +using ByteSizeLib; using OwlCore.Storage; +using SecureFolderFS.Sdk.AppModels; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.Extensions; @@ -14,6 +17,7 @@ using SecureFolderFS.Shared.Enums; using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage.Extensions; namespace SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser { @@ -21,6 +25,8 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser [Bindable(true)] public partial class FileViewModel : BrowserItemViewModel { + private Task? _fileLoadingTask; + /// public override IStorable Inner => File; @@ -48,31 +54,52 @@ public FileViewModel(IFile file, BrowserViewModel browserViewModel, FolderViewMo /// public override async Task InitAsync(CancellationToken cancellationToken = default) { - Thumbnail?.Dispose(); + // Deduplicate concurrent calls to prevent duplicate calls + if (_fileLoadingTask is { IsCompleted: false }) + { + try + { + await _fileLoadingTask; + return; + } + catch (OperationCanceledException) + { + // The previous load was canceled (e.g., navigation) - start a fresh one with the new token + } + } - if (!SettingsService.UserSettings.AreThumbnailsEnabled) - return; + _fileLoadingTask = PerformFileLoadAsync(cancellationToken); + await _fileLoadingTask; + } + + private async Task PerformFileLoadAsync(CancellationToken cancellationToken) + { + var size = await File.GetSizeAsync(cancellationToken); + SizeText = size.HasValue ? ByteSize.FromBytes(size.Value).ToString() : null; + LastModified = await File.GetDateModifiedAsync(cancellationToken); - if (!CanLoadThumbnail()) + if (!SettingsService.UserSettings.AreThumbnailsEnabled || !CanLoadThumbnail()) return; // Try to get from the cache first - var cachedStream = await BrowserViewModel.ThumbnailCache.TryGetCachedThumbnailAsync(File, cancellationToken); + Thumbnail?.Dispose(); + var cacheKey = await ThumbnailCacheModel.GetCacheKeyAsync(File, cancellationToken); + var cachedStream = await BrowserViewModel.ThumbnailCache.TryGetCachedThumbnailAsync(cacheKey, cancellationToken); if (cachedStream is not null) { Thumbnail = new StreamImageModel(cachedStream); return; } + cancellationToken.ThrowIfCancellationRequested(); // Generate a new thumbnail - cancellationToken.ThrowIfCancellationRequested(); var generatedThumbnail = await MediaService.TryGenerateThumbnailAsync(File, Classification.TypeHint, cancellationToken); if (generatedThumbnail is null) return; // Show and cache the generated thumbnail Thumbnail = generatedThumbnail; - _ = BrowserViewModel.ThumbnailCache.CacheThumbnailAsync(File, generatedThumbnail, cancellationToken); + _ = BrowserViewModel.ThumbnailCache.CacheThumbnailAsync(cacheKey, generatedThumbnail, cancellationToken); } /// @@ -128,18 +155,19 @@ protected override async Task OpenAsync(CancellationToken cancellationToken) if (!result.Positive()) return; - if (BrowserViewModel.TransferViewModel is not { } transferViewModel) + if (BrowserViewModel.TransferViewModel is { } transferViewModel) { - await persistable.SaveAsync(cancellationToken); - return; + transferViewModel.TransferType = TransferType.Save; + using var saveCancellation = transferViewModel.GetCancellation(); + await transferViewModel.PerformOperationAsync(async ct => + { + await persistable.SaveAsync(ct); + }, saveCancellation.Token); } + else + await persistable.SaveAsync(cancellationToken); - transferViewModel.TransferType = TransferType.Save; - using var saveCancellation = transferViewModel.GetCancellation(); - await transferViewModel.PerformOperationAsync(async ct => - { - await persistable.SaveAsync(ct); - }, saveCancellation.Token); + await PerformFileLoadAsync(cancellationToken); } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs index a31ba3f9f..d5958017f 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Services; @@ -13,10 +14,11 @@ using SecureFolderFS.Shared.Enums; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Helpers; +using SecureFolderFS.Storage.Extensions; namespace SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser { - [Inject] + [Inject, Inject] [Bindable(true)] public partial class FolderViewModel : BrowserItemViewModel, IViewDesignation { @@ -49,10 +51,9 @@ public FolderViewModel(IFolder folder, BrowserViewModel browserViewModel, Folder } /// - public override Task InitAsync(CancellationToken cancellationToken = default) + public override async Task InitAsync(CancellationToken cancellationToken = default) { - // TODO: Load thumbnail - return Task.CompletedTask; + LastModified = await Folder.GetDateModifiedAsync(cancellationToken); } /// @@ -76,12 +77,13 @@ public virtual void OnDisappearing() /// A that represents the asynchronous operation. public async Task ListContentsAsync(CancellationToken cancellationToken = default) { + var scope = Logger.GetPerformanceScope(); SelectedItems.Clear(); Items.DisposeAll(); Items.Clear(); var isPickingFolder = BrowserViewModel.TransferViewModel?.IsPickingFolder ?? false; - var items = await Folder.GetItemsAsync(StorableType.All, cancellationToken).ToArrayAsyncImpl(cancellationToken: cancellationToken); + var items = await Folder.GetItemsAsync(isPickingFolder ? StorableType.Folder : StorableType.All, cancellationToken).ToArrayAsyncImpl(cancellationToken: cancellationToken); BrowserViewModel.Layouts.GetSorter().SortCollection(items.Where(x => !isPickingFolder || x is IFolder).Select(x => (BrowserItemViewModel)(x switch { IFile file => new FileViewModel(file, BrowserViewModel, this), @@ -92,6 +94,8 @@ public async Task ListContentsAsync(CancellationToken cancellationToken = defaul // Apply adaptive layout if (SettingsService.UserSettings.IsAdaptiveLayoutEnabled && BrowserViewModel.TransferViewModel is { IsPickingFolder: false }) ApplyAdaptiveLayout(); + + Logger.LogPerformance(scope, minThresholdMs: 200); } /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs index fa9d46571..dfb46110e 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs @@ -67,7 +67,8 @@ public async Task InitAsync(CancellationToken cancellationToken = default) if (!CanLoadThumbnail() || Inner is not IFile file || Classification is not { TypeHint: var typeHint }) return; - var cachedStream = await _thumbnailCache.TryGetCachedThumbnailAsync(file, cancellationToken).ConfigureAwait(false); + var cacheKey = await ThumbnailCacheModel.GetCacheKeyAsync(file, cancellationToken).ConfigureAwait(false); + var cachedStream = await _thumbnailCache.TryGetCachedThumbnailAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (cachedStream is not null) { await _uiContext.PostOrExecuteAsync(() => @@ -89,7 +90,7 @@ await _uiContext.PostOrExecuteAsync(() => return Task.CompletedTask; }); - _ = _thumbnailCache.CacheThumbnailAsync(file, generatedThumbnail, cancellationToken); + _ = _thumbnailCache.CacheThumbnailAsync(cacheKey, generatedThumbnail, cancellationToken); } public bool CanLoadThumbnail() diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs index 113e0ca8a..397ebeb41 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs @@ -76,7 +76,7 @@ private async Task MoveItemAsync(string? direction, CancellationToken cancellati UpdateCanMove(); // Save after move - await _vaultCollectionModel.SaveAsync(cancellationToken); + await _vaultCollectionModel.TrySaveAsync(cancellationToken); } [RelayCommand] diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs index 931fbc827..c38523312 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs @@ -28,6 +28,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.VaultList public sealed partial class VaultListViewModel : ObservableObject, IAsyncInitialize { private readonly IVaultCollectionModel _vaultCollectionModel; + private bool _ignoreCollectionChanged; [ObservableProperty] private bool _HasVaults; [ObservableProperty] private VaultListItemViewModel? _SelectedItem; @@ -63,6 +64,33 @@ public Task InitAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } + /// + /// Syncs the order of the vaults with the internal model. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. + public async Task SyncOrderAsync(CancellationToken cancellationToken = default) + { + try + { + _ignoreCollectionChanged = true; + foreach (var item in Items) + { + var currentIndex = _vaultCollectionModel.IndexOf(item.VaultViewModel.VaultModel); + var targetIndex = Items.IndexOf(item); + + if (currentIndex != targetIndex) + _vaultCollectionModel.Move(currentIndex, targetIndex); + } + + await _vaultCollectionModel.TrySaveAsync(cancellationToken); + } + finally + { + _ignoreCollectionChanged = false; + } + } + [RelayCommand(AllowConcurrentExecutions = true)] private async Task AddNewVaultAsync(IFolder? folder, CancellationToken cancellationToken) { @@ -135,6 +163,9 @@ private void RemoveVault(IVaultModel vaultModel) private void VaultCollectionModel_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { + if (_ignoreCollectionChanged) + return; + switch (e.Action) { case NotifyCollectionChangedAction.Add when e.NewItems is not null && e.NewItems[0] is IVaultModel vaultModel: diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/AggregatedDataWidgetViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/AggregatedDataWidgetViewModel.cs index cd7088cfb..fa6024199 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/AggregatedDataWidgetViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/AggregatedDataWidgetViewModel.cs @@ -21,15 +21,18 @@ public sealed partial class AggregatedDataWidgetViewModel : BaseWidgetViewModel private ulong _pendingBytesWritten; private ByteSize _bytesRead; private ByteSize _bytesWritten; + private int _updateTicks; [ObservableProperty] private string? _TotalRead; [ObservableProperty] private string? _TotalWrite; + [ObservableProperty] private bool _IsReading; + [ObservableProperty] private bool _IsWriting; public AggregatedDataWidgetViewModel(UnlockedVaultViewModel unlockedVaultViewModel, IWidgetModel widgetModel) : base(widgetModel) { _fileSystemStatistics = unlockedVaultViewModel.StorageRoot.Options.FileSystemStatistics; - _periodicTimer = new(TimeSpan.FromMilliseconds(Constants.Widgets.Graphs.GRAPH_UPDATE_INTERVAL_MS)); + _periodicTimer = new(TimeSpan.FromMilliseconds(Constants.Widgets.AggregatedData.UPDATE_INTERVAL_MS)); Title = "AggregatedDataWidget".ToLocalized(); } @@ -46,14 +49,20 @@ public override Task InitAsync(CancellationToken cancellationToken = default) { _bytesReadSubscription = subscriber.SubscribeToBytesRead(new Progress(x => { - if (x > 0) - _pendingBytesRead += (ulong)x; + if (x <= 0L) + return; + + IsReading = true; + _pendingBytesRead += (ulong)x; })); _bytesWrittenSubscription = subscriber.SubscribeToBytesWritten(new Progress(x => { - if (x > 0) - _pendingBytesWritten += (ulong)x; + if (x <= 0L) + return; + + IsWriting = true; + _pendingBytesWritten += (ulong)x; })); } @@ -80,6 +89,14 @@ private async Task InitializeBlockingTimer(CancellationToken cancellationToken) TotalWrite = _bytesWritten.ToString().Replace(" ", string.Empty); _pendingBytesWritten = 0UL; } + + _updateTicks++; + if (_updateTicks >= Constants.Widgets.AggregatedData.REFRESH_RATE) + { + _updateTicks = 0; + IsReading = false; + IsWriting = false; + } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphControlViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphControlViewModel.cs index 96ba2a774..04e2f68c8 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphControlViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphControlViewModel.cs @@ -29,7 +29,7 @@ public void Report(double value) /// public Task InitAsync(CancellationToken cancellationToken = default) { - for (var i = 0; i < Constants.Widgets.Graphs.MAX_GRAPH_POINTS; i++) + for (var i = 0; i < Constants.Widgets.Graphs.MAX_POINTS; i++) Data.Add(0d); return Task.CompletedTask; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphsWidgetViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphsWidgetViewModel.cs index a56269d12..49b9ab368 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphsWidgetViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphsWidgetViewModel.cs @@ -39,7 +39,7 @@ public GraphsWidgetViewModel(UnlockedVaultViewModel unlockedVaultViewModel, IWid WriteGraphViewModel = new(); _fileSystemStatistics = unlockedVaultViewModel.StorageRoot.Options.FileSystemStatistics; - _periodicTimer = new(TimeSpan.FromMilliseconds(Constants.Widgets.Graphs.GRAPH_UPDATE_INTERVAL_MS)); + _periodicTimer = new(TimeSpan.FromMilliseconds(Constants.Widgets.Graphs.UPDATE_INTERVAL_MS)); _readRates = [ 0 ]; _writeRates = [ 0 ]; } @@ -68,8 +68,8 @@ private async Task InitializeBlockingTimer(CancellationToken cancellationToken) if (!IsActive) continue; - _readRates.AddWithMaxCapacity(_currentReadAmount, Constants.Widgets.Graphs.GRAPH_REFRESH_RATE); - _writeRates.AddWithMaxCapacity(_currentWriteAmount, Constants.Widgets.Graphs.GRAPH_REFRESH_RATE); + _readRates.AddWithMaxCapacity(_currentReadAmount, Constants.Widgets.Graphs.REFRESH_RATE); + _writeRates.AddWithMaxCapacity(_currentWriteAmount, Constants.Widgets.Graphs.REFRESH_RATE); CalculateStatistics(); } @@ -93,7 +93,7 @@ private void CalculateStatistics() _currentWriteAmount = 0; _updateTimeCount++; - if (_updateTimeCount == Constants.Widgets.Graphs.GRAPH_REFRESH_RATE) + if (_updateTimeCount == Constants.Widgets.Graphs.REFRESH_RATE) { _updateTimeCount = 0; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/MainHostViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/MainHostViewModel.cs index 5254ed690..79363719d 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/MainHostViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/MainHostViewModel.cs @@ -16,10 +16,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using SecureFolderFS.Sdk.Enums; namespace SecureFolderFS.Sdk.ViewModels.Views.Host { - [Inject(Visibility = "public"), Inject, Inject] + [Inject(Visibility = "public"), Inject, Inject, Inject] [Bindable(true)] public sealed partial class MainHostViewModel : BaseDesignationViewModel, IAsyncInitialize, IDisposable { @@ -53,8 +54,16 @@ private async Task OpenSettingsAsync() } [RelayCommand(AllowConcurrentExecutions = true)] - private async Task OpenVaultCredentialsAsync() + private async Task OpenDeviceLinkAsync(CancellationToken cancellationToken) { + // Check Iap Plus requirement + if (!await IapService.IsOwnedAsync(IapProductType.Any, cancellationToken)) + { + await OverlayService.ShowAsync(PaymentOverlayViewModel.Instance.WithInitAsync(cancellationToken)); + if (!await IapService.IsOwnedAsync(IapProductType.Any, cancellationToken)) + return; + } + await OverlayService.ShowAsync(DeviceLinkCredentialsOverlayViewModel.Instance); } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs index 6b5708aab..f1881c712 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs @@ -65,6 +65,9 @@ public async Task InitAsync(CancellationToken cancellationToken = default) foreach (var sortedItem in items.OrderByDescending(x => x is FileSystemItemViewModel)) FileSystems.Add(sortedItem); + + SelectedFileSystem = FileSystems.FirstOrDefault(x => x.Id == SettingsService.UserSettings.PreferredFileSystemId); + SelectedFileSystem ??= FileSystems.FirstOrDefault(); } public bool Next() diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PropertiesOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PropertiesOverlayViewModel.cs index aaac6eca8..0418e2dfc 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PropertiesOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PropertiesOverlayViewModel.cs @@ -11,6 +11,7 @@ using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.StorageProperties; @@ -20,7 +21,9 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays [Inject, Inject, Inject] public sealed partial class PropertiesOverlayViewModel : OverlayViewModel, IWrapper, IAsyncInitialize, IDisposable { + [ObservableProperty] private string? _Id; [ObservableProperty] private string? _SizeText; + [ObservableProperty] private string? _CiphertextId; [ObservableProperty] private string? _FileTypeText; [ObservableProperty] private string? _DateCreatedText; [ObservableProperty] private string? _DateModifiedText; @@ -65,6 +68,14 @@ public async Task InitAsync(CancellationToken cancellationToken = default) if (createdAtDate is not null) DateCreatedText = LocalizationService.LocalizeDate(createdAtDate.Value); } + + Id = Inner.Id; + CiphertextId = Inner switch + { + IFolder => Inner.AsWrapper().GetWrapperAt("CryptoFolder").Inner.Id, + IFile => Inner.AsWrapper().GetWrapperAt("CryptoFile").Inner.Id, + _ => CiphertextId + }; } [RelayCommand] @@ -75,6 +86,8 @@ private async Task CopyPropertyAsync(string? propertyName, CancellationToken can "ItemType" => FileTypeText, "DateModified" => DateModifiedText, "Size" => SizeText, + "Path" => Id, + "EncryptedPath" => CiphertextId, _ => null }; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/VaultItemInfoOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/VaultItemInfoOverlayViewModel.cs new file mode 100644 index 000000000..748c93d73 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/VaultItemInfoOverlayViewModel.cs @@ -0,0 +1,68 @@ +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using ByteSizeLib; +using CommunityToolkit.Mvvm.ComponentModel; +using OwlCore.Storage; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Storage.Extensions; + +namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays +{ + [Bindable(true)] + public sealed partial class VaultItemInfoOverlayViewModel : OverlayViewModel, IAsyncInitialize + { + private readonly IStorable _ciphertextItem; + private readonly IStorable _plaintextItem; + + [ObservableProperty] private string? _PlaintextSize; + [ObservableProperty] private string? _PlaintextFullSize; + [ObservableProperty] private string? _CiphertextSize; + [ObservableProperty] private string? _CiphertextFullSize; + [ObservableProperty] private string? _PlaintextName; + [ObservableProperty] private string? _CiphertextName; + [ObservableProperty] private string? _PlaintextPath; + [ObservableProperty] private string? _CiphertextPath; + [ObservableProperty] private bool _IsFile; + + public VaultItemInfoOverlayViewModel(IStorable ciphertextItem, IStorable plaintextItem) + { + IsFile = ciphertextItem is IFile; + _ciphertextItem = ciphertextItem; + _plaintextItem = plaintextItem; + } + + /// + public async Task InitAsync(CancellationToken cancellationToken = default) + { + CiphertextPath = _ciphertextItem.Id; + CiphertextName = _ciphertextItem.Name; + PlaintextPath = _plaintextItem.Id; + PlaintextName = _plaintextItem.Name; + + if (!IsFile) + return; + + var ciphertextSize = await ((IFile)_ciphertextItem).GetSizeAsync(cancellationToken); + var plaintextSize = await ((IFile)_plaintextItem).GetSizeAsync(cancellationToken); + + if (ciphertextSize.HasValue) + { + CiphertextSize = ByteSize.FromBytes(ciphertextSize.Value).ToString(); + CiphertextFullSize = $"{ciphertextSize.Value} B"; + } + + if (plaintextSize.HasValue) + { + PlaintextSize = ByteSize.FromBytes(plaintextSize.Value).ToString(); + PlaintextFullSize = $"{plaintextSize.Value} B"; + + if (ciphertextSize.HasValue) + { + var difference = ciphertextSize.Value - plaintextSize.Value; + CiphertextSize += $" (+{ByteSize.FromBytes(difference)})"; + } + } + } + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/WizardOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/WizardOverlayViewModel.cs index f5f74c175..08776240e 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/WizardOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/WizardOverlayViewModel.cs @@ -16,7 +16,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays [Bindable(true)] public sealed partial class WizardOverlayViewModel : OverlayViewModel, IStagingView, INavigatable, IDisposable { - [ObservableProperty] private IStagingView? _CurrentViewModel; + [ObservableProperty] private IViewDesignation? _CurrentViewModel; public IVaultCollectionModel VaultCollectionModel { get; } @@ -40,10 +40,10 @@ public override void OnDisappearing() /// public async Task TryContinueAsync(CancellationToken cancellationToken) { - if (CurrentViewModel is null) + if (CurrentViewModel is not IStagingView stagingView) return Result.Failure(null); - var result = await CurrentViewModel.TryContinueAsync(cancellationToken); + var result = await stagingView.TryContinueAsync(cancellationToken); if (result.Successful) NavigationRequested?.Invoke(this, new WizardNavigationRequestedEventArgs(result, CurrentViewModel)); @@ -53,10 +53,10 @@ public async Task TryContinueAsync(CancellationToken cancellationToken) /// public async Task TryCancelAsync(CancellationToken cancellationToken) { - if (CurrentViewModel is null) + if (CurrentViewModel is not IStagingView stagingView) return Result.Failure(null); - var result = await CurrentViewModel.TryCancelAsync(cancellationToken); + var result = await stagingView.TryCancelAsync(cancellationToken); if (result.Successful) NavigationRequested?.Invoke(this, new DismissNavigationRequestedEventArgs(CurrentViewModel)); @@ -78,26 +78,22 @@ private async Task CancellationAsync(IEventDispatch? eventDispatch, Cancellation eventDispatch?.PreventForwarding(); } - partial void OnCurrentViewModelChanging(IStagingView? oldValue, IStagingView? newValue) + partial void OnCurrentViewModelChanging(IViewDesignation? oldValue, IViewDesignation? newValue) { - if (oldValue is not null) - { - oldValue.PropertyChanged -= CurrentViewModel_PropertyChanged; - oldValue.OnDisappearing(); - } + oldValue?.PropertyChanged -= CurrentViewModel_PropertyChanged; + oldValue?.OnDisappearing(); + + newValue?.PropertyChanged += CurrentViewModel_PropertyChanged; + newValue?.OnAppearing(); + Title = newValue?.Title; if (newValue is OverlayViewModel overlayViewModel) { - overlayViewModel.PropertyChanged += CurrentViewModel_PropertyChanged; - overlayViewModel.OnAppearing(); - CanContinue = overlayViewModel.CanContinue; CanCancel = overlayViewModel.CanCancel; PrimaryText = overlayViewModel.PrimaryText; SecondaryText = overlayViewModel.SecondaryText; } - - Title = newValue?.Title; } private void CurrentViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs index a97ed8149..b3b36aabb 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs @@ -7,7 +7,6 @@ using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Services; -using SecureFolderFS.Sdk.ViewModels.Controls; using SecureFolderFS.Sdk.ViewModels.Controls.Storage; using SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser; using SecureFolderFS.Sdk.ViewModels.Controls.Transfer; @@ -101,6 +100,7 @@ public override void OnDisappearing() try { + TransferViewModel.IsPickingFolder = true; await OuterNavigator.NavigateAsync(this); using var cts = TransferViewModel.GetCancellation(cancellationToken); return await TransferViewModel.PickFolderAsync(new TransferOptions(TransferType.Select), false, cts.Token); @@ -111,6 +111,7 @@ public override void OnDisappearing() } finally { + TransferViewModel.IsPickingFolder = false; await OuterNavigator.GoBackAsync(); Dispose(); } @@ -274,14 +275,14 @@ protected virtual async Task NewItemAsync(string? itemType, CancellationToken ca case "File": { var file = await modifiableFolder.CreateFileAsync(formattedName, false, cancellationToken); - CurrentFolder.Items.Insert(new FileViewModel(file, this, CurrentFolder), Layouts.GetSorter()); + CurrentFolder.Items.Insert(new FileViewModel(file, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); break; } case "Folder": { var folder = await modifiableFolder.CreateFolderAsync(formattedName, false, cancellationToken); - CurrentFolder.Items.Insert(new FolderViewModel(folder, this, CurrentFolder), Layouts.GetSorter()); + CurrentFolder.Items.Insert(new FolderViewModel(folder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); break; } } @@ -328,7 +329,7 @@ await TransferViewModel.TransferAsync([ file ], async (item, token) => var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); // Add to destination - CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder), Layouts.GetSorter()); + CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); }, cts.Token); break; @@ -351,7 +352,7 @@ await TransferViewModel.TransferAsync([ folder ], async (item, reporter, token) var copiedFolder = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, reporter, token); // Add to destination - CurrentFolder.Items.Insert(new FolderViewModel(copiedFolder, this, CurrentFolder), Layouts.GetSorter()); + CurrentFolder.Items.Insert(new FolderViewModel(copiedFolder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); }, cts.Token); break; @@ -374,7 +375,7 @@ await TransferViewModel.TransferAsync(galleryItems, async (item, token) => var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); // Add to destination - CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder), Layouts.GetSorter()); + CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); }, cts.Token); break; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.Scanning.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.Scanning.cs index 4e90f506a..c5d7d27f8 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.Scanning.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.Scanning.cs @@ -50,7 +50,7 @@ private async Task ScanFolderAsync(string? mode, CancellationToken cancellationT return; // Prompt the user to pick a folder - IFolder pickedFolder = await FileExplorerService.PickFolderAsync(null, false); + var pickedFolder = await FileExplorerService.PickFolderAsync(null, false, cancellationToken); if (pickedFolder is null) return; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs index d7601dbce..e269c8c97 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs @@ -21,9 +21,6 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Vault [Bindable(true)] public sealed partial class VaultPropertiesViewModel : BaseDesignationViewModel, IUnlockedViewContext, IAsyncInitialize, IDisposable { - private readonly INavigator _innerNavigator; - private readonly INavigator _outerNavigator; - [ObservableProperty] private string? _SecurityText; [ObservableProperty] private string? _ContentCipherText; [ObservableProperty] private string? _FileNameCipherText; @@ -47,8 +44,6 @@ public VaultPropertiesViewModel(UnlockedVaultViewModel unlockedVaultViewModel, I UnlockedVaultViewModel = unlockedVaultViewModel; RecycleBinOverlayViewModel = new(UnlockedVaultViewModel, outerNavigator); Title = "VaultProperties".ToLocalized(); - _innerNavigator = innerNavigator; - _outerNavigator = outerNavigator; } /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/AccountSourceWizardViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/AccountSourceWizardViewModel.cs index 45d973efc..73c71845a 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/AccountSourceWizardViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/AccountSourceWizardViewModel.cs @@ -36,9 +36,6 @@ public partial class AccountSourceWizardViewModel : BaseDataSourceWizardViewMode [ObservableProperty] private AccountViewModel? _SelectedAccount; [ObservableProperty] private ObservableCollection _Accounts; - /// - public override string DataSourceName { get; } - /// public event EventHandler? NavigationRequested; @@ -117,7 +114,7 @@ private async Task AddAccountAsync(CancellationToken cancellationToken) [RelayCommand] private async Task RemoveAccountAsync(AccountViewModel? accountViewModel, CancellationToken cancellationToken) { - if (accountViewModel is null) + if (accountViewModel is null || DataSourceName is null) return; // Get the property store @@ -171,7 +168,6 @@ private async Task SelectAccountAsync(AccountViewModel? accountViewModel, Cancel var browser = BrowserHelpers.CreateBrowser(rootFolder, new FileSystemOptions(), accountViewModel, outerNavigator: this); // Prompt the user to pick a folder - browser.OnAppearing(); _selectedFolder = await browser.PickFolderAsync(null, true, cancellationToken); if (_selectedFolder is null) return; @@ -192,6 +188,8 @@ private async Task SelectAccountAsync(AccountViewModel? accountViewModel, Cancel public override void Dispose() { NavigationRequested = null; + Accounts.Where(x => x != SelectedAccount).DisposeAll(); + Accounts.Clear(); } } } \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/BaseDataSourceWizardViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/BaseDataSourceWizardViewModel.cs index 61304db38..93ace6a44 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/BaseDataSourceWizardViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/BaseDataSourceWizardViewModel.cs @@ -18,19 +18,28 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Wizard.DataSources public abstract partial class BaseDataSourceWizardViewModel : OverlayViewModel, IStagingView, IDisposable { [ObservableProperty] private IImage? _Icon; + [ObservableProperty] private string? _DataSourceName; + [ObservableProperty] private string? _DataSourceDescription; + /// + /// Gets the model representing the current vault collection. + /// protected IVaultCollectionModel VaultCollectionModel { get; } - public NewVaultMode Mode { get; } - + /// + /// Gets the type of the data source. + /// public string DataSourceType { get; } - public abstract string DataSourceName { get; } + /// + /// Gets the creation mode of the new vault. + /// + public NewVaultMode Mode { get; } protected BaseDataSourceWizardViewModel(string dataSourceType, NewVaultMode mode, IVaultCollectionModel vaultCollectionModel) { - DataSourceType = dataSourceType; Mode = mode; + DataSourceType = dataSourceType; VaultCollectionModel = vaultCollectionModel; } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/PickerSourceWizardViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/PickerSourceWizardViewModel.cs index 6b5e3bc53..f918e0107 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/PickerSourceWizardViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/PickerSourceWizardViewModel.cs @@ -32,14 +32,13 @@ public sealed partial class PickerSourceWizardViewModel : BaseDataSourceWizardVi [ObservableProperty] private string? _SelectedLocation; [ObservableProperty] private Severity _Severity; - /// - public override string DataSourceName { get; } = "SourceLocalStorage".ToLocalized(); - public PickerSourceWizardViewModel(string dataSourceType, IFolderPicker folderPicker, NewVaultMode mode, IVaultCollectionModel vaultCollectionModel) : base(dataSourceType, mode, vaultCollectionModel) { ServiceProvider = DI.Default; _folderPicker = folderPicker; + DataSourceName = "SourceLocalStorage".ToLocalized(); + DataSourceDescription = "SourceLocalStorageDescription".ToLocalized(); CanCancel = true; CanContinue = false; PrimaryText = "Continue".ToLocalized(); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/SourceSelectionWizardViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/SourceSelectionWizardViewModel.cs index 4e7029edc..f2471c3f2 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/SourceSelectionWizardViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/SourceSelectionWizardViewModel.cs @@ -58,9 +58,6 @@ public Task TryCancelAsync(CancellationToken cancellationToken) /// public override async void OnAppearing() { - if (!Sources.IsEmpty()) - return; - var sources = await VaultFileSystemService.GetSourcesAsync(_vaultCollectionModel, _mode).ToArrayAsyncImpl(); Sources.DisposeAll(); Sources.Clear(); diff --git a/src/Shared/SecureFolderFS.Shared/ComponentModel/IImageStream.cs b/src/Shared/SecureFolderFS.Shared/ComponentModel/IImageStream.cs index b50d130b7..c110fd8a3 100644 --- a/src/Shared/SecureFolderFS.Shared/ComponentModel/IImageStream.cs +++ b/src/Shared/SecureFolderFS.Shared/ComponentModel/IImageStream.cs @@ -1,11 +1,11 @@ using System.IO; -using System.Threading; -using System.Threading.Tasks; namespace SecureFolderFS.Shared.ComponentModel { - public interface IImageStream : IImage + /// + /// Represents an image that can be read from a . + /// + public interface IImageStream : IImage, IWrapper { - Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default); } } diff --git a/src/Shared/SecureFolderFS.Shared/DI.cs b/src/Shared/SecureFolderFS.Shared/DI.cs index 225c210ac..ce00eaf07 100644 --- a/src/Shared/SecureFolderFS.Shared/DI.cs +++ b/src/Shared/SecureFolderFS.Shared/DI.cs @@ -1,4 +1,5 @@ using System; +using Microsoft.Extensions.Logging; namespace SecureFolderFS.Shared { @@ -19,6 +20,17 @@ public sealed class DI : IServiceProvider /// public bool IsAvailable => _serviceProvider is not null; + /// + /// Retrieves a logger instance for the specified type. + /// + /// The type for which the logger is created. + /// An instance for the specified type. + public ILogger GetLogger() + where T : class + { + return GetService().CreateLogger(); + } + /// public object? GetService(Type serviceType) { @@ -53,6 +65,17 @@ public T GetService() return GetService(); } + /// + /// Retrieves a logger instance for the specified type using the default service provider. + /// + /// The type for which the logger is created. + /// An instance for the specified type. + public static ILogger Logger() + where T : class + { + return Default.GetLogger(); + } + /// /// Resolves a specific service identified by from . /// diff --git a/src/Shared/SecureFolderFS.Shared/Extensions/CollectionExtensions.cs b/src/Shared/SecureFolderFS.Shared/Extensions/CollectionExtensions.cs index 50514c3af..b87952092 100644 --- a/src/Shared/SecureFolderFS.Shared/Extensions/CollectionExtensions.cs +++ b/src/Shared/SecureFolderFS.Shared/Extensions/CollectionExtensions.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading.Tasks; using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Shared.Extensions diff --git a/src/Shared/SecureFolderFS.Shared/Extensions/DictionaryExtensions.cs b/src/Shared/SecureFolderFS.Shared/Extensions/DictionaryExtensions.cs index 1fb74e0a7..47d374b5b 100644 --- a/src/Shared/SecureFolderFS.Shared/Extensions/DictionaryExtensions.cs +++ b/src/Shared/SecureFolderFS.Shared/Extensions/DictionaryExtensions.cs @@ -11,6 +11,13 @@ public static class DictionaryExtensions return dictionary.TryGetValue(key, out var value) ? value : null; } + public static TAs? GetAs(this IDictionary dictionary, TKey key) + where TValue : class? + where TAs : class? + { + return dictionary.TryGetValue(key, out var value) ? value.TryCast() : null; + } + public static TKV? GetByKeyOrValue(this IDictionary dictionary, TKV tkv) where TKV : class { diff --git a/src/Shared/SecureFolderFS.Shared/Extensions/LoggingExtensions.cs b/src/Shared/SecureFolderFS.Shared/Extensions/LoggingExtensions.cs new file mode 100644 index 000000000..bbebbf8c1 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Extensions/LoggingExtensions.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; + +namespace SecureFolderFS.Shared.Extensions +{ + public static class LoggingExtensions + { + public static long GetPerformanceScope(this ILogger logger) + { + return Stopwatch.GetTimestamp(); + } + + public static void LogPerformance(this ILogger logger, long scope, int minThresholdMs = -1, [CallerMemberName] string caller = "") + { + var elapsed = Stopwatch.GetElapsedTime(scope); + if (!logger.IsEnabled(LogLevel.Debug)) + return; + + if (minThresholdMs < 0 || elapsed.TotalMilliseconds > minThresholdMs) + logger.LogDebug("{Caller} completed in {ElapsedMs:F2}ms", caller, elapsed.TotalMilliseconds); + } + } +} diff --git a/src/Shared/SecureFolderFS.Shared/Extensions/WrapperExtensions.cs b/src/Shared/SecureFolderFS.Shared/Extensions/WrapperExtensions.cs index 37a2d61a2..6d3f7ae17 100644 --- a/src/Shared/SecureFolderFS.Shared/Extensions/WrapperExtensions.cs +++ b/src/Shared/SecureFolderFS.Shared/Extensions/WrapperExtensions.cs @@ -57,6 +57,25 @@ public static IWrapper GetWrapperAt(this IWrapper wrapper, int level) } } + public static IWrapper GetWrapperAt(this IWrapper wrapper, string typeName) + { + while (true) + { + if (wrapper.GetType().Name == typeName) + return wrapper; + + if (wrapper.Inner is IWrapper innerWrapper) + { + wrapper = innerWrapper; + continue; + } + + break; + } + + throw new InvalidOperationException($"Could not find wrapper at {typeName}."); + } + public static IWrapper GetWrapperBelow(this IWrapper wrapper) { var aboveWrapper = GetWrapperAt(wrapper); diff --git a/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLogger.cs b/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLogger.cs new file mode 100644 index 000000000..ed923e28f --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLogger.cs @@ -0,0 +1,60 @@ +using System; +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace SecureFolderFS.Shared.Logging +{ + internal sealed class DebugOutputLogger : ILogger + { + private readonly string _categoryName; + private readonly LogLevel _minLevel; + + public DebugOutputLogger(string categoryName, LogLevel minLevel) + { + _categoryName = categoryName; + _minLevel = minLevel; + } + + /// + public bool IsEnabled(LogLevel logLevel) + { +#if !DEBUG + return false; +#endif + return logLevel >= _minLevel && logLevel != LogLevel.None; + } + + /// + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + return; + + var message = formatter(state, exception); + var timestamp = DateTime.Now.ToString("HH:mm:ss.fff"); + var level = GetLevelTag(logLevel); + + Debug.WriteLine($"[{timestamp}] {level} {_categoryName}: {message}"); + + if (exception is not null) + Debug.WriteLine($"[{timestamp}] {level} {_categoryName}: >>> {exception}"); + } + + private static string GetLevelTag(LogLevel logLevel) => logLevel switch + { + LogLevel.Trace => "[TRC]", + LogLevel.Debug => "[DBG]", + LogLevel.Information => "[INF]", + LogLevel.Warning => "[WRN]", + LogLevel.Error => "[ERR]", + LogLevel.Critical => "[CRT]", + _ => "[???]" + }; + } +} diff --git a/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLoggerProvider.cs b/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLoggerProvider.cs new file mode 100644 index 000000000..1be588213 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Logging/DebugOutputLoggerProvider.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace SecureFolderFS.Shared.Logging +{ + /// + /// An that writes log messages to the IDE Debug Output window via . + /// + public sealed class DebugOutputLoggerProvider : ILoggerProvider + { + private readonly ConcurrentDictionary _loggers = new(); + private readonly LogLevel _minLevel; + + public DebugOutputLoggerProvider(LogLevel minLevel = LogLevel.Trace) + { + _minLevel = minLevel; + } + + /// + public ILogger CreateLogger(string categoryName) + { + return _loggers.GetOrAdd(categoryName, name => new DebugOutputLogger(name, _minLevel)); + } + + /// + public void Dispose() + { + _loggers.Clear(); + } + } +} diff --git a/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLogger.cs b/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLogger.cs new file mode 100644 index 000000000..fd1c77f25 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLogger.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using Microsoft.Extensions.Logging; + +namespace SecureFolderFS.Shared.Logging +{ + internal sealed class FileOutputLogger : ILogger + { + private readonly string _categoryName; + private readonly LogLevel _minLevel; + private readonly FileOutputLoggerProvider _provider; + + public FileOutputLogger(string categoryName, LogLevel minLevel, FileOutputLoggerProvider provider) + { + _categoryName = categoryName; + _minLevel = minLevel; + _provider = provider; + } + + /// + public bool IsEnabled(LogLevel logLevel) + { + return logLevel >= _minLevel && logLevel != LogLevel.None; + } + + /// + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + return; + + var message = formatter(state, exception); + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); + var level = GetLevelTag(logLevel); + + var line = $"[{timestamp}] {level} {_categoryName}: {message}"; + if (exception is not null) + line += $"{Environment.NewLine} >>> {exception}"; + + _provider.WriteMessage(line); + } + + private static string GetLevelTag(LogLevel logLevel) => logLevel switch + { + LogLevel.Trace => "[TRC]", + LogLevel.Debug => "[DBG]", + LogLevel.Information => "[INF]", + LogLevel.Warning => "[WRN]", + LogLevel.Error => "[ERR]", + LogLevel.Critical => "[CRT]", + _ => "[???]" + }; + } +} diff --git a/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLoggerProvider.cs b/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLoggerProvider.cs new file mode 100644 index 000000000..02d8224a0 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Logging/FileOutputLoggerProvider.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using Microsoft.Extensions.Logging; +using SecureFolderFS.Shared.Helpers; + +namespace SecureFolderFS.Shared.Logging +{ + /// + /// An that writes log messages to a file on disk. + /// + public sealed class FileOutputLoggerProvider : ILoggerProvider + { + private readonly ConcurrentDictionary _loggers = new(); + private readonly object _writeLock = new(); + private readonly string _filePath; + private readonly LogLevel _minLevel; + + public FileOutputLoggerProvider(string filePath, LogLevel minLevel = LogLevel.Information) + { + _filePath = filePath; + _minLevel = minLevel; + + var directory = Path.GetDirectoryName(filePath); + if (directory is not null) + Directory.CreateDirectory(directory); + } + + /// + public ILogger CreateLogger(string categoryName) + { + return _loggers.GetOrAdd(categoryName, name => new FileOutputLogger(name, _minLevel, this)); + } + + internal void WriteMessage(string message) + { + lock (_writeLock) + SafetyHelpers.NoFailure(() => File.AppendAllText(_filePath, message + Environment.NewLine)); + } + + /// + public void Dispose() + { + _loggers.Clear(); + } + } +} diff --git a/src/Shared/SecureFolderFS.Shared/Logging/LoggingBuilderExtensions.cs b/src/Shared/SecureFolderFS.Shared/Logging/LoggingBuilderExtensions.cs new file mode 100644 index 000000000..2b63a0b83 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Logging/LoggingBuilderExtensions.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace SecureFolderFS.Shared.Logging +{ + public static class LoggingBuilderExtensions + { + /// + /// Adds that writes to the IDE Debug Output window. + /// + /// The to configure. + /// The minimum log level to output. + /// The so that calls can be chained. + public static ILoggingBuilder AddDebugOutput(this ILoggingBuilder builder, LogLevel minLevel = LogLevel.Trace) + { + builder.Services.AddSingleton(new DebugOutputLoggerProvider(minLevel)); + return builder; + } + + /// + /// Adds that writes to a log file on disk. + /// + /// The to configure. + /// The absolute path to the log file. + /// The minimum log level to output. + /// The so that calls can be chained. + public static ILoggingBuilder AddFileOutput(this ILoggingBuilder builder, string filePath, LogLevel minLevel = LogLevel.Information) + { + builder.Services.AddSingleton(new FileOutputLoggerProvider(filePath, minLevel)); + return builder; + } + } +} diff --git a/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs b/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs index e6366be99..524b1a4ac 100644 --- a/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs +++ b/src/Shared/SecureFolderFS.Shared/Models/StreamImageModel.cs @@ -1,45 +1,27 @@ using System.IO; -using System.Threading; -using System.Threading.Tasks; using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Shared.Models { - /// - /// A simple implementation of that wraps a . - /// + /// public sealed class StreamImageModel : IImageStream { - /// - /// Gets the stream containing image data. - /// - public Stream Stream { get; } + /// + public Stream Inner { get; } /// /// Initializes a new instance of the class. /// - /// The stream containing image data. - public StreamImageModel(Stream stream) - { - Stream = stream; - } - - /// - public async Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default) + /// The stream containing image data. + public StreamImageModel(Stream inner) { - var savedPosition = Stream.Position; - await Stream.CopyToAsync(destination, cancellationToken); - - if (Stream.CanSeek) - Stream.Position = savedPosition; + Inner = inner; } /// public void Dispose() { - Stream.Dispose(); + Inner.Dispose(); } } } - - diff --git a/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj b/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj index eb3625cbf..202891eac 100644 --- a/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj +++ b/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj @@ -8,7 +8,8 @@ - + + diff --git a/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs b/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs index 62508b35e..52d1f650c 100644 --- a/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs +++ b/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs @@ -12,6 +12,35 @@ namespace SecureFolderFS.SourceGenerator.Helpers { internal static class SourceGeneratorHelpers { + /// + /// Generate the following code: + /// + /// global::Microsoft.Extensions.Logging.LoggerFactoryExtensions.CreateLogger<>( + /// global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<global::Microsoft.Extensions.Logging.ILoggerFactory>()); + /// + /// + /// + internal static ExpressionSyntax GetLoggerRegistration(string containingTypeName, string serviceProviderName) + { + // ServiceProviderServiceExtensions.GetRequiredService(this.ServiceProvider) + var getLoggerFactory = InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions"), + GenericName("GetRequiredService").WithTypeArgumentList( + TypeArgumentList(SeparatedList().Add( + ParseTypeName("global::Microsoft.Extensions.Logging.ILoggerFactory")))))) + .AddArgumentListArguments(Argument(GetThisMemberAccessExpression(serviceProviderName))); + + // LoggerFactoryExtensions.CreateLogger(loggerFactory) + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("global::Microsoft.Extensions.Logging.LoggerFactoryExtensions"), + GenericName("CreateLogger").WithTypeArgumentList( + TypeArgumentList(SeparatedList().Add( + ParseTypeName(containingTypeName)))))) + .AddArgumentListArguments(Argument(getLoggerFactory)); + } + /// /// Generate the following code: /// diff --git a/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs b/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs index 6484a5b2d..292a2b985 100644 --- a/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs +++ b/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs @@ -53,20 +53,45 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator } visibility = visibility == SyntaxKind.None ? SyntaxKind.PrivateKeyword : visibility; - name = string.IsNullOrEmpty(name) ? FormatName(type.Name) : name; - var backingFieldName = $"_{name}"; - - var injecteeField = GetFieldDeclaration(SyntaxKind.PrivateKeyword, backingFieldName, type.ToDisplayString(), true); - var injecteeProperty = GetPropertyDeclaration(visibility, name, type.ToDisplayString()).WithExpressionBody( - ArrowExpressionClause( - AssignmentExpression(SyntaxKind.CoalesceAssignmentExpression, - GetThisMemberAccessExpression(backingFieldName), - GetServiceRegistration(type, Constants.ServiceProviderName)))) - .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) - .AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(InjectGenerator))); - - members.Add(injecteeField); - members.Add(injecteeProperty); + + // Check if this is the special ILogger injection case + var isLoggerInjection = type is INamedTypeSymbol { Name: "ILogger", IsGenericType: false } && type.ContainingNamespace.ToDisplayString().Contains("Microsoft.Extensions.Logging"); + if (isLoggerInjection) + { + name = string.IsNullOrEmpty(name) ? "Logger" : name; + var backingFieldName = $"_{name}"; + var containingTypeName = typeSymbol.ToDisplayString(); + var loggerTypeName = $"global::Microsoft.Extensions.Logging.ILogger<{containingTypeName}>"; + + var loggerField = GetFieldDeclaration(SyntaxKind.PrivateKeyword, backingFieldName, loggerTypeName, true); + var loggerProperty = GetPropertyDeclaration(visibility, name, loggerTypeName).WithExpressionBody( + ArrowExpressionClause( + AssignmentExpression(SyntaxKind.CoalesceAssignmentExpression, + GetThisMemberAccessExpression(backingFieldName), + GetLoggerRegistration(containingTypeName, Constants.ServiceProviderName)))) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) + .AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(InjectGenerator))); + + members.Add(loggerField); + members.Add(loggerProperty); + } + else + { + name = string.IsNullOrEmpty(name) ? FormatName(type.Name) : name; + var backingFieldName = $"_{name}"; + + var injecteeField = GetFieldDeclaration(SyntaxKind.PrivateKeyword, backingFieldName, type.ToDisplayString(), true); + var injecteeProperty = GetPropertyDeclaration(visibility, name, type.ToDisplayString()).WithExpressionBody( + ArrowExpressionClause( + AssignmentExpression(SyntaxKind.CoalesceAssignmentExpression, + GetThisMemberAccessExpression(backingFieldName), + GetServiceRegistration(type, Constants.ServiceProviderName)))) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) + .AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(InjectGenerator))); + + members.Add(injecteeField); + members.Add(injecteeProperty); + } } if (members.Count > 0) diff --git a/src/Shared/SecureFolderFS.SourceGenerator/SecureFolderFS.SourceGenerator.csproj b/src/Shared/SecureFolderFS.SourceGenerator/SecureFolderFS.SourceGenerator.csproj index 5083e9dc0..7fd0483e0 100644 --- a/src/Shared/SecureFolderFS.SourceGenerator/SecureFolderFS.SourceGenerator.csproj +++ b/src/Shared/SecureFolderFS.SourceGenerator/SecureFolderFS.SourceGenerator.csproj @@ -1,7 +1,7 @@  - net10.0 + netstandard2.0 latest enable false diff --git a/targetframework-override.props b/targetframework-override.props deleted file mode 100644 index 81408044a..000000000 --- a/targetframework-override.props +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - $(OverrideTargetFrameworks) - - \ No newline at end of file diff --git a/tests/SecureFolderFS.Tests/CliTests/BaseCliCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/BaseCliCommandTests.cs new file mode 100644 index 000000000..637ca1b18 --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/BaseCliCommandTests.cs @@ -0,0 +1,53 @@ +using NUnit.Framework; + +namespace SecureFolderFS.Tests.CliTests; + +public abstract class BaseCliCommandTests +{ + private readonly List _tempDirectories = []; + + [SetUp] + public void ResetExitCode() + { + Environment.ExitCode = 0; + } + + [TearDown] + public void CleanupTempDirectories() + { + foreach (var directory in _tempDirectories) + { + try + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + catch + { + // Best-effort cleanup only. + } + } + + _tempDirectories.Clear(); + } + + protected Task RunCliAsync(params string[] args) + { + return CliTestHost.RunAsync(args); + } + + protected Task RunCliAsync(string[] args, IReadOnlyDictionary environmentVariables) + { + return CliTestHost.RunAsync(args, environmentVariables: environmentVariables); + } + + protected string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), $"sffs-cli-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + _tempDirectories.Add(path); + + return path; + } +} + diff --git a/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs b/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs new file mode 100644 index 000000000..a4d4e42b6 --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs @@ -0,0 +1,9 @@ +namespace SecureFolderFS.Tests.CliTests; + +public sealed record CliExecutionResult( + int AppExitCode, + int ProcessExitCode, + string StandardOutput, + string StandardError); + + diff --git a/tests/SecureFolderFS.Tests/CliTests/CliExpectedExitCodes.cs b/tests/SecureFolderFS.Tests/CliTests/CliExpectedExitCodes.cs new file mode 100644 index 000000000..18194ce14 --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/CliExpectedExitCodes.cs @@ -0,0 +1,10 @@ +namespace SecureFolderFS.Tests.CliTests; + +internal static class CliExpectedExitCodes +{ + public const int Success = 0; + public const int BadArguments = 2; + public const int VaultUnreadable = 4; + public const int MountStateError = 6; +} + diff --git a/tests/SecureFolderFS.Tests/CliTests/CliTestHost.cs b/tests/SecureFolderFS.Tests/CliTests/CliTestHost.cs new file mode 100644 index 000000000..fc8d7e255 --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/CliTestHost.cs @@ -0,0 +1,118 @@ +using CliFx; +using Microsoft.Extensions.DependencyInjection; +using OwlCore.Storage.System.IO; +using SecureFolderFS.Cli; +using SecureFolderFS.Cli.Commands; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared; +using SecureFolderFS.Tests.ServiceImplementation; +using SecureFolderFS.UI.ServiceImplementation; + +namespace SecureFolderFS.Tests.CliTests; + +internal static class CliTestHost +{ + public static async Task RunAsync(string[] args, string? standardInput = null, IReadOnlyDictionary? environmentVariables = null) + { + var originalOut = Console.Out; + var originalError = Console.Error; + var originalIn = Console.In; + + var outputWriter = new StringWriter(); + var errorWriter = new StringWriter(); + + var originalEnvironment = new Dictionary(); + if (environmentVariables is not null) + { + foreach (var pair in environmentVariables) + { + originalEnvironment[pair.Key] = Environment.GetEnvironmentVariable(pair.Key); + Environment.SetEnvironmentVariable(pair.Key, pair.Value); + } + } + + var settingsPath = Path.Combine(Path.GetTempPath(), $"sffs-cli-tests-settings-{Guid.NewGuid():N}"); + Directory.CreateDirectory(settingsPath); + + var services = BuildServiceProvider(settingsPath); + + try + { + Console.SetOut(outputWriter); + Console.SetError(errorWriter); + Console.SetIn(new StringReader(standardInput ?? string.Empty)); + + Environment.ExitCode = 0; + var app = BuildApplication(services); + var appExitCode = await app.RunAsync(args); + + return new CliExecutionResult( + appExitCode, + Environment.ExitCode, + outputWriter.ToString(), + errorWriter.ToString()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + Console.SetIn(originalIn); + + // Re-initialize the shared test DI root because CLI tests replace DI.Default. + GlobalSetup.GlobalInitialize(); + + foreach (var pair in originalEnvironment) + Environment.SetEnvironmentVariable(pair.Key, pair.Value); + + try + { + Directory.Delete(settingsPath, recursive: true); + } + catch + { + // Best-effort cleanup only. + } + } + } + + private static IServiceProvider BuildServiceProvider(string settingsPath) + { + var settingsFolder = new SystemFolder(Directory.CreateDirectory(settingsPath)); + + var serviceProvider = new ServiceCollection() + .AddSingleton(_ => new(settingsFolder)) + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .BuildServiceProvider(); + + DI.Default.SetServiceProvider(serviceProvider); + return serviceProvider; + } + + private static CliApplication BuildApplication(IServiceProvider services) + { + return new CliApplicationBuilder() + .AddCommand() + .AddCommand() + .AddCommand() + .AddCommand() + .AddCommand() + .AddCommand() + .AddCommand() + .AddCommand() + .AddCommand() + .UseTypeActivator(type => ActivatorUtilities.CreateInstance(services, type)) + .Build(); + } +} + diff --git a/tests/SecureFolderFS.Tests/CliTests/CommandDiscoveryTests.cs b/tests/SecureFolderFS.Tests/CliTests/CommandDiscoveryTests.cs new file mode 100644 index 000000000..192e4f3dd --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/CommandDiscoveryTests.cs @@ -0,0 +1,44 @@ +using CliFx; +using CliFx.Attributes; +using FluentAssertions; +using NUnit.Framework; +using SecureFolderFS.Cli.Commands; +using System.Reflection; + +namespace SecureFolderFS.Tests.CliTests; + +[TestFixture] +public class CommandDiscoveryTests +{ + [Test] + public void CliAssembly_ExposesExpectedCommandTypes() + { + // Arrange + var assembly = typeof(VaultCreateCommand).Assembly; + + // Act + var commandTypes = assembly + .GetTypes() + .Where(type => + !type.IsAbstract && + typeof(ICommand).IsAssignableFrom(type) && + type.GetCustomAttribute() is not null) + .Select(type => type.Name) + .ToArray(); + + // Assert + commandTypes.Should().BeEquivalentTo( + [ + nameof(CredsAddCommand), + nameof(CredsChangeCommand), + nameof(CredsRemoveCommand), + nameof(VaultCreateCommand), + nameof(VaultInfoCommand), + nameof(VaultMountCommand), + nameof(VaultRunCommand), + nameof(VaultShellCommand), + nameof(VaultUnmountCommand) + ]); + } +} + diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs new file mode 100644 index 000000000..da3c28180 --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs @@ -0,0 +1,23 @@ +using FluentAssertions; +using NUnit.Framework; + +namespace SecureFolderFS.Tests.CliTests.Commands; + +[TestFixture] +public class CredsAddCommandTests : BaseCliCommandTests +{ + [Test] + public async Task CredsAdd_WithRecoveryKeyFlag_ShouldReturnBadArguments() + { + // Arrange + var vaultPath = CreateTempDirectory(); + + // Act + var result = await RunCliAsync("creds", "add", vaultPath, "--recovery-key", "rk", "--no-color"); + + // Assert + result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments); + } +} + + diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs new file mode 100644 index 000000000..0e8e99b09 --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs @@ -0,0 +1,23 @@ +using FluentAssertions; +using NUnit.Framework; + +namespace SecureFolderFS.Tests.CliTests.Commands; + +[TestFixture] +public class CredsChangeCommandTests : BaseCliCommandTests +{ + [Test] + public async Task CredsChange_WithRecoveryKeyFlag_ShouldReturnBadArguments() + { + // Arrange + var vaultPath = CreateTempDirectory(); + + // Act + var result = await RunCliAsync("creds", "change", vaultPath, "--recovery-key", "rk", "--no-color"); + + // Assert + result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments); + } +} + + diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs new file mode 100644 index 000000000..4191f582f --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs @@ -0,0 +1,23 @@ +using FluentAssertions; +using NUnit.Framework; + +namespace SecureFolderFS.Tests.CliTests.Commands; + +[TestFixture] +public class CredsRemoveCommandTests : BaseCliCommandTests +{ + [Test] + public async Task CredsRemove_WithRecoveryKeyFlag_ShouldReturnBadArguments() + { + // Arrange + var vaultPath = CreateTempDirectory(); + + // Act + var result = await RunCliAsync("creds", "remove", vaultPath, "--recovery-key", "rk", "--no-color"); + + // Assert + result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments); + } +} + + diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs new file mode 100644 index 000000000..968b30135 --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using NUnit.Framework; +using SecureFolderFS.Core; + +namespace SecureFolderFS.Tests.CliTests.Commands; + +[TestFixture] +public class VaultCreateCommandTests : BaseCliCommandTests +{ + [Test] + public async Task VaultCreate_WithoutCredential_ShouldReturnBadArguments() + { + // Arrange + var vaultPath = CreateTempDirectory(); + + // Act + var result = await RunCliAsync("vault", "create", vaultPath, "--no-color"); + + // Assert + result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments); + } + + [Test] + public async Task VaultCreate_WithPasswordEnvironmentVariable_ShouldCreateVault() + { + // Arrange + var vaultPath = CreateTempDirectory(); + var environmentVariables = new Dictionary + { + ["SFFS_PASSWORD"] = "Password#1" + }; + + // Act + var result = await RunCliAsync(["vault", "create", vaultPath, "--no-color"], environmentVariables); + + // Assert + var configPath = Path.Combine(vaultPath, Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME); + + result.ProcessExitCode.Should().Be(CliExpectedExitCodes.Success); + File.Exists(configPath).Should().BeTrue(); + } +} + + diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs new file mode 100644 index 000000000..82e1a496d --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs @@ -0,0 +1,39 @@ +using FluentAssertions; +using NUnit.Framework; +using SecureFolderFS.Tests.Helpers; + +namespace SecureFolderFS.Tests.CliTests.Commands; + +[TestFixture] +public class VaultInfoCommandTests : BaseCliCommandTests +{ + [Test] + public async Task VaultInfo_OnMissingPath_ShouldReturnVaultUnreadable() + { + // Arrange + var missingPath = Path.Combine(Path.GetTempPath(), $"sffs-cli-missing-{Guid.NewGuid():N}"); + + // Act + var result = await RunCliAsync("vault", "info", missingPath, "--no-color"); + + // Assert + result.ProcessExitCode.Should().Be(CliExpectedExitCodes.VaultUnreadable); + } + + [Test] + [Ignore("Since Cli currently uses hardcoded SystemFolderEx, we cannot test against MemoryFolderEx")] + public async Task VaultInfo_OnExistingVault_ShouldReturnInformation() + { + // Arrange + var (folder, _) = await MockVaultHelpers.CreateVaultLatestAsync(null); + + // Act + var result = await RunCliAsync("vault", "info", folder.Id, "--no-color"); + + // TODO: Assert + // Since Cli currently uses hardcoded SystemFolderEx, we cannot test against MemoryFolderEx + Assert.Pass(); + } +} + + diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs new file mode 100644 index 000000000..6ed58c267 --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs @@ -0,0 +1,23 @@ +using FluentAssertions; +using NUnit.Framework; + +namespace SecureFolderFS.Tests.CliTests.Commands; + +[TestFixture] +public class VaultRunCommandTests : BaseCliCommandTests +{ + [Test] + public async Task VaultRun_WithoutReadOrWrite_ShouldReturnBadArguments() + { + // Arrange + var vaultPath = CreateTempDirectory(); + + // Act + var result = await RunCliAsync("vault", "run", vaultPath, "--no-color"); + + // Assert + result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments); + } +} + + diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs new file mode 100644 index 000000000..edd046846 --- /dev/null +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs @@ -0,0 +1,23 @@ +using FluentAssertions; +using NUnit.Framework; + +namespace SecureFolderFS.Tests.CliTests.Commands; + +[TestFixture] +public class VaultShellCommandTests : BaseCliCommandTests +{ + [Test] + public async Task VaultShell_WithoutAnyCredential_ShouldReturnBadArguments() + { + // Arrange + var vaultPath = CreateTempDirectory(); + + // Act + var result = await RunCliAsync("vault", "shell", vaultPath, "--no-color"); + + // Assert + result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments); + } +} + + diff --git a/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs b/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs index 0041da225..7dcd8d3e9 100644 --- a/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs +++ b/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs @@ -102,17 +102,16 @@ public async Task Create_FolderWith_SubFile_SubFolder_Delete_And_Restore_NoThrow var recycleBin = await _recycleBinService.GetRecycleBinAsync(_storageRoot); var recycleBinItems = await recycleBin.GetItemsAsync().ToArrayAsyncImpl(); - var first = recycleBinItems[0]; - var second = recycleBinItems[1]; + var deletedFile = recycleBinItems.First(x => x.Name == "SUB_FILE"); + var deletedFolder = recycleBinItems.First(x => x.Name == "SUB_FOLDER"); - await recycleBin.RestoreItemsAsync([ first ], _fileExplorerService); - await recycleBin.RestoreItemsAsync([ second ], _fileExplorerService); + await recycleBin.RestoreItemsAsync([ deletedFile ], _fileExplorerService); + await recycleBin.RestoreItemsAsync([ deletedFolder ], _fileExplorerService); // Assert var restoredItems = await subFolder.GetItemsAsync().ToArrayAsyncImpl(); - restoredItems.Should().HaveCount(2); - restoredItems[0].Name.Should().Match(x => x == "SUB_FILE" || x == "SUB_FOLDER"); - restoredItems[1].Name.Should().Match(x => x == "SUB_FILE" || x == "SUB_FOLDER"); + restoredItems.Select(x => x.Name).Should().Contain("SUB_FILE"); + restoredItems.Select(x => x.Name).Should().Contain("SUB_FOLDER"); Assert.Pass($"{nameof(restoredItems)}:\n" + string.Join('\n', restoredItems.Select(x => x.Id))); } diff --git a/tests/SecureFolderFS.Tests/GlobalSetup.cs b/tests/SecureFolderFS.Tests/GlobalSetup.cs index 8b732e6c9..416213d38 100644 --- a/tests/SecureFolderFS.Tests/GlobalSetup.cs +++ b/tests/SecureFolderFS.Tests/GlobalSetup.cs @@ -40,7 +40,7 @@ private static IServiceProvider ConfigureServices(IModifiableFolder settingsFold .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton() .BuildServiceProvider(); } diff --git a/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj b/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj index e8de2050c..39e8e64fd 100644 --- a/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj +++ b/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj @@ -11,7 +11,7 @@ - + @@ -22,6 +22,7 @@ + diff --git a/tests/SecureFolderFS.Tests/ServiceImplementation/MockFileExplorerService.cs b/tests/SecureFolderFS.Tests/ServiceImplementation/MockFileExplorerService.cs index 12585fb29..da414a76d 100644 --- a/tests/SecureFolderFS.Tests/ServiceImplementation/MockFileExplorerService.cs +++ b/tests/SecureFolderFS.Tests/ServiceImplementation/MockFileExplorerService.cs @@ -7,6 +7,8 @@ namespace SecureFolderFS.Tests.ServiceImplementation { internal sealed class MockFileExplorerService : IFileExplorerService { + private byte[] _savedFileBytes = []; + /// public async Task> PickGalleryItemsAsync(CancellationToken cancellationToken = default) { @@ -21,8 +23,9 @@ public async Task> PickGalleryItemsAsync(CancellationToke { await Task.CompletedTask; var guid = Guid.NewGuid().ToString(); + var stream = new MemoryStream(_savedFileBytes, writable: false); - return new MemoryFileEx(guid, guid, new(), null); + return new MemoryFileEx(guid, guid, stream, null); } /// @@ -43,8 +46,15 @@ public Task TryOpenInFileExplorerAsync(IFolder folder, CancellationToken c /// public async Task SaveFileAsync(string suggestedName, Stream dataStream, IDictionary? filter, CancellationToken cancellationToken = default) { - using var streamReader = new StreamReader(dataStream, leaveOpen: true); - _ = await streamReader.ReadToEndAsync(cancellationToken); + _ = suggestedName; + _ = filter; + + if (dataStream.CanSeek) + dataStream.Position = 0L; + + await using var memoryStream = new MemoryStream(); + await dataStream.CopyToAsync(memoryStream, cancellationToken); + _savedFileBytes = memoryStream.ToArray(); return true; } diff --git a/tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs b/tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs new file mode 100644 index 000000000..a62e1603a --- /dev/null +++ b/tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs @@ -0,0 +1,321 @@ +using FluentAssertions; +using NUnit.Framework; +using OwlCore.Storage; +using OwlCore.Storage.Memory; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.UI.ViewModels.Authentication; +using static SecureFolderFS.Core.Constants.Vault.Authentication; + +namespace SecureFolderFS.Tests.VaultTests +{ + [TestFixture] + public class CredentialTests + { + [Test] + public async Task CreateVault_Password_UnlocksSuccessfully() + { + // Arrange + var vaultFolder = CreateVaultFolder(); + var manager = DI.Service(); + var vaultService = DI.Service(); + var vaultId = Guid.NewGuid().ToString("N"); + + var passwordProcedure = new AuthenticationMethod([AUTH_PASSWORD], null); + var options = CreateOptions(passwordProcedure, vaultId); + using var initialPassword = await GetPasswordCreationCredentialAsync("Password#1"); + + // Act + using var _ = await manager.CreateAsync(vaultFolder, initialPassword, options); + + // Assert + using var validPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + var canUnlock = await CanUnlockAsync(manager, vaultFolder, validPasskey); + var configuredOptions = await vaultService.GetVaultOptionsAsync(vaultFolder); + + canUnlock.Should().BeTrue(); + configuredOptions.UnlockProcedure.Should().BeEquivalentTo(passwordProcedure); + } + + [Test] + public async Task CreateVault_PasswordAndKeyFile_RequiresBothCredentials() + { + // Arrange + var vaultFolder = CreateVaultFolder(); + var manager = DI.Service(); + var vaultService = DI.Service(); + var vaultId = Guid.NewGuid().ToString("N"); + + using var compositePasskey = await GetCreationCompositeCredentialAsync(vaultFolder, "Password#1", vaultId); + + var compositeProcedure = new AuthenticationMethod([AUTH_PASSWORD, AUTH_KEYFILE], null); + var options = CreateOptions(compositeProcedure, vaultId); + + // Act + using var _ = await manager.CreateAsync(vaultFolder, compositePasskey, options); + + // Assert + using var correctPasskey = await GetLoginCompositeCredentialAsync(vaultFolder, "Password#1", vaultId); + using var missingKeyFilePasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var wrongPasswordPasskey = await GetLoginCompositeCredentialAsync(vaultFolder, "WrongPassword", vaultId); + + (await CanUnlockAsync(manager, vaultFolder, correctPasskey)).Should().BeTrue(); + (await CanUnlockAsync(manager, vaultFolder, missingKeyFilePasskey)).Should().BeFalse(); + (await CanUnlockAsync(manager, vaultFolder, wrongPasswordPasskey)).Should().BeFalse(); + + var configuredOptions = await vaultService.GetVaultOptionsAsync(vaultFolder); + configuredOptions.UnlockProcedure.Should().BeEquivalentTo(compositeProcedure); + } + + [Test] + public async Task ModifyAuthentication_PasswordChange_UsesNewPasswordAndRejectsOldPassword() + { + // Arrange + var vaultFolder = CreateVaultFolder(); + var manager = DI.Service(); + var vaultId = Guid.NewGuid().ToString("N"); + + var passwordProcedure = new AuthenticationMethod([AUTH_PASSWORD], null); + var options = CreateOptions(passwordProcedure, vaultId); + using var originalPassword = await GetPasswordCreationCredentialAsync("Password#1"); + + using var _ = await manager.CreateAsync(vaultFolder, originalPassword, options); + + using var unlockPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var unlockContract = await manager.UnlockAsync(vaultFolder, unlockPasskey); + using var newPassword = await GetPasswordCreationCredentialAsync("Password#2"); + + // Act + await manager.ModifyAuthenticationAsync(vaultFolder, unlockContract, newPassword, options); + + // Assert + using var oldPassword = await GetPasswordLoginCredentialAsync("Password#1"); + using var updatedPassword = await GetPasswordLoginCredentialAsync("Password#2"); + + (await CanUnlockAsync(manager, vaultFolder, oldPassword)).Should().BeFalse(); + (await CanUnlockAsync(manager, vaultFolder, updatedPassword)).Should().BeTrue(); + } + + [Test] + public async Task ModifyAuthentication_AddKeyFile_RequiresPasswordAndKeyFile() + { + // Arrange + var vaultFolder = CreateVaultFolder(); + var manager = DI.Service(); + var vaultService = DI.Service(); + var vaultId = Guid.NewGuid().ToString("N"); + + var passwordOnlyProcedure = new AuthenticationMethod([AUTH_PASSWORD], null); + var passwordAndKeyFileProcedure = new AuthenticationMethod([AUTH_PASSWORD, AUTH_KEYFILE], null); + + using var password = await GetPasswordCreationCredentialAsync("Password#1"); + using var _ = await manager.CreateAsync(vaultFolder, password, CreateOptions(passwordOnlyProcedure, vaultId)); + + using var unlockPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var unlockContract = await manager.UnlockAsync(vaultFolder, unlockPasskey); + using var newCompositePasskey = await GetCreationCompositeCredentialAsync(vaultFolder, "Password#1", vaultId); + + // Act + await manager.ModifyAuthenticationAsync(vaultFolder, unlockContract, newCompositePasskey, CreateOptions(passwordAndKeyFileProcedure, vaultId)); + + // Assert + using var fullCredentialPasskey = await GetLoginCompositeCredentialAsync(vaultFolder, "Password#1", vaultId); + using var passwordOnlyPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + + (await CanUnlockAsync(manager, vaultFolder, fullCredentialPasskey)).Should().BeTrue(); + (await CanUnlockAsync(manager, vaultFolder, passwordOnlyPasskey)).Should().BeFalse(); + + var configuredOptions = await vaultService.GetVaultOptionsAsync(vaultFolder); + configuredOptions.UnlockProcedure.Should().BeEquivalentTo(passwordAndKeyFileProcedure); + } + + [Test] + public async Task ModifyAuthentication_RemoveKeyFile_RequiresPasswordOnly() + { + // Arrange + var vaultFolder = CreateVaultFolder(); + var manager = DI.Service(); + var vaultService = DI.Service(); + var vaultId = Guid.NewGuid().ToString("N"); + + var compositeProcedure = new AuthenticationMethod([AUTH_PASSWORD, AUTH_KEYFILE], null); + var passwordOnlyProcedure = new AuthenticationMethod([AUTH_PASSWORD], null); + + using var initialCompositePasskey = await GetCreationCompositeCredentialAsync(vaultFolder, "Password#1", vaultId); + using var _ = await manager.CreateAsync(vaultFolder, initialCompositePasskey, CreateOptions(compositeProcedure, vaultId)); + + using var unlockPasskey = await GetLoginCompositeCredentialAsync(vaultFolder, "Password#1", vaultId); + using var unlockContract = await manager.UnlockAsync(vaultFolder, unlockPasskey); + using var newPassword = await GetPasswordCreationCredentialAsync("Password#1"); + + // Act + await manager.ModifyAuthenticationAsync(vaultFolder, unlockContract, newPassword, CreateOptions(passwordOnlyProcedure, vaultId)); + + // Assert + using var passwordOnlyPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var oldCompositePasskey = await GetLoginCompositeCredentialAsync(vaultFolder, "Password#1", vaultId); + + (await CanUnlockAsync(manager, vaultFolder, passwordOnlyPasskey)).Should().BeTrue(); + (await CanUnlockAsync(manager, vaultFolder, oldCompositePasskey)).Should().BeFalse(); + + var configuredOptions = await vaultService.GetVaultOptionsAsync(vaultFolder); + configuredOptions.UnlockProcedure.Should().BeEquivalentTo(passwordOnlyProcedure); + } + + [Test] + public async Task ModifyAuthentication_InvalidUnlockContract_ThrowsArgumentException() + { + // Arrange + var vaultFolder = CreateVaultFolder(); + var manager = DI.Service(); + var vaultId = Guid.NewGuid().ToString("N"); + + var passwordProcedure = new AuthenticationMethod([AUTH_PASSWORD], null); + using var originalPassword = await GetPasswordCreationCredentialAsync("Password#1"); + using var _ = await manager.CreateAsync(vaultFolder, originalPassword, CreateOptions(passwordProcedure, vaultId)); + + var newPassword = await GetPasswordCreationCredentialAsync("Password#2"); + var invalidContract = await GetPasswordLoginCredentialAsync("not-a-contract"); + + try + { + // Act + Func action = () => manager.ModifyAuthenticationAsync(vaultFolder, invalidContract, newPassword, CreateOptions(passwordProcedure, vaultId)); + + // Assert + await action.Should().ThrowAsync(); + } + finally + { + newPassword.Dispose(); + invalidContract.Dispose(); + } + } + + private static IFolder CreateVaultFolder() + { + var path = Path.Combine(Path.DirectorySeparatorChar.ToString(), $"TestVault-{Guid.NewGuid():N}"); + return new MemoryFolder(path, Path.GetFileName(path)); + } + + private static VaultOptions CreateOptions(AuthenticationMethod unlockProcedure, string vaultId) + { + return new VaultOptions() + { + UnlockProcedure = unlockProcedure, + VaultId = vaultId + }; + } + + private static async Task GetPasswordCreationCredentialAsync(string password) + { + using var viewModel = CreatePasswordCreationViewModel(password); + + var result = await viewModel.EnrollAsync(Guid.NewGuid().ToString("N"), null); + return result.TryGetValue(out var key) + ? key + : throw result.Exception ?? new InvalidOperationException("Password creation credential was not provided."); + } + + private static async Task GetPasswordLoginCredentialAsync(string password) + { + using var viewModel = CreatePasswordLoginViewModel(password); + + var result = await viewModel.AcquireAsync(Guid.NewGuid().ToString("N"), null); + return result.TryGetValue(out var key) + ? key + : throw result.Exception ?? new InvalidOperationException("Password login credential was not provided."); + } + + private static PasswordCreationViewModel CreatePasswordCreationViewModel(string password) + { + var viewModel = new PasswordCreationViewModel(); + viewModel.PrimaryPassword = password; + viewModel.SecondaryPassword = password; + + return viewModel; + } + + private static PasswordLoginViewModel CreatePasswordLoginViewModel(string password) + { + var viewModel = new PasswordLoginViewModel(); + viewModel.PrimaryPassword = password; + + return viewModel; + } + + private static async Task GetCreationCompositeCredentialAsync(IFolder vaultFolder, string password, string vaultId) + { + _ = vaultFolder; + var keySequence = new KeySequence(); + + try + { + keySequence.Add(await GetPasswordCreationCredentialAsync(password)); + keySequence.Add(await GetKeyFileCreationCredentialAsync(vaultId)); + + return keySequence; + } + catch + { + keySequence.Dispose(); + throw; + } + } + + private static async Task GetLoginCompositeCredentialAsync(IFolder vaultFolder, string password, string vaultId) + { + var keySequence = new KeySequence(); + + try + { + keySequence.Add(await GetPasswordLoginCredentialAsync(password)); + keySequence.Add(await GetKeyFileLoginCredentialAsync(vaultFolder, vaultId)); + + return keySequence; + } + catch + { + keySequence.Dispose(); + throw; + } + } + + private static async Task GetKeyFileCreationCredentialAsync(string vaultId) + { + using var viewModel = new KeyFileCreationViewModel(vaultId); + var result = await viewModel.EnrollAsync(vaultId, null); + + return result.TryGetValue(out var key) + ? key + : throw result.Exception ?? new InvalidOperationException("Key file creation credential was not provided."); + } + + private static async Task GetKeyFileLoginCredentialAsync(IFolder vaultFolder, string vaultId) + { + using var viewModel = new KeyFileLoginViewModel(vaultFolder); + var result = await viewModel.AcquireAsync(vaultId, null); + + return result.TryGetValue(out var key) + ? key + : throw result.Exception ?? new InvalidOperationException("Key file login credential was not provided."); + } + + private static async Task CanUnlockAsync(IVaultManagerService manager, IFolder vaultFolder, IKeyUsage passkey) + { + try + { + using var _ = await manager.UnlockAsync(vaultFolder, passkey); + return true; + } + catch + { + return false; + } + } + } +} +