From c3b00ff42613270e3a8452dcafebaa795b9c226b Mon Sep 17 00:00:00 2001 From: Stoia <23234609+StoiaCode@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:22:18 +0200 Subject: [PATCH 1/4] Integrate FileWatcher HEAVY WIP --- Penumbra/Configuration.cs | 2 + Penumbra/Penumbra.cs | 2 + Penumbra/Services/FileWatcher.cs | 136 +++++++++++++++++++++++++++++++ Penumbra/UI/Tabs/SettingsTab.cs | 47 ++++++++++- 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 Penumbra/Services/FileWatcher.cs diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs index f9cad217..500d5d57 100644 --- a/Penumbra/Configuration.cs +++ b/Penumbra/Configuration.cs @@ -53,6 +53,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService public string ModDirectory { get; set; } = string.Empty; public string ExportDirectory { get; set; } = string.Empty; + public string WatchDirectory { get; set; } = string.Empty; public bool? UseCrashHandler { get; set; } = null; public bool OpenWindowAtStart { get; set; } = false; @@ -76,6 +77,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService public bool HideRedrawBar { get; set; } = false; public bool HideMachinistOffhandFromChangedItems { get; set; } = true; public bool DefaultTemporaryMode { get; set; } = false; + public bool EnableDirectoryWatch { get; set; } = false; public bool EnableCustomShapes { get; set; } = true; public PcpSettings PcpSettings = new(); public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; diff --git a/Penumbra/Penumbra.cs b/Penumbra/Penumbra.cs index f036adc7..0f5703a3 100644 --- a/Penumbra/Penumbra.cs +++ b/Penumbra/Penumbra.cs @@ -44,6 +44,7 @@ public class Penumbra : IDalamudPlugin private readonly TempModManager _tempMods; private readonly TempCollectionManager _tempCollections; private readonly ModManager _modManager; + private readonly FileWatcher _fileWatcher; private readonly CollectionManager _collectionManager; private readonly Configuration _config; private readonly CharacterUtility _characterUtility; @@ -81,6 +82,7 @@ public class Penumbra : IDalamudPlugin _residentResources = _services.GetService(); _services.GetService(); // Initialize because not required anywhere else. _modManager = _services.GetService(); + _fileWatcher = _services.GetService(); _collectionManager = _services.GetService(); _tempCollections = _services.GetService(); _redrawService = _services.GetService(); diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs new file mode 100644 index 00000000..8a2f9402 --- /dev/null +++ b/Penumbra/Services/FileWatcher.cs @@ -0,0 +1,136 @@ +using System.Threading.Channels; +using OtterGui.Services; +using Penumbra.Mods.Manager; + +namespace Penumbra.Services; +public class FileWatcher : IDisposable, IService +{ + 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 Configuration _config; + private readonly bool _enabled; + + public FileWatcher(ModImportManager modImportManager, Configuration config) + { + _config = config; + _modImportManager = modImportManager; + _enabled = config.EnableDirectoryWatch; + + if (!_enabled) return; + + _queue = Channel.CreateBounded(new BoundedChannelOptions(256) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.DropOldest + }); + + _fsw = new FileSystemWatcher(_config.WatchDirectory) + { + IncludeSubdirectories = false, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, + InternalBufferSize = 32 * 1024 + }; + + // Only wake us for the exact patterns we care about + _fsw.Filters.Add("*.pmp"); + _fsw.Filters.Add("*.pcp"); + _fsw.Filters.Add("*.ttmp"); + _fsw.Filters.Add("*.ttmp2"); + + _fsw.Created += OnPath; + _fsw.Renamed += OnPath; + + _consumer = Task.Factory.StartNew( + () => ConsumerLoopAsync(_cts.Token), + _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + _fsw.EnableRaisingEvents = true; + } + + private void OnPath(object? sender, FileSystemEventArgs e) + { + // Cheap de-dupe: only queue once per filename until processed + if (!_enabled || !_pending.TryAdd(e.FullPath, 0)) return; + _ = _queue.Writer.TryWrite(e.FullPath); + } + + private async Task ConsumerLoopAsync(CancellationToken token) + { + if (!_enabled) return; + var reader = _queue.Reader; + while (await reader.WaitToReadAsync(token).ConfigureAwait(false)) + { + while (reader.TryRead(out var path)) + { + 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 _); + } + } + } + } + + private async Task ProcessOneAsync(string path, CancellationToken token) + { + // Downloads often finish via rename; file may be locked briefly. + // Wait until it exists and is readable; also require two stable size checks. + const int maxTries = 40; + long lastLen = -1; + + for (int i = 0; i < maxTries && !token.IsCancellationRequested; i++) + { + if (!File.Exists(path)) { await Task.Delay(100, token); continue; } + + try + { + var fi = new FileInfo(path); + var len = fi.Length; + if (len > 0 && len == lastLen) + { + _modImportManager.AddUnpack(path); + return; + } + + lastLen = len; + } + catch (IOException) { Penumbra.Log.Debug($"[FileWatcher] File is still being written to."); } + catch (UnauthorizedAccessException) { Penumbra.Log.Debug($"[FileWatcher] File is locked."); } + + await Task.Delay(150, token); + } + } + + public void UpdateDirectory(string newPath) + { + if (!_enabled || _fsw is null || !Directory.Exists(newPath) || string.IsNullOrWhiteSpace(newPath)) return; + + _fsw.EnableRaisingEvents = false; + _fsw.Path = newPath; + _fsw.EnableRaisingEvents = true; + } + + public void Dispose() + { + if (!_enabled) 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/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index 308cc471..c84214f3 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -37,6 +37,7 @@ public class SettingsTab : ITab, IUiService private readonly Penumbra _penumbra; private readonly FileDialogService _fileDialog; private readonly ModManager _modManager; + private readonly FileWatcher _fileWatcher; private readonly ModExportManager _modExportManager; private readonly ModFileSystemSelector _selector; private readonly CharacterUtility _characterUtility; @@ -65,7 +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, 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, @@ -82,6 +83,7 @@ public class SettingsTab : ITab, IUiService _characterUtility = characterUtility; _residentResources = residentResources; _modExportManager = modExportManager; + _fileWatcher = fileWatcher; _httpApi = httpApi; _dalamudSubstitutionProvider = dalamudSubstitutionProvider; _compactor = compactor; @@ -647,6 +649,10 @@ public class SettingsTab : ITab, IUiService DrawDefaultModImportFolder(); DrawPcpFolder(); DrawDefaultModExportPath(); + Checkbox("Enable Automatic Import of Mods from Directory", + "Enables a File Watcher that automatically listens for Mod files that enter, causing Penumbra to automatically import these mods.", + _config.EnableDirectoryWatch, v => _config.EnableDirectoryWatch = v); + DrawFileWatcherPath(); } @@ -726,6 +732,45 @@ public class SettingsTab : ITab, IUiService + "Keep this empty to use the root directory."); } + private string _tempWatchDirectory = string.Empty; + /// Draw input for the Automatic Mod import path. + private void DrawFileWatcherPath() + { + 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.IsItemDeactivatedAfterEdit()) + _fileWatcher.UpdateDirectory(_tempWatchDirectory); + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton($"{FontAwesomeIcon.Folder.ToIconString()}##fileWatch", UiHelpers.IconButtonSize, + "Select a directory via dialog.", false, true)) + { + var startDir = _config.WatchDirectory.Length > 0 && Directory.Exists(_config.WatchDirectory) + ? _config.WatchDirectory + : Directory.Exists(_config.ModDirectory) + ? _config.ModDirectory + : null; + _fileDialog.OpenFolderPicker("Choose Automatic Import Directory", (b, s) => + { + if (b) + { + _fileWatcher.UpdateDirectory(s); + _config.WatchDirectory = s; + _config.Save(); + } + }, startDir, false); + } + + style.Pop(); + ImGuiUtil.LabeledHelpMarker("Automatic Import Director", + "Choose the Directory the File Watcher listens to."); + } + /// Draw input for the default name to input as author into newly generated mods. private void DrawDefaultModAuthor() { From f05cb52da2a77dc8b6bcd5cad3dd4b32d97febb3 Mon Sep 17 00:00:00 2001 From: Stoia <23234609+StoiaCode@users.noreply.github.com> Date: Wed, 22 Oct 2025 18:20:44 +0200 Subject: [PATCH 2/4] Add Option to notify instead of auto install. And General Fixes --- Penumbra/Configuration.cs | 1 + Penumbra/Services/FileWatcher.cs | 42 +++++++++++++++++++++-------- Penumbra/Services/MessageService.cs | 32 ++++++++++++++++++++++ Penumbra/UI/Tabs/SettingsTab.cs | 11 +++++--- 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs index 500d5d57..e337997b 100644 --- a/Penumbra/Configuration.cs +++ b/Penumbra/Configuration.cs @@ -78,6 +78,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService public bool HideMachinistOffhandFromChangedItems { get; set; } = true; public bool DefaultTemporaryMode { get; set; } = false; public bool EnableDirectoryWatch { get; set; } = false; + public bool EnableAutomaticModImport { get; set; } = false; public bool EnableCustomShapes { get; set; } = true; public PcpSettings PcpSettings = new(); public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs index 8a2f9402..e7172f58 100644 --- a/Penumbra/Services/FileWatcher.cs +++ b/Penumbra/Services/FileWatcher.cs @@ -1,5 +1,6 @@ using System.Threading.Channels; using OtterGui.Services; +using Penumbra.Services; using Penumbra.Mods.Manager; namespace Penumbra.Services; @@ -11,16 +12,16 @@ public class FileWatcher : IDisposable, IService private readonly Task _consumer; private readonly ConcurrentDictionary _pending = new(StringComparer.OrdinalIgnoreCase); private readonly ModImportManager _modImportManager; + private readonly MessageService _messageService; private readonly Configuration _config; - private readonly bool _enabled; - public FileWatcher(ModImportManager modImportManager, Configuration config) + public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config) { - _config = config; _modImportManager = modImportManager; - _enabled = config.EnableDirectoryWatch; + _messageService = messageService; + _config = config; - if (!_enabled) return; + if (!_config.EnableDirectoryWatch) return; _queue = Channel.CreateBounded(new BoundedChannelOptions(256) { @@ -55,13 +56,13 @@ public class FileWatcher : IDisposable, IService private void OnPath(object? sender, FileSystemEventArgs e) { // Cheap de-dupe: only queue once per filename until processed - if (!_enabled || !_pending.TryAdd(e.FullPath, 0)) return; + if (!_config.EnableDirectoryWatch || !_pending.TryAdd(e.FullPath, 0)) return; _ = _queue.Writer.TryWrite(e.FullPath); } private async Task ConsumerLoopAsync(CancellationToken token) { - if (!_enabled) return; + if (!_config.EnableDirectoryWatch) return; var reader = _queue.Reader; while (await reader.WaitToReadAsync(token).ConfigureAwait(false)) { @@ -101,8 +102,27 @@ public class FileWatcher : IDisposable, IService var len = fi.Length; if (len > 0 && len == lastLen) { - _modImportManager.AddUnpack(path); - return; + if (_config.EnableAutomaticModImport) + { + _modImportManager.AddUnpack(path); + return; + } + else + { + var invoked = false; + Action installRequest = args => + { + if (invoked) return; + invoked = true; + _modImportManager.AddUnpack(path); + }; + + _messageService.PrintModFoundInfo( + Path.GetFileNameWithoutExtension(path), + installRequest); + + return; + } } lastLen = len; @@ -116,7 +136,7 @@ public class FileWatcher : IDisposable, IService public void UpdateDirectory(string newPath) { - if (!_enabled || _fsw is null || !Directory.Exists(newPath) || string.IsNullOrWhiteSpace(newPath)) return; + if (!_config.EnableDirectoryWatch || _fsw is null || !Directory.Exists(newPath) || string.IsNullOrWhiteSpace(newPath)) return; _fsw.EnableRaisingEvents = false; _fsw.Path = newPath; @@ -125,7 +145,7 @@ public class FileWatcher : IDisposable, IService public void Dispose() { - if (!_enabled) return; + if (!_config.EnableDirectoryWatch) return; _fsw.EnableRaisingEvents = false; _cts.Cancel(); _fsw.Dispose(); diff --git a/Penumbra/Services/MessageService.cs b/Penumbra/Services/MessageService.cs index 70ccf47b..6c13fc38 100644 --- a/Penumbra/Services/MessageService.cs +++ b/Penumbra/Services/MessageService.cs @@ -1,19 +1,44 @@ +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 readonly Action _installRequest = installRequest; + 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 +80,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 c84214f3..217b6788 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -53,6 +53,7 @@ public class SettingsTab : ITab, IUiService private readonly MigrationSectionDrawer _migrationDrawer; private readonly CollectionAutoSelector _autoSelector; private readonly CleanupService _cleanupService; + private readonly MessageService _messageService; private readonly AttributeHook _attributeHook; private readonly PcpService _pcpService; @@ -69,7 +70,7 @@ public class SettingsTab : ITab, IUiService 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, + MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService, MessageService messageService, AttributeHook attributeHook, PcpService pcpService) { _pluginInterface = pluginInterface; @@ -96,6 +97,7 @@ public class SettingsTab : ITab, IUiService _migrationDrawer = migrationDrawer; _autoSelector = autoSelector; _cleanupService = cleanupService; + _messageService = messageService; _attributeHook = attributeHook; _pcpService = pcpService; } @@ -649,9 +651,12 @@ public class SettingsTab : ITab, IUiService DrawDefaultModImportFolder(); DrawPcpFolder(); DrawDefaultModExportPath(); - Checkbox("Enable Automatic Import of Mods from Directory", - "Enables a File Watcher that automatically listens for Mod files that enter, causing Penumbra to automatically import these mods.", + Checkbox("Enable Directory Watcher", + "Enables a File Watcher that automatically listens for Mod files that enter, causing Penumbra to open a Popup to import these mods.", _config.EnableDirectoryWatch, v => _config.EnableDirectoryWatch = v); + Checkbox("Enable Fully Automatic Import", + "Uses the File Watcher in order to not just open a Popup, but fully automatically import new mods.", + _config.EnableAutomaticModImport, v => _config.EnableAutomaticModImport = v); DrawFileWatcherPath(); } From cbedc878b94ceda8cc91105d5b2456b76bda2fdb Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Wed, 22 Oct 2025 21:56:16 +0200 Subject: [PATCH 3/4] Slight cleanup and autoformat. --- Penumbra/Configuration.cs | 2 +- Penumbra/Penumbra.cs | 2 - Penumbra/Services/FileWatcher.cs | 91 +++++++++++++++++++---------- Penumbra/Services/MessageService.cs | 3 +- Penumbra/UI/Tabs/SettingsTab.cs | 8 +-- 5 files changed, 66 insertions(+), 40 deletions(-) diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs index e337997b..2991230e 100644 --- a/Penumbra/Configuration.cs +++ b/Penumbra/Configuration.cs @@ -53,7 +53,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService public string ModDirectory { get; set; } = string.Empty; public string ExportDirectory { get; set; } = string.Empty; - public string WatchDirectory { get; set; } = string.Empty; + public string WatchDirectory { get; set; } = string.Empty; public bool? UseCrashHandler { get; set; } = null; public bool OpenWindowAtStart { get; set; } = false; diff --git a/Penumbra/Penumbra.cs b/Penumbra/Penumbra.cs index 8ed2c585..d433a0fb 100644 --- a/Penumbra/Penumbra.cs +++ b/Penumbra/Penumbra.cs @@ -43,7 +43,6 @@ public class Penumbra : IDalamudPlugin private readonly TempModManager _tempMods; private readonly TempCollectionManager _tempCollections; private readonly ModManager _modManager; - private readonly FileWatcher _fileWatcher; private readonly CollectionManager _collectionManager; private readonly Configuration _config; private readonly CharacterUtility _characterUtility; @@ -81,7 +80,6 @@ public class Penumbra : IDalamudPlugin _residentResources = _services.GetService(); _services.GetService(); // Initialize because not required anywhere else. _modManager = _services.GetService(); - _fileWatcher = _services.GetService(); _collectionManager = _services.GetService(); _tempCollections = _services.GetService(); _redrawService = _services.GetService(); diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs index e7172f58..141825f5 100644 --- a/Penumbra/Services/FileWatcher.cs +++ b/Penumbra/Services/FileWatcher.cs @@ -1,40 +1,41 @@ using System.Threading.Channels; using OtterGui.Services; -using Penumbra.Services; using Penumbra.Mods.Manager; namespace Penumbra.Services; + public class FileWatcher : IDisposable, IService { - 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 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; public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config) { _modImportManager = modImportManager; - _messageService = messageService; - _config = config; + _messageService = messageService; + _config = config; - if (!_config.EnableDirectoryWatch) return; + if (!_config.EnableDirectoryWatch) + return; _queue = Channel.CreateBounded(new BoundedChannelOptions(256) { SingleReader = true, SingleWriter = false, - FullMode = BoundedChannelFullMode.DropOldest + FullMode = BoundedChannelFullMode.DropOldest, }); _fsw = new FileSystemWatcher(_config.WatchDirectory) { IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, - InternalBufferSize = 32 * 1024 + NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, + InternalBufferSize = 32 * 1024, }; // Only wake us for the exact patterns we care about @@ -56,13 +57,17 @@ public class FileWatcher : IDisposable, IService private void OnPath(object? sender, FileSystemEventArgs e) { // Cheap de-dupe: only queue once per filename until processed - if (!_config.EnableDirectoryWatch || !_pending.TryAdd(e.FullPath, 0)) return; + if (!_config.EnableDirectoryWatch || !_pending.TryAdd(e.FullPath, 0)) + return; + _ = _queue.Writer.TryWrite(e.FullPath); } private async Task ConsumerLoopAsync(CancellationToken token) { - if (!_config.EnableDirectoryWatch) return; + if (!_config.EnableDirectoryWatch) + return; + var reader = _queue.Reader; while (await reader.WaitToReadAsync(token).ConfigureAwait(false)) { @@ -72,7 +77,10 @@ public class FileWatcher : IDisposable, IService { await ProcessOneAsync(path, token).ConfigureAwait(false); } - catch (OperationCanceledException) { Penumbra.Log.Debug($"[FileWatcher] Canceled via Token."); } + catch (OperationCanceledException) + { + Penumbra.Log.Debug($"[FileWatcher] Canceled via Token."); + } catch (Exception ex) { Penumbra.Log.Debug($"[FileWatcher] Error during Processing: {ex}"); @@ -90,15 +98,19 @@ public class FileWatcher : IDisposable, IService // Downloads often finish via rename; file may be locked briefly. // Wait until it exists and is readable; also require two stable size checks. const int maxTries = 40; - long lastLen = -1; + long lastLen = -1; - for (int i = 0; i < maxTries && !token.IsCancellationRequested; i++) + for (var i = 0; i < maxTries && !token.IsCancellationRequested; i++) { - if (!File.Exists(path)) { await Task.Delay(100, token); continue; } + if (!File.Exists(path)) + { + await Task.Delay(100, token); + continue; + } try { - var fi = new FileInfo(path); + var fi = new FileInfo(path); var len = fi.Length; if (len > 0 && len == lastLen) { @@ -112,7 +124,9 @@ public class FileWatcher : IDisposable, IService var invoked = false; Action installRequest = args => { - if (invoked) return; + if (invoked) + return; + invoked = true; _modImportManager.AddUnpack(path); }; @@ -122,13 +136,19 @@ public class FileWatcher : IDisposable, IService installRequest); return; - } + } } lastLen = len; } - catch (IOException) { Penumbra.Log.Debug($"[FileWatcher] File is still being written to."); } - catch (UnauthorizedAccessException) { Penumbra.Log.Debug($"[FileWatcher] File is locked."); } + catch (IOException) + { + Penumbra.Log.Debug($"[FileWatcher] File is still being written to."); + } + catch (UnauthorizedAccessException) + { + Penumbra.Log.Debug($"[FileWatcher] File is locked."); + } await Task.Delay(150, token); } @@ -136,21 +156,32 @@ public class FileWatcher : IDisposable, IService public void UpdateDirectory(string newPath) { - if (!_config.EnableDirectoryWatch || _fsw is null || !Directory.Exists(newPath) || string.IsNullOrWhiteSpace(newPath)) return; + if (!_config.EnableDirectoryWatch || _fsw is null || !Directory.Exists(newPath) || string.IsNullOrWhiteSpace(newPath)) + return; _fsw.EnableRaisingEvents = false; - _fsw.Path = newPath; + _fsw.Path = newPath; _fsw.EnableRaisingEvents = true; } public void Dispose() { - if (!_config.EnableDirectoryWatch) return; + if (!_config.EnableDirectoryWatch) + return; + _fsw.EnableRaisingEvents = false; _cts.Cancel(); _fsw.Dispose(); _queue.Writer.TryComplete(); - try { _consumer.Wait(TimeSpan.FromSeconds(5)); } catch { /* swallow */ } + try + { + _consumer.Wait(TimeSpan.FromSeconds(5)); + } + catch + { + /* swallow */ + } + _cts.Dispose(); } } diff --git a/Penumbra/Services/MessageService.cs b/Penumbra/Services/MessageService.cs index 6c13fc38..3dc6a90c 100644 --- a/Penumbra/Services/MessageService.cs +++ b/Penumbra/Services/MessageService.cs @@ -20,7 +20,6 @@ namespace Penumbra.Services; public class InstallNotification(string message, Action installRequest) : IMessage { - private readonly Action _installRequest = installRequest; private bool _invoked = false; public string Message { get; } = message; @@ -33,7 +32,7 @@ public class InstallNotification(string message, Action installRequest) : { if (ImUtf8.ButtonEx("Install"u8, "Install this mod."u8, disabled: _invoked)) { - _installRequest(true); + installRequest(true); _invoked = true; } } diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index 217b6788..46f4d38f 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -53,7 +53,6 @@ public class SettingsTab : ITab, IUiService private readonly MigrationSectionDrawer _migrationDrawer; private readonly CollectionAutoSelector _autoSelector; private readonly CleanupService _cleanupService; - private readonly MessageService _messageService; private readonly AttributeHook _attributeHook; private readonly PcpService _pcpService; @@ -70,7 +69,7 @@ public class SettingsTab : ITab, IUiService 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, MessageService messageService, + MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService, AttributeHook attributeHook, PcpService pcpService) { _pluginInterface = pluginInterface; @@ -97,7 +96,6 @@ public class SettingsTab : ITab, IUiService _migrationDrawer = migrationDrawer; _autoSelector = autoSelector; _cleanupService = cleanupService; - _messageService = messageService; _attributeHook = attributeHook; _pcpService = pcpService; } @@ -652,10 +650,10 @@ public class SettingsTab : ITab, IUiService DrawPcpFolder(); DrawDefaultModExportPath(); Checkbox("Enable Directory Watcher", - "Enables a File Watcher that automatically listens for Mod files that enter, causing Penumbra to open a Popup to import these mods.", + "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, v => _config.EnableDirectoryWatch = v); Checkbox("Enable Fully Automatic Import", - "Uses the File Watcher in order to not just open a Popup, but fully automatically import new mods.", + "Uses the File Watcher in order to skip the query popup and automatically import any new mods.", _config.EnableAutomaticModImport, v => _config.EnableAutomaticModImport = v); DrawFileWatcherPath(); } From 5bf901d0c45f7c0384480387cab03eb626d25899 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Thu, 23 Oct 2025 17:30:29 +0200 Subject: [PATCH 4/4] Update actorobjectmanager when setting cutscene index. --- OtterGui | 2 +- Penumbra.Api | 2 +- Penumbra.GameData | 2 +- Penumbra/Interop/PathResolving/CutsceneService.cs | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/OtterGui b/OtterGui index 9af1e5fc..a63f6735 160000 --- a/OtterGui +++ b/OtterGui @@ -1 +1 @@ -Subproject commit 9af1e5fce4c13ef98842807d4f593dec8ae80c87 +Subproject commit a63f6735cf4bed4f7502a022a10378607082b770 diff --git a/Penumbra.Api b/Penumbra.Api index dd141317..c23ee05c 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit dd14131793e5ae47cc8e9232f46469216017b5aa +Subproject commit c23ee05c1e9fa103eaa52e6aa7e855ef568ee669 diff --git a/Penumbra.GameData b/Penumbra.GameData index 3baace73..283d51f6 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit 3baace73c828271dcb71a8156e3e7b91e1dd12ae +Subproject commit 283d51f6f6c7721a810548d95ba83eef2484e17e diff --git a/Penumbra/Interop/PathResolving/CutsceneService.cs b/Penumbra/Interop/PathResolving/CutsceneService.cs index 6be19c46..97e64f84 100644 --- a/Penumbra/Interop/PathResolving/CutsceneService.cs +++ b/Penumbra/Interop/PathResolving/CutsceneService.cs @@ -75,6 +75,7 @@ public sealed class CutsceneService : IRequiredService, IDisposable return false; _copiedCharacters[copyIdx - CutsceneStartIdx] = (short)parentIdx; + _objects.InvokeRequiredUpdates(); return true; }