diff --git a/Penumbra.GameData b/Penumbra.GameData index d889f9ef..283d51f6 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit d889f9ef918514a46049725052d378b441915b00 +Subproject commit 283d51f6f6c7721a810548d95ba83eef2484e17e diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs index 1d572f05..141825f5 100644 --- a/Penumbra/Services/FileWatcher.cs +++ b/Penumbra/Services/FileWatcher.cs @@ -1,69 +1,37 @@ -using OtterGui.Services; +using System.Threading.Channels; +using OtterGui.Services; using Penumbra.Mods.Manager; namespace Penumbra.Services; public class FileWatcher : IDisposable, IService { - // TODO: use ConcurrentSet when it supports comparers in Luna. + private readonly FileSystemWatcher _fsw; + private readonly Channel _queue; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _consumer; private readonly ConcurrentDictionary _pending = new(StringComparer.OrdinalIgnoreCase); private readonly ModImportManager _modImportManager; private readonly MessageService _messageService; private readonly Configuration _config; - private bool _pausedConsumer; - private FileSystemWatcher? _fsw; - private CancellationTokenSource? _cts = new(); - private Task? _consumer; - public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config) { _modImportManager = modImportManager; _messageService = messageService; _config = config; - if (_config.EnableDirectoryWatch) - { - SetupFileWatcher(_config.WatchDirectory); - SetupConsumerTask(); - } - } - - public void Toggle(bool value) - { - if (_config.EnableDirectoryWatch == value) + if (!_config.EnableDirectoryWatch) return; - _config.EnableDirectoryWatch = value; - _config.Save(); - if (value) + _queue = Channel.CreateBounded(new BoundedChannelOptions(256) { - SetupFileWatcher(_config.WatchDirectory); - SetupConsumerTask(); - } - else - { - EndFileWatcher(); - EndConsumerTask(); - } - } + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.DropOldest, + }); - internal void PauseConsumer(bool pause) - => _pausedConsumer = pause; - - private void EndFileWatcher() - { - if (_fsw is null) - return; - - _fsw.Dispose(); - _fsw = null; - } - - private void SetupFileWatcher(string directory) - { - EndFileWatcher(); - _fsw = new FileSystemWatcher + _fsw = new FileSystemWatcher(_config.WatchDirectory) { IncludeSubdirectories = false, NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, @@ -78,81 +46,49 @@ public class FileWatcher : IDisposable, IService _fsw.Created += OnPath; _fsw.Renamed += OnPath; - UpdateDirectory(directory); - } - - private void EndConsumerTask() - { - if (_cts is not null) - { - _cts.Cancel(); - _cts = null; - } - _consumer = null; - } - - private void SetupConsumerTask() - { - EndConsumerTask(); - _cts = new CancellationTokenSource(); _consumer = Task.Factory.StartNew( () => ConsumerLoopAsync(_cts.Token), _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); - } - public void UpdateDirectory(string newPath) - { - if (_config.WatchDirectory != newPath) - { - _config.WatchDirectory = newPath; - _config.Save(); - } - - if (_fsw is null) - return; - - _fsw.EnableRaisingEvents = false; - if (!Directory.Exists(newPath) || newPath.Length is 0) - { - _fsw.Path = string.Empty; - } - else - { - _fsw.Path = newPath; - _fsw.EnableRaisingEvents = true; - } + _fsw.EnableRaisingEvents = true; } private void OnPath(object? sender, FileSystemEventArgs e) - => _pending.TryAdd(e.FullPath, 0); + { + // Cheap de-dupe: only queue once per filename until processed + if (!_config.EnableDirectoryWatch || !_pending.TryAdd(e.FullPath, 0)) + return; + + _ = _queue.Writer.TryWrite(e.FullPath); + } private async Task ConsumerLoopAsync(CancellationToken token) { - while (true) - { - var (path, _) = _pending.FirstOrDefault(); - if (path is null || _pausedConsumer) - { - await Task.Delay(500, token).ConfigureAwait(false); - continue; - } + if (!_config.EnableDirectoryWatch) + return; - try + var reader = _queue.Reader; + while (await reader.WaitToReadAsync(token).ConfigureAwait(false)) + { + while (reader.TryRead(out var path)) { - await ProcessOneAsync(path, token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - Penumbra.Log.Debug("[FileWatcher] Canceled via Token."); - } - catch (Exception ex) - { - Penumbra.Log.Warning($"[FileWatcher] Error during Processing: {ex}"); - } - finally - { - _pending.TryRemove(path, out _); + try + { + await ProcessOneAsync(path, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Penumbra.Log.Debug($"[FileWatcher] Canceled via Token."); + } + catch (Exception ex) + { + Penumbra.Log.Debug($"[FileWatcher] Error during Processing: {ex}"); + } + finally + { + _pending.TryRemove(path, out _); + } } } } @@ -179,10 +115,28 @@ public class FileWatcher : IDisposable, IService if (len > 0 && len == lastLen) { if (_config.EnableAutomaticModImport) + { _modImportManager.AddUnpack(path); + return; + } else - _messageService.AddMessage(new InstallNotification(_modImportManager, path), false); - return; + { + var invoked = false; + Action installRequest = args => + { + if (invoked) + return; + + invoked = true; + _modImportManager.AddUnpack(path); + }; + + _messageService.PrintModFoundInfo( + Path.GetFileNameWithoutExtension(path), + installRequest); + + return; + } } lastLen = len; @@ -200,10 +154,34 @@ public class FileWatcher : IDisposable, IService } } + public void UpdateDirectory(string newPath) + { + if (!_config.EnableDirectoryWatch || _fsw is null || !Directory.Exists(newPath) || string.IsNullOrWhiteSpace(newPath)) + return; + + _fsw.EnableRaisingEvents = false; + _fsw.Path = newPath; + _fsw.EnableRaisingEvents = true; + } public void Dispose() { - EndConsumerTask(); - EndFileWatcher(); + if (!_config.EnableDirectoryWatch) + return; + + _fsw.EnableRaisingEvents = false; + _cts.Cancel(); + _fsw.Dispose(); + _queue.Writer.TryComplete(); + try + { + _consumer.Wait(TimeSpan.FromSeconds(5)); + } + catch + { + /* swallow */ + } + + _cts.Dispose(); } } diff --git a/Penumbra/Services/InstallNotification.cs b/Penumbra/Services/InstallNotification.cs deleted file mode 100644 index e3956076..00000000 --- a/Penumbra/Services/InstallNotification.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Dalamud.Bindings.ImGui; -using Dalamud.Interface.ImGuiNotification; -using Dalamud.Interface.ImGuiNotification.EventArgs; -using OtterGui.Text; -using Penumbra.Mods.Manager; - -namespace Penumbra.Services; - -public class InstallNotification(ModImportManager modImportManager, string filePath) : OtterGui.Classes.MessageService.IMessage -{ - public string Message - => "A new mod has been found!"; - - public NotificationType NotificationType - => NotificationType.Info; - - public uint NotificationDuration - => uint.MaxValue; - - public string NotificationTitle { get; } = Path.GetFileNameWithoutExtension(filePath); - - public string LogMessage - => $"A new mod has been found: {Path.GetFileName(filePath)}"; - - public void OnNotificationActions(INotificationDrawArgs args) - { - var region = ImGui.GetContentRegionAvail(); - var buttonSize = new Vector2((region.X - ImGui.GetStyle().ItemSpacing.X) / 2, 0); - if (ImUtf8.ButtonEx("Install"u8, ""u8, buttonSize)) - { - modImportManager.AddUnpack(filePath); - args.Notification.DismissNow(); - } - - ImGui.SameLine(); - if (ImUtf8.ButtonEx("Ignore"u8, ""u8, buttonSize)) - args.Notification.DismissNow(); - } -} diff --git a/Penumbra/Services/MessageService.cs b/Penumbra/Services/MessageService.cs index 70ccf47b..3dc6a90c 100644 --- a/Penumbra/Services/MessageService.cs +++ b/Penumbra/Services/MessageService.cs @@ -1,19 +1,43 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface; using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.ImGuiNotification.EventArgs; using Dalamud.Plugin.Services; using Lumina.Excel.Sheets; using OtterGui.Log; using OtterGui.Services; +using OtterGui.Text; using Penumbra.GameData.Data; using Penumbra.Mods.Manager; using Penumbra.String.Classes; +using static OtterGui.Classes.MessageService; using Notification = OtterGui.Classes.Notification; namespace Penumbra.Services; +public class InstallNotification(string message, Action installRequest) : IMessage +{ + private bool _invoked = false; + + public string Message { get; } = message; + + public NotificationType NotificationType => NotificationType.Info; + + public uint NotificationDuration => 10000; + + public void OnNotificationActions(INotificationDrawArgs args) + { + if (ImUtf8.ButtonEx("Install"u8, "Install this mod."u8, disabled: _invoked)) + { + installRequest(true); + _invoked = true; + } + } +} + public class MessageService(Logger log, IUiBuilder builder, IChatGui chat, INotificationManager notificationManager) : OtterGui.Classes.MessageService(log, builder, chat, notificationManager), IService { @@ -55,4 +79,11 @@ public class MessageService(Logger log, IUiBuilder builder, IChatGui chat, INoti $"Cowardly refusing to load replacement for {originalGamePath.Filename().ToString().ToLowerInvariant()} by {mod.Name}{(messageComplement.Length > 0 ? ":\n" : ".")}{messageComplement}", NotificationType.Warning, 10000)); } + + public void PrintModFoundInfo(string fileName, Action installRequest) + { + AddMessage( + new InstallNotification($"A new mod has been found: {fileName}", installRequest) + ); + } } diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index 86c01cb2..46f4d38f 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -66,8 +66,7 @@ public class SettingsTab : ITab, IUiService public SettingsTab(IDalamudPluginInterface pluginInterface, Configuration config, FontReloader fontReloader, TutorialService tutorial, Penumbra penumbra, FileDialogService fileDialog, ModManager modManager, ModFileSystemSelector selector, - CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager, - FileWatcher fileWatcher, HttpApi httpApi, + CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager, FileWatcher fileWatcher, HttpApi httpApi, DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig, IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService, MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService, @@ -652,7 +651,7 @@ public class SettingsTab : ITab, IUiService DrawDefaultModExportPath(); Checkbox("Enable Directory Watcher", "Enables a File Watcher that automatically listens for Mod files that enter a specified directory, causing Penumbra to open a popup to import these mods.", - _config.EnableDirectoryWatch, _fileWatcher.Toggle); + _config.EnableDirectoryWatch, v => _config.EnableDirectoryWatch = v); Checkbox("Enable Fully Automatic Import", "Uses the File Watcher in order to skip the query popup and automatically import any new mods.", _config.EnableAutomaticModImport, v => _config.EnableAutomaticModImport = v); @@ -736,24 +735,19 @@ public class SettingsTab : ITab, IUiService + "Keep this empty to use the root directory."); } - private string? _tempWatchDirectory; - + private string _tempWatchDirectory = string.Empty; /// Draw input for the Automatic Mod import path. private void DrawFileWatcherPath() { - var tmp = _tempWatchDirectory ?? _config.WatchDirectory; - var spacing = new Vector2(UiHelpers.ScaleX3); - using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing); + var tmp = _config.WatchDirectory; + var spacing = new Vector2(UiHelpers.ScaleX3); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing); ImGui.SetNextItemWidth(UiHelpers.InputTextMinusButton3); if (ImGui.InputText("##fileWatchPath", ref tmp, 256)) _tempWatchDirectory = tmp; - if (ImGui.IsItemDeactivated() && _tempWatchDirectory is not null) - { - if (ImGui.IsItemDeactivatedAfterEdit()) - _fileWatcher.UpdateDirectory(_tempWatchDirectory); - _tempWatchDirectory = null; - } + if (ImGui.IsItemDeactivatedAfterEdit()) + _fileWatcher.UpdateDirectory(_tempWatchDirectory); ImGui.SameLine(); if (ImGuiUtil.DrawDisabledButton($"{FontAwesomeIcon.Folder.ToIconString()}##fileWatch", UiHelpers.IconButtonSize, @@ -767,7 +761,11 @@ public class SettingsTab : ITab, IUiService _fileDialog.OpenFolderPicker("Choose Automatic Import Directory", (b, s) => { if (b) + { _fileWatcher.UpdateDirectory(s); + _config.WatchDirectory = s; + _config.Save(); + } }, startDir, false); } diff --git a/repo.json b/repo.json index 34405eb6..2a31b75e 100644 --- a/repo.json +++ b/repo.json @@ -6,7 +6,7 @@ "Description": "Runtime mod loader and manager.", "InternalName": "Penumbra", "AssemblyVersion": "1.5.1.6", - "TestingAssemblyVersion": "1.5.1.7", + "TestingAssemblyVersion": "1.5.1.6", "RepoUrl": "https://github.com/xivdev/Penumbra", "ApplicableVersion": "any", "DalamudApiLevel": 13, @@ -19,7 +19,7 @@ "LoadRequiredState": 2, "LoadSync": true, "DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.6/Penumbra.zip", - "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/testing_1.5.1.7/Penumbra.zip", + "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.6/Penumbra.zip", "DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.6/Penumbra.zip", "IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png" }