diff --git a/src/Files.App/Data/Items/ListedItem.cs b/src/Files.App/Data/Items/ListedItem.cs index d3a94b71d753..66e35bddf4e5 100644 --- a/src/Files.App/Data/Items/ListedItem.cs +++ b/src/Files.App/Data/Items/ListedItem.cs @@ -117,9 +117,8 @@ public string[] FileTags // only set the tags if the file tags have been changed if (fileTagsInitialized) { - var dbInstance = FileTagsHelper.GetDbInstance(); - dbInstance.SetTags(ItemPath, FileFRN, value); - FileTagsHelper.WriteFileTag(ItemPath, value); + // Update the registry index and ADS as one synchronized operation. + _ = FileTagsHelper.SetTagsAndWriteFileTagAsync(ItemPath, FileFRN, value); } HasTags = !FileTags.IsEmpty(); diff --git a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs index 24d301d1c61f..0808301dad5f 100644 --- a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs +++ b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs @@ -111,23 +111,33 @@ await Task.WhenAll( // Start non-critical tasks without waiting for them to complete _ = Task.Run(async () => { - await Task.WhenAll( - OptionalTaskAsync(CloudDrivesManager.UpdateDrivesAsync(), generalSettingsService.ShowCloudDrivesSection), - App.LibraryManager.UpdateLibrariesAsync(), - OptionalTaskAsync(WSLDistroManager.UpdateDrivesAsync(), generalSettingsService.ShowWslSection), - OptionalTaskAsync(App.FileTagsManager.UpdateFileTagsAsync(), generalSettingsService.ShowFileTagsSection), - jumpListService.InitializeAsync() - ); - - //Start the tasks separately to reduce resource contention - await Task.WhenAll( - addItemService.InitializeAsync(), - ContextMenu.WarmUpQueryContextMenuAsync() - ); + try + { + await Task.WhenAll( + OptionalTaskAsync(CloudDrivesManager.UpdateDrivesAsync(), generalSettingsService.ShowCloudDrivesSection), + App.LibraryManager.UpdateLibrariesAsync(), + OptionalTaskAsync(WSLDistroManager.UpdateDrivesAsync(), generalSettingsService.ShowWslSection), + OptionalTaskAsync(App.FileTagsManager.UpdateFileTagsAsync(), generalSettingsService.ShowFileTagsSection), + jumpListService.InitializeAsync() + ); + + // Run these tasks after the preceding initialization to reduce resource contention. + await Task.WhenAll( + addItemService.InitializeAsync(), + ContextMenu.WarmUpQueryContextMenuAsync() + ); + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, "Failed to initialize non-critical app services."); + } + finally + { + // Run the tag database scan even if a preceding background task fails. + SafetyExtensions.IgnoreExceptions(FileTagsHelper.UpdateTagsDb, App.Logger); + } }); - FileTagsHelper.UpdateTagsDb(); - _ = Task.Run(async () => { // The follwing method invokes UI thread, so we run it in a separate task diff --git a/src/Files.App/MainWindow.xaml.cs b/src/Files.App/MainWindow.xaml.cs index 8ec6274470ec..266e6211a438 100644 --- a/src/Files.App/MainWindow.xaml.cs +++ b/src/Files.App/MainWindow.xaml.cs @@ -337,9 +337,8 @@ x.tabItem.NavigationParameter.NavigationParameter is PaneNavigationArguments pan if (fileFRN is not null) { var tagUid = tag is not null ? new[] { tag.Uid } : []; - var dbInstance = FileTagsHelper.GetDbInstance(); - dbInstance.SetTags(file, fileFRN, tagUid); - FileTagsHelper.WriteFileTag(file, tagUid); + // Update the registry index and ADS as one synchronized operation. + _ = FileTagsHelper.SetTagsAndWriteFileTagAsync(file, fileFRN, tagUid); } } break; diff --git a/src/Files.App/Utils/FileTags/FileTagsDatabase.cs b/src/Files.App/Utils/FileTags/FileTagsDatabase.cs index e7d120850dd7..5ddf2bff15e8 100644 --- a/src/Files.App/Utils/FileTags/FileTagsDatabase.cs +++ b/src/Files.App/Utils/FileTags/FileTagsDatabase.cs @@ -14,9 +14,30 @@ namespace Files.App.Utils.FileTags public sealed class FileTagsDatabase { private static string? _FileTagsKey; + + // Protect the registry path and FRN indices from concurrent updates. + internal static object SyncRoot { get; } = new(); + private string? FileTagsKey => _FileTagsKey ??= SafetyExtensions.IgnoreExceptions(() => @$"Software\Files Community\{Package.Current.Id.Name}\v1\FileTags"); + internal static void RunSynchronized(Action action) + { + lock (SyncRoot) + action(); + } + + internal static T RunSynchronized(Func action) + { + lock (SyncRoot) + return action(); + } + public void SetTags(string filePath, ulong? frn, string[] tags) + { + RunSynchronized(() => SetTagsCore(filePath, frn, tags)); + } + + private void SetTagsCore(string filePath, ulong? frn, string[] tags) { if (FileTagsKey is null) return; @@ -94,6 +115,11 @@ public void SetTags(string filePath, ulong? frn, string[] tags) } public void UpdateTag(string oldFilePath, ulong? frn, string? newFilePath) + { + RunSynchronized(() => UpdateTagCore(oldFilePath, frn, newFilePath)); + } + + private void UpdateTagCore(string oldFilePath, ulong? frn, string? newFilePath) { if (FileTagsKey is null) return; @@ -122,6 +148,11 @@ public void UpdateTag(string oldFilePath, ulong? frn, string? newFilePath) } public void UpdateTag(ulong oldFrn, ulong? frn, string? newFilePath) + { + RunSynchronized(() => UpdateTagCore(oldFrn, frn, newFilePath)); + } + + private void UpdateTagCore(ulong oldFrn, ulong? frn, string? newFilePath) { if (FileTagsKey is null) return; @@ -151,10 +182,15 @@ public void UpdateTag(ulong oldFrn, ulong? frn, string? newFilePath) public string[] GetTags(string? filePath, ulong? frn) { - return FindTag(filePath, frn)?.Tags ?? []; + return RunSynchronized(() => FindTag(filePath, frn)?.Tags ?? []); } public IEnumerable GetAll() + { + return RunSynchronized(GetAllCore); + } + + private IEnumerable GetAllCore() { var list = new List(); @@ -174,6 +210,11 @@ public IEnumerable GetAll() } public IEnumerable GetAllUnderPath(string folderPath) + { + return RunSynchronized(() => GetAllUnderPathCore(folderPath)); + } + + private IEnumerable GetAllUnderPathCore(string folderPath) { folderPath = folderPath.Replace('/', '\\').TrimStart('\\'); var list = new List(); @@ -194,6 +235,11 @@ public IEnumerable GetAllUnderPath(string folderPath) } public void Import(string json) + { + RunSynchronized(() => ImportCore(json)); + } + + private void ImportCore(string json) { if (FileTagsKey is null) return; @@ -218,6 +264,11 @@ public void Import(string json) } public string Export() + { + return RunSynchronized(ExportCore); + } + + private string ExportCore() { var list = new List(); @@ -250,4 +301,4 @@ private void IterateKeys(List list, string path, int depth) } } } -} \ No newline at end of file +} diff --git a/src/Files.App/Utils/FileTags/FileTagsHelper.cs b/src/Files.App/Utils/FileTags/FileTagsHelper.cs index 7e95c07c77d5..b408c5e2c4de 100644 --- a/src/Files.App/Utils/FileTags/FileTagsHelper.cs +++ b/src/Files.App/Utils/FileTags/FileTagsHelper.cs @@ -19,12 +19,41 @@ public static class FileTagsHelper public static string[] ReadFileTag(string filePath) { - var tagString = Win32Helper.ReadStringFromFile($"{filePath}:files"); - return tagString?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? []; + lock (FileTagsDatabase.SyncRoot) + { + var tagString = Win32Helper.ReadStringFromFile($"{filePath}:files"); + return tagString?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? []; + } } public static async void WriteFileTag(string filePath, string[] tag) { + bool result; + lock (FileTagsDatabase.SyncRoot) + { + result = WriteFileTagCore(filePath, tag); + } + + if (!result) + await ShowTagWriteErrorAsync(); + } + + public static Task SetTagsAndWriteFileTagAsync(string filePath, ulong? frn, string[] tags) + { + bool result; + lock (FileTagsDatabase.SyncRoot) + { + // Update the registry index and ADS without allowing another tag operation to interleave. + GetDbInstance().SetTags(filePath, frn, tags); + result = WriteFileTagCore(filePath, tags); + } + + return result ? Task.CompletedTask : ShowTagWriteErrorAsync(); + } + + private static bool WriteFileTagCore(string filePath, string[] tag) + { + var result = true; var isDateOk = Win32Helper.GetFileDateModified(filePath, out var dateModified); // Backup date modified var isReadOnly = Win32Helper.HasFileAttribute(filePath, IO.FileAttributes.ReadOnly); if (isReadOnly) // Unset read-only attribute (#7534) @@ -37,24 +66,7 @@ public static async void WriteFileTag(string filePath, string[] tag) } else if (ReadFileTag(filePath) is not string[] arr || !tag.SequenceEqual(arr)) { - var result = Win32Helper.WriteStringToFile($"{filePath}:files", string.Join(',', tag)); - if (result == false) - { - await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => - { - ContentDialog dialog = new() - { - Title = Strings.ErrorApplyingTagTitle.GetLocalizedResource(), - Content = Strings.ErrorApplyingTagContent.GetLocalizedResource(), - PrimaryButtonText = "Ok".GetLocalizedResource() - }; - - if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) - dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; - - await dialog.TryShowAsync(); - }); - } + result = Win32Helper.WriteStringToFile($"{filePath}:files", string.Join(',', tag)); } if (isReadOnly) // Restore read-only attribute (#7534) { @@ -64,6 +76,27 @@ await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => { Win32Helper.SetFileDateModified(filePath, dateModified); // Restore date modified } + + return result; + } + + private static Task ShowTagWriteErrorAsync() + { + return MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => + { + // Show the error dialog on the UI thread after releasing the file operation lock. + ContentDialog dialog = new() + { + Title = Strings.ErrorApplyingTagTitle.GetLocalizedResource(), + Content = Strings.ErrorApplyingTagContent.GetLocalizedResource(), + PrimaryButtonText = "Ok".GetLocalizedResource() + }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + await dialog.TryShowAsync(); + }); } public static void UpdateTagsDb() @@ -71,40 +104,44 @@ public static void UpdateTagsDb() var dbInstance = GetDbInstance(); foreach (var file in dbInstance.GetAll()) { - var pathFromFrn = Win32Helper.PathFromFileId(file.Frn ?? 0, file.FilePath); - if (pathFromFrn is not null) + lock (FileTagsDatabase.SyncRoot) { - // Frn is valid, update file path - var tag = ReadFileTag(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal)); - if (tag is not null && tag.Any()) + // Keep validation and updates for one item atomic with user tag changes. + var pathFromFrn = Win32Helper.PathFromFileId(file.Frn ?? 0, file.FilePath); + if (pathFromFrn is not null) { - dbInstance.UpdateTag(file.Frn ?? 0, null, pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal)); - dbInstance.SetTags(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal), file.Frn, tag); + // Frn is valid, update file path + var tag = ReadFileTag(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal)); + if (tag is not null && tag.Any()) + { + dbInstance.UpdateTag(file.Frn ?? 0, null, pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal)); + dbInstance.SetTags(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal), file.Frn, tag); + } + else + { + dbInstance.SetTags(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal), file.Frn, []); + } } else { - dbInstance.SetTags(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal), file.Frn, []); - } - } - else - { - var tag = ReadFileTag(file.FilePath); - if (tag is not null && tag.Any()) - { - if (!SafetyExtensions.IgnoreExceptions(() => + var tag = ReadFileTag(file.FilePath); + if (tag is not null && tag.Any()) { - var frn = GetFileFRN(file.FilePath); - dbInstance.UpdateTag(file.FilePath, frn, null); - dbInstance.SetTags(file.FilePath, frn, tag); - }, App.Logger)) + if (!SafetyExtensions.IgnoreExceptions(() => + { + var frn = GetFileFRN(file.FilePath); + dbInstance.UpdateTag(file.FilePath, frn, null); + dbInstance.SetTags(file.FilePath, frn, tag); + }, App.Logger)) + { + dbInstance.SetTags(file.FilePath, null, []); + } + } + else { dbInstance.SetTags(file.FilePath, null, []); } } - else - { - dbInstance.SetTags(file.FilePath, null, []); - } } } } @@ -152,4 +189,4 @@ public static async Task RemoveTagsAsync(IEnumerable items) } } } -} \ No newline at end of file +} diff --git a/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs b/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs index 0ce4ed32fe50..5ecbe10baab8 100644 --- a/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs +++ b/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs @@ -1022,12 +1022,14 @@ private static void UpdateFileTagsDb(ShellFileOperations2.ShellFileOpEventArgs e { var tag = dbInstance.GetTags(sourcePath, null); - dbInstance.SetTags(destination, FileTagsHelper.GetFileFRN(destination), tag); // copy tag to new files using var si = new ShellItem(destination); if (si.IsFolder) // File tag is not copied automatically for folders { - FileTagsHelper.WriteFileTag(destination, tag); + // Update the registry index and ADS together when copying a folder. + _ = FileTagsHelper.SetTagsAndWriteFileTagAsync(destination, FileTagsHelper.GetFileFRN(destination), tag); } + else + dbInstance.SetTags(destination, FileTagsHelper.GetFileFRN(destination), tag); // copy tag to new files } else { diff --git a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs index 6f5cdf2e7fa7..ff73f5c8ab07 100644 --- a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs @@ -1438,7 +1438,6 @@ private Task HandleDriveItemDroppedAsync(DriveItem driveItem, Item private async Task HandleTagItemDroppedAsync(FileTagItem fileTagItem, ItemDroppedEventArgs args) { var storageItems = await Utils.Storage.FilesystemHelpers.GetDraggedStorageItems(args.DroppedItem); - var dbInstance = FileTagsHelper.GetDbInstance(); var pathToTags = new Dictionary(); foreach (var item in storageItems.Where(x => !string.IsNullOrEmpty(x.Path))) { @@ -1447,8 +1446,8 @@ private async Task HandleTagItemDroppedAsync(FileTagItem fileTagItem, ItemDroppe { filesTags = [.. filesTags, fileTagItem.FileTag.Uid]; var fileFRN = await FileTagsHelper.GetFileFRN(item.Item); - dbInstance.SetTags(item.Path, fileFRN, filesTags); - FileTagsHelper.WriteFileTag(item.Path, filesTags); + // Update the registry index and ADS as one synchronized operation. + _ = FileTagsHelper.SetTagsAndWriteFileTagAsync(item.Path, fileFRN, filesTags); pathToTags[item.Path] = filesTags; } }