Integrate FileWatcher

HEAVY WIP
This commit is contained in:
Stoia 2025-09-06 14:22:18 +02:00
parent 6348c4a639
commit c3b00ff426
4 changed files with 186 additions and 1 deletions

View file

@ -53,6 +53,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService
public string ModDirectory { get; set; } = string.Empty; public string ModDirectory { get; set; } = string.Empty;
public string ExportDirectory { 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? UseCrashHandler { get; set; } = null;
public bool OpenWindowAtStart { get; set; } = false; public bool OpenWindowAtStart { get; set; } = false;
@ -76,6 +77,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService
public bool HideRedrawBar { get; set; } = false; public bool HideRedrawBar { get; set; } = false;
public bool HideMachinistOffhandFromChangedItems { get; set; } = true; public bool HideMachinistOffhandFromChangedItems { get; set; } = true;
public bool DefaultTemporaryMode { get; set; } = false; public bool DefaultTemporaryMode { get; set; } = false;
public bool EnableDirectoryWatch { get; set; } = false;
public bool EnableCustomShapes { get; set; } = true; public bool EnableCustomShapes { get; set; } = true;
public PcpSettings PcpSettings = new(); public PcpSettings PcpSettings = new();
public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; public RenameField ShowRename { get; set; } = RenameField.BothDataPrio;

View file

@ -44,6 +44,7 @@ public class Penumbra : IDalamudPlugin
private readonly TempModManager _tempMods; private readonly TempModManager _tempMods;
private readonly TempCollectionManager _tempCollections; private readonly TempCollectionManager _tempCollections;
private readonly ModManager _modManager; private readonly ModManager _modManager;
private readonly FileWatcher _fileWatcher;
private readonly CollectionManager _collectionManager; private readonly CollectionManager _collectionManager;
private readonly Configuration _config; private readonly Configuration _config;
private readonly CharacterUtility _characterUtility; private readonly CharacterUtility _characterUtility;
@ -81,6 +82,7 @@ public class Penumbra : IDalamudPlugin
_residentResources = _services.GetService<ResidentResourceManager>(); _residentResources = _services.GetService<ResidentResourceManager>();
_services.GetService<ResourceManagerService>(); // Initialize because not required anywhere else. _services.GetService<ResourceManagerService>(); // Initialize because not required anywhere else.
_modManager = _services.GetService<ModManager>(); _modManager = _services.GetService<ModManager>();
_fileWatcher = _services.GetService<FileWatcher>();
_collectionManager = _services.GetService<CollectionManager>(); _collectionManager = _services.GetService<CollectionManager>();
_tempCollections = _services.GetService<TempCollectionManager>(); _tempCollections = _services.GetService<TempCollectionManager>();
_redrawService = _services.GetService<RedrawService>(); _redrawService = _services.GetService<RedrawService>();

View file

@ -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<string> _queue;
private readonly CancellationTokenSource _cts = new();
private readonly Task _consumer;
private readonly ConcurrentDictionary<string, byte> _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<string>(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();
}
}

View file

@ -37,6 +37,7 @@ public class SettingsTab : ITab, IUiService
private readonly Penumbra _penumbra; private readonly Penumbra _penumbra;
private readonly FileDialogService _fileDialog; private readonly FileDialogService _fileDialog;
private readonly ModManager _modManager; private readonly ModManager _modManager;
private readonly FileWatcher _fileWatcher;
private readonly ModExportManager _modExportManager; private readonly ModExportManager _modExportManager;
private readonly ModFileSystemSelector _selector; private readonly ModFileSystemSelector _selector;
private readonly CharacterUtility _characterUtility; private readonly CharacterUtility _characterUtility;
@ -65,7 +66,7 @@ public class SettingsTab : ITab, IUiService
public SettingsTab(IDalamudPluginInterface pluginInterface, Configuration config, FontReloader fontReloader, TutorialService tutorial, public SettingsTab(IDalamudPluginInterface pluginInterface, Configuration config, FontReloader fontReloader, TutorialService tutorial,
Penumbra penumbra, FileDialogService fileDialog, ModManager modManager, ModFileSystemSelector selector, 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, DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig,
IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService, IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService,
MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService, MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService,
@ -82,6 +83,7 @@ public class SettingsTab : ITab, IUiService
_characterUtility = characterUtility; _characterUtility = characterUtility;
_residentResources = residentResources; _residentResources = residentResources;
_modExportManager = modExportManager; _modExportManager = modExportManager;
_fileWatcher = fileWatcher;
_httpApi = httpApi; _httpApi = httpApi;
_dalamudSubstitutionProvider = dalamudSubstitutionProvider; _dalamudSubstitutionProvider = dalamudSubstitutionProvider;
_compactor = compactor; _compactor = compactor;
@ -647,6 +649,10 @@ public class SettingsTab : ITab, IUiService
DrawDefaultModImportFolder(); DrawDefaultModImportFolder();
DrawPcpFolder(); DrawPcpFolder();
DrawDefaultModExportPath(); 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."); + "Keep this empty to use the root directory.");
} }
private string _tempWatchDirectory = string.Empty;
/// <summary> Draw input for the Automatic Mod import path. </summary>
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.");
}
/// <summary> Draw input for the default name to input as author into newly generated mods. </summary> /// <summary> Draw input for the default name to input as author into newly generated mods. </summary>
private void DrawDefaultModAuthor() private void DrawDefaultModAuthor()
{ {