diff --git a/Penumbra/Config/Configuration.cs b/Penumbra/Config/Configuration.cs index 94f291a3..587d5dfa 100644 --- a/Penumbra/Config/Configuration.cs +++ b/Penumbra/Config/Configuration.cs @@ -71,6 +71,7 @@ public partial class Configuration : IPluginConfiguration, ISavable, IService public bool DefaultTemporaryMode { get; set; } = false; public bool EnableDirectoryWatch { get; set; } = false; public bool EnableAutomaticModImport { get; set; } = false; + public bool PreventExportLoopback { get; set; } = true; public bool EnableCustomShapes { get; set; } = true; public PcpSettings PcpSettings = new(); diff --git a/Penumbra/Mods/Editor/ModBackup.cs b/Penumbra/Mods/Editor/ModBackup.cs index 7bdf86c7..ee691c29 100644 --- a/Penumbra/Mods/Editor/ModBackup.cs +++ b/Penumbra/Mods/Editor/ModBackup.cs @@ -1,4 +1,5 @@ using Penumbra.Mods.Manager; +using Penumbra.Services; using Penumbra.Util; namespace Penumbra.Mods.Editor; @@ -11,15 +12,17 @@ public class ModBackup public static bool CreatingBackup { get; private set; } - private readonly Mod _mod; - public readonly string Name; - public readonly bool Exists; + private readonly ModExportManager _modExport; + private readonly Mod _mod; + public readonly string Name; + public readonly bool Exists; public ModBackup(ModExportManager modExportManager, Mod mod) { - _mod = mod; - Name = Path.Combine(modExportManager.ExportDirectory.FullName, _mod.ModPath.Name) + ".pmp"; - Exists = File.Exists(Name); + _modExport = modExportManager; + _mod = mod; + Name = Path.Combine(modExportManager.ExportDirectory.FullName, _mod.ModPath.Name) + ".pmp"; + Exists = File.Exists(Name); } /// Migrate file extensions. @@ -87,6 +90,7 @@ public class ModBackup try { Delete(); + _modExport.IgnoreExportedFile(Name); ArchiveUtility.CreateFromDirectory(_mod.ModPath.FullName, Name); Penumbra.Log.Debug($"Created export file {Name} from {_mod.ModPath.FullName}."); } diff --git a/Penumbra/Mods/Manager/ModExportManager.cs b/Penumbra/Mods/Manager/ModExportManager.cs index d61e4eb5..e53f8c3e 100644 --- a/Penumbra/Mods/Manager/ModExportManager.cs +++ b/Penumbra/Mods/Manager/ModExportManager.cs @@ -9,17 +9,19 @@ public class ModExportManager : IDisposable, Luna.IService private readonly Configuration _config; private readonly CommunicatorService _communicator; private readonly ModManager _modManager; + private readonly FileWatcher _fileWatcher; private DirectoryInfo? _exportDirectory; public DirectoryInfo ExportDirectory => _exportDirectory ?? _modManager.BasePath; - public ModExportManager(Configuration config, CommunicatorService communicator, ModManager modManager) + public ModExportManager(Configuration config, CommunicatorService communicator, ModManager modManager, FileWatcher fileWatcher) { _config = config; _communicator = communicator; _modManager = modManager; + _fileWatcher = fileWatcher; UpdateExportDirectory(_config.ExportDirectory, false); _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModExportManager); } @@ -34,6 +36,12 @@ public class ModExportManager : IDisposable, Luna.IService return backup.CreateAsync(); } + public void IgnoreExportedFile(string fullPath) + { + if (_config.PreventExportLoopback) + _fileWatcher.IgnoreFile(fullPath); + } + /// /// Update the export directory to a new directory. Can also reset it to null with empty input. /// If the directory is changed, all existing backups will be moved to the new one. diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs index c9fec3f8..b2eab1e1 100644 --- a/Penumbra/Services/FileWatcher.cs +++ b/Penumbra/Services/FileWatcher.cs @@ -6,16 +6,20 @@ namespace Penumbra.Services; public sealed class FileWatcher : IDisposable, IService { - private readonly ConcurrentSet _pending = new(StringComparer.OrdinalIgnoreCase); - private readonly ModImportManager _modImportManager; - private readonly MessageService _messageService; - private readonly Configuration _config; + private readonly ConcurrentSet _pending = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _ignored = 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; + /// The time-to-live of ignore entries, in the same unit as , namely milliseconds. + private const long IgnoreTimeToLive = 60000L; + public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config) { _modImportManager = modImportManager; @@ -48,6 +52,12 @@ public sealed class FileWatcher : IDisposable, IService } } + public void IgnoreFile(string fullPath) + { + if (_config.EnableDirectoryWatch) + _ignored[fullPath] = Environment.TickCount64 + IgnoreTimeToLive; + } + private void EndFileWatcher() { if (_fsw is null) @@ -123,12 +133,16 @@ public sealed class FileWatcher : IDisposable, IService } private void OnPath(object? sender, FileSystemEventArgs e) - => _pending.TryAdd(e.FullPath); + { + if (!_ignored.TryRemove(e.FullPath, out var expiresAtTickCount) || expiresAtTickCount <= Environment.TickCount64) + _pending.TryAdd(e.FullPath); + } private async Task ConsumerLoopAsync(CancellationToken token) { while (true) { + GarbageCollectIgnored(); var path = _pending.FirstOrDefault(); if (path is null || _pausedConsumer) { @@ -155,6 +169,15 @@ public sealed class FileWatcher : IDisposable, IService } } + private void GarbageCollectIgnored() + { + foreach (var entry in _ignored) + { + if (Environment.TickCount64 >= entry.Value) + _ignored.TryRemove(entry); + } + } + private async Task ProcessOneAsync(string path, CancellationToken token) { // Downloads often finish via rename; file may be locked briefly. @@ -245,6 +268,15 @@ public sealed class FileWatcher : IDisposable, IService table.DrawColumn("Pending Files"u8); table.DrawColumn(StringU8.Join('\n', fileWatcher._pending)); + + table.DrawColumn("Ignored Files"u8); + // FIXME .ToList() forces the use of an IReadOnlyCollection overload because, at the time of writing, IEnumerable ones don't handle empty enumerables correctly. + table.DrawColumn(StringU8.Join((byte)'\n', fileWatcher._ignored.Select(entry => + (entry.Value - Environment.TickCount64) switch + { + <= 0 => $" {entry.Key}", + var ttl => $"<{ttl}ms> {entry.Key}", + }).ToList())); } } } diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs index 4e4f6a07..57aac71d 100644 --- a/Penumbra/Services/PcpService.cs +++ b/Penumbra/Services/PcpService.cs @@ -190,7 +190,9 @@ public class PcpService : IApiService, IDisposable var modDirectory = CreateMod(identifier, note, time); await CreateDefaultMod(modDirectory, meta, tree, cancel); await CreateCollectionInfo(modDirectory, objectIndex, identifier, note, time, cancel); - var file = ZipUp(modDirectory, modPath, Extension); + var file = GetFullZipPath(modDirectory, modPath, Extension); + _modExport.IgnoreExportedFile(file); + ZipUp(modDirectory, file); return (true, file); } catch (Exception ex) @@ -199,15 +201,19 @@ public class PcpService : IApiService, IDisposable } } - private static string ZipUp(DirectoryInfo directory, string? path, string extension) + private static string GetFullZipPath(DirectoryInfo directory, string? path, string extension) { if (path is null) path = directory.FullName + extension; else if (Path.GetExtension(path.AsSpan()).IsEmpty) path += extension; + return path; + } + + private static void ZipUp(DirectoryInfo directory, string path) + { ArchiveUtility.CreateFromDirectory(directory.FullName, path); directory.Delete(true); - return path; } private async Task CreateCollectionInfo(DirectoryInfo directory, ObjectIndex index, ActorIdentifier actor, string note, DateTime time, diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index 9595e7a5..3b66a176 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -639,6 +639,9 @@ public sealed class SettingsTab : ITab Checkbox("Enable Fully Automatic Import"u8, "Uses the File Watcher in order to skip the query popup and automatically import any new mods."u8, _config.EnableAutomaticModImport, v => _config.EnableAutomaticModImport = v); + Checkbox("Prevent Exported Mods From Being Automatically Reimported"u8, + "If your Automatic Import Directory is the same as your Default Mod Export Directory, prevents mods and character packs you export from being reimported or showing a query popup."u8, + _config.PreventExportLoopback, v => _config.PreventExportLoopback = v); DrawFileWatcherPath(); } @@ -738,7 +741,7 @@ public sealed class SettingsTab : ITab } style.Pop(); - LunaStyle.DrawAlignedHelpMarkerLabel("Automatic Import Director"u8, + LunaStyle.DrawAlignedHelpMarkerLabel("Automatic Import Directory"u8, "Choose the Directory the File Watcher listens to."u8); }