Compare commits

...

6 commits

Author SHA1 Message Date
Ottermandias
5bf901d0c4 Update actorobjectmanager when setting cutscene index.
Some checks are pending
.NET Build / build (push) Waiting to run
2025-10-23 17:30:29 +02:00
Ottermandias
cbedc878b9 Slight cleanup and autoformat. 2025-10-22 21:56:16 +02:00
Ottermandias
c8cf560fc1 Merge branch 'refs/heads/StoiaCode/fileWatcher' 2025-10-22 21:48:42 +02:00
Stoia
f05cb52da2 Add Option to notify instead of auto install.
And General Fixes
2025-10-22 18:20:44 +02:00
Stoia
60aa23efcd
Merge branch 'xivdev:master' into fileWatcher 2025-10-22 14:28:08 +02:00
Stoia
c3b00ff426 Integrate FileWatcher
HEAVY WIP
2025-09-06 14:22:18 +02:00
8 changed files with 274 additions and 4 deletions

@ -1 +1 @@
Subproject commit 9af1e5fce4c13ef98842807d4f593dec8ae80c87
Subproject commit a63f6735cf4bed4f7502a022a10378607082b770

@ -1 +1 @@
Subproject commit dd14131793e5ae47cc8e9232f46469216017b5aa
Subproject commit c23ee05c1e9fa103eaa52e6aa7e855ef568ee669

@ -1 +1 @@
Subproject commit 3baace73c828271dcb71a8156e3e7b91e1dd12ae
Subproject commit 283d51f6f6c7721a810548d95ba83eef2484e17e

View file

@ -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,8 @@ 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 EnableAutomaticModImport { get; set; } = false;
public bool EnableCustomShapes { get; set; } = true;
public PcpSettings PcpSettings = new();
public RenameField ShowRename { get; set; } = RenameField.BothDataPrio;

View file

@ -75,6 +75,7 @@ public sealed class CutsceneService : IRequiredService, IDisposable
return false;
_copiedCharacters[copyIdx - CutsceneStartIdx] = (short)parentIdx;
_objects.InvokeRequiredUpdates();
return true;
}

View file

@ -0,0 +1,187 @@
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 MessageService _messageService;
private readonly Configuration _config;
public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config)
{
_modImportManager = modImportManager;
_messageService = messageService;
_config = config;
if (!_config.EnableDirectoryWatch)
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 (!_config.EnableDirectoryWatch || !_pending.TryAdd(e.FullPath, 0))
return;
_ = _queue.Writer.TryWrite(e.FullPath);
}
private async Task ConsumerLoopAsync(CancellationToken token)
{
if (!_config.EnableDirectoryWatch)
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 (var 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)
{
if (_config.EnableAutomaticModImport)
{
_modImportManager.AddUnpack(path);
return;
}
else
{
var invoked = false;
Action<bool> installRequest = args =>
{
if (invoked)
return;
invoked = true;
_modImportManager.AddUnpack(path);
};
_messageService.PrintModFoundInfo(
Path.GetFileNameWithoutExtension(path),
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.");
}
await Task.Delay(150, token);
}
}
public void UpdateDirectory(string newPath)
{
if (!_config.EnableDirectoryWatch || _fsw is null || !Directory.Exists(newPath) || string.IsNullOrWhiteSpace(newPath))
return;
_fsw.EnableRaisingEvents = false;
_fsw.Path = newPath;
_fsw.EnableRaisingEvents = true;
}
public void Dispose()
{
if (!_config.EnableDirectoryWatch)
return;
_fsw.EnableRaisingEvents = false;
_cts.Cancel();
_fsw.Dispose();
_queue.Writer.TryComplete();
try
{
_consumer.Wait(TimeSpan.FromSeconds(5));
}
catch
{
/* swallow */
}
_cts.Dispose();
}
}

View file

@ -1,19 +1,43 @@
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<bool> installRequest) : IMessage
{
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 +79,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<bool> installRequest)
{
AddMessage(
new InstallNotification($"A new mod has been found: {fileName}", installRequest)
);
}
}

View file

@ -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,13 @@ public class SettingsTab : ITab, IUiService
DrawDefaultModImportFolder();
DrawPcpFolder();
DrawDefaultModExportPath();
Checkbox("Enable Directory Watcher",
"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 skip the query popup and automatically import any new mods.",
_config.EnableAutomaticModImport, v => _config.EnableAutomaticModImport = v);
DrawFileWatcherPath();
}
@ -726,6 +735,45 @@ public class SettingsTab : ITab, IUiService
+ "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>
private void DrawDefaultModAuthor()
{