File Watcher: Prevent export loopback

This commit is contained in:
Exter-N 2026-01-25 19:18:44 +01:00 committed by Ottermandias
parent d3cc5b0b58
commit c503aa2a14
6 changed files with 70 additions and 16 deletions

View file

@ -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();

View file

@ -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);
}
/// <summary> Migrate file extensions. </summary>
@ -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}.");
}

View file

@ -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);
}
/// <summary>
/// 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.

View file

@ -6,16 +6,20 @@ namespace Penumbra.Services;
public sealed class FileWatcher : IDisposable, IService
{
private readonly ConcurrentSet<string> _pending = new(StringComparer.OrdinalIgnoreCase);
private readonly ModImportManager _modImportManager;
private readonly MessageService _messageService;
private readonly Configuration _config;
private readonly ConcurrentSet<string> _pending = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, long> _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;
/// <summary> The time-to-live of ignore entries, in the same unit as <see cref="Environment.TickCount64"/>, namely milliseconds. </summary>
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<string>();
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 => $"<EXPIRED> {entry.Key}",
var ttl => $"<{ttl}ms> {entry.Key}",
}).ToList()));
}
}
}

View file

@ -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,

View file

@ -639,6 +639,9 @@ public sealed class SettingsTab : ITab<TabType>
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<TabType>
}
style.Pop();
LunaStyle.DrawAlignedHelpMarkerLabel("Automatic Import Director"u8,
LunaStyle.DrawAlignedHelpMarkerLabel("Automatic Import Directory"u8,
"Choose the Directory the File Watcher listens to."u8);
}