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);
}