diff --git a/Luna b/Luna index 78216203..c764db88 160000 --- a/Luna +++ b/Luna @@ -1 +1 @@ -Subproject commit 78216203f4570a6194fce9422204d8abb536c828 +Subproject commit c764db88097c88cd49f2bed4f60268d617f97fdb diff --git a/OtterGui b/OtterGui index 9af1e5fc..a63f6735 160000 --- a/OtterGui +++ b/OtterGui @@ -1 +1 @@ -Subproject commit 9af1e5fce4c13ef98842807d4f593dec8ae80c87 +Subproject commit a63f6735cf4bed4f7502a022a10378607082b770 diff --git a/Penumbra.Api b/Penumbra.Api index 97fe622e..c23ee05c 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit 97fe622e4ec0a5469a26aba8a8c3933fa8ef7fd6 +Subproject commit c23ee05c1e9fa103eaa52e6aa7e855ef568ee669 diff --git a/Penumbra.GameData b/Penumbra.GameData index 182cca56..cf3d868e 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit 182cca56a49411430233d73d7a8a6bb3d983f8f0 +Subproject commit cf3d868eeeb4ea3ea728ae15a8d09ec127ce80e9 diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs index 454963fb..e3ca27a7 100644 --- a/Penumbra/Configuration.cs +++ b/Penumbra/Configuration.cs @@ -51,6 +51,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; @@ -74,6 +75,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; diff --git a/Penumbra/EphemeralConfig.cs b/Penumbra/EphemeralConfig.cs index 7a5091be..caf34027 100644 --- a/Penumbra/EphemeralConfig.cs +++ b/Penumbra/EphemeralConfig.cs @@ -41,7 +41,7 @@ public class EphemeralConfig : ISavable, IDisposable, IService public ChangedItemIconFlag ChangedItemFilter { get; set; } = ChangedItemFlagExtensions.DefaultFlags; public bool FixMainWindow { get; set; } = false; public string LastModPath { get; set; } = string.Empty; - public bool AdvancedEditingOpen { get; set; } = false; + public HashSet AdvancedEditingOpenForModPaths { get; set; } = []; public bool ForceRedrawOnFileChange { get; set; } = false; public bool IncognitoMode { get; set; } = false; diff --git a/Penumbra/Interop/PathResolving/CutsceneService.cs b/Penumbra/Interop/PathResolving/CutsceneService.cs index 23d1b9bf..892688b6 100644 --- a/Penumbra/Interop/PathResolving/CutsceneService.cs +++ b/Penumbra/Interop/PathResolving/CutsceneService.cs @@ -73,6 +73,7 @@ public sealed class CutsceneService : Luna.IRequiredService, IDisposable return false; _copiedCharacters[copyIdx - CutsceneStartIdx] = (short)parentIdx; + _objects.InvokeRequiredUpdates(); return true; } diff --git a/Penumbra/Mods/Manager/ModStorage.cs b/Penumbra/Mods/Manager/ModStorage.cs index ea82582d..15d86866 100644 --- a/Penumbra/Mods/Manager/ModStorage.cs +++ b/Penumbra/Mods/Manager/ModStorage.cs @@ -1,76 +1,88 @@ -using OtterGui.Classes; -using OtterGui.Widgets; - -namespace Penumbra.Mods.Manager; - -public class ModCombo(Func> generator) : FilterComboCache(generator, MouseWheelType.None, Penumbra.Log) -{ - protected override bool IsVisible(int globalIndex, LowerString filter) - => Items[globalIndex].Name.Contains(filter); - - protected override string ToString(Mod obj) - => obj.Name; -} - -public class ModStorage : IReadOnlyList -{ - /// The actual list of mods. - protected readonly List Mods = []; - - public int Count - => Mods.Count; - - public Mod this[int idx] - => Mods[idx]; - - public Mod this[Index idx] - => Mods[idx]; - - public IEnumerator GetEnumerator() - => Mods.GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() - => GetEnumerator(); - - /// - /// Try to obtain a mod by its directory name (unique identifier, preferred), - /// or the first mod of the given name if no directory fits. - /// - public bool TryGetMod(string identifier, string modName, [NotNullWhen(true)] out Mod? mod) - { - mod = null; - foreach (var m in Mods) - { - if (string.Equals(m.Identifier, identifier, StringComparison.OrdinalIgnoreCase)) - { - mod = m; - return true; - } - - if (m.Name == modName) - mod ??= m; - } - - return mod != null; - } - - /// - /// An easily accessible set of new mods. - /// Mods are added when they are created or imported. - /// Mods are removed when they are deleted or when they are toggled in any collection. - /// Also gets cleared on mod rediscovery. - /// - private readonly HashSet _newMods = []; - - public bool IsNew(Mod mod) - => _newMods.Contains(mod); - - public void SetNew(Mod mod) - => _newMods.Add(mod); - - public void SetKnown(Mod mod) - => _newMods.Remove(mod); - - public void ClearNewMods() - => _newMods.Clear(); -} +using OtterGui.Classes; +using OtterGui.Widgets; + +namespace Penumbra.Mods.Manager; + +public class ModCombo(Func> generator) : FilterComboCache(generator, MouseWheelType.None, Penumbra.Log) +{ + protected override bool IsVisible(int globalIndex, LowerString filter) + => Items[globalIndex].Name.Contains(filter); + + protected override string ToString(Mod obj) + => obj.Name; +} + +public class ModStorage : IReadOnlyList +{ + /// The actual list of mods. + protected readonly List Mods = []; + + public int Count + => Mods.Count; + + public Mod this[int idx] + => Mods[idx]; + + public Mod this[Index idx] + => Mods[idx]; + + public IEnumerator GetEnumerator() + => Mods.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + /// + /// Try to obtain a mod by its directory name (unique identifier). + /// + public bool TryGetMod(string identifier, [NotNullWhen(true)] out Mod? mod) + { + mod = this.FirstOrDefault(m => string.Equals(m.Identifier, identifier, StringComparison.OrdinalIgnoreCase)); + return mod is not null; + } + + /// + /// Try to obtain a mod by its directory name (unique identifier, preferred), + /// or the first mod of the given name if no directory fits. + /// + public bool TryGetMod(string identifier, string modName, [NotNullWhen(true)] out Mod? mod) + { + if (modName.Length is 0) + return TryGetMod(identifier, out mod); + + mod = null; + foreach (var m in Mods) + { + if (string.Equals(m.Identifier, identifier, StringComparison.OrdinalIgnoreCase)) + { + mod = m; + return true; + } + + if (m.Name == modName) + mod ??= m; + } + + return mod != null; + } + + /// + /// An easily accessible set of new mods. + /// Mods are added when they are created or imported. + /// Mods are removed when they are deleted or when they are toggled in any collection. + /// Also gets cleared on mod rediscovery. + /// + private readonly HashSet _newMods = []; + + public bool IsNew(Mod mod) + => _newMods.Contains(mod); + + public void SetNew(Mod mod) + => _newMods.Add(mod); + + public void SetKnown(Mod mod) + => _newMods.Remove(mod); + + public void ClearNewMods() + => _newMods.Clear(); +} diff --git a/Penumbra/Mods/ModSelection.cs b/Penumbra/Mods/ModSelection.cs index 986f074b..294d8244 100644 --- a/Penumbra/Mods/ModSelection.cs +++ b/Penumbra/Mods/ModSelection.cs @@ -27,8 +27,8 @@ public class ModSelection : EventBase 0) - SelectMod(mods.FirstOrDefault(m => string.Equals(m.Identifier, config.LastModPath, StringComparison.OrdinalIgnoreCase))); + if (_config.LastModPath.Length > 0 && mods.TryGetMod(config.LastModPath, out var mod)) + SelectMod(mod); _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.ModSelection); _communicator.CollectionInheritanceChanged.Subscribe(OnInheritanceChange, CollectionInheritanceChanged.Priority.ModSelection); diff --git a/Penumbra/Penumbra.cs b/Penumbra/Penumbra.cs index 770f809a..24066e6e 100644 --- a/Penumbra/Penumbra.cs +++ b/Penumbra/Penumbra.cs @@ -141,8 +141,14 @@ public class Penumbra : IDalamudPlugin if (!_disposed) { _windowSystem = system; - if (_config is { OpenWindowAtStart: true, Ephemeral.AdvancedEditingOpen: true } && _services.GetService().Mod is {} mod) - _services.GetService().OpenForMod(mod); + if (_config is { OpenWindowAtStart: true, Ephemeral.AdvancedEditingOpenForModPaths.Count: > 0 }) + { + var mods = _services.GetService(); + var editWindowFactory = _services.GetService(); + foreach (var identifier in _config.Ephemeral.AdvancedEditingOpenForModPaths) + if (mods.TryGetMod(identifier, out var mod)) + editWindowFactory.OpenForMod(mod); + } } else system.Dispose(); diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs new file mode 100644 index 00000000..92f5bebe --- /dev/null +++ b/Penumbra/Services/FileWatcher.cs @@ -0,0 +1,209 @@ +using Luna; +using Penumbra.Mods.Manager; + +namespace Penumbra.Services; + +public class FileWatcher : IDisposable, IService +{ + private readonly ConcurrentSet _pending = 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; + + public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config) + { + _modImportManager = modImportManager; + _messageService = messageService; + _config = config; + + if (_config.EnableDirectoryWatch) + { + SetupFileWatcher(_config.WatchDirectory); + SetupConsumerTask(); + } + } + + public void Toggle(bool value) + { + if (_config.EnableDirectoryWatch == value) + return; + + _config.EnableDirectoryWatch = value; + _config.Save(); + if (value) + { + SetupFileWatcher(_config.WatchDirectory); + SetupConsumerTask(); + } + else + { + EndFileWatcher(); + EndConsumerTask(); + } + } + + internal void PauseConsumer(bool pause) + => _pausedConsumer = pause; + + private void EndFileWatcher() + { + if (_fsw is null) + return; + + _fsw.Dispose(); + _fsw = null; + } + + private void SetupFileWatcher(string directory) + { + EndFileWatcher(); + _fsw = new FileSystemWatcher + { + 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; + UpdateDirectory(directory); + } + + + private void EndConsumerTask() + { + if (_cts is not null) + { + _cts.Cancel(); + _cts = null; + } + + _consumer = null; + } + + private void SetupConsumerTask() + { + EndConsumerTask(); + _cts = new CancellationTokenSource(); + _consumer = Task.Factory.StartNew( + () => ConsumerLoopAsync(_cts.Token), + _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + } + + public void UpdateDirectory(string newPath) + { + if (_config.WatchDirectory != newPath) + { + _config.WatchDirectory = newPath; + _config.Save(); + } + + if (_fsw is null) + return; + + _fsw.EnableRaisingEvents = false; + if (!Directory.Exists(newPath) || newPath.Length is 0) + { + _fsw.Path = string.Empty; + } + else + { + _fsw.Path = newPath; + _fsw.EnableRaisingEvents = true; + } + } + + private void OnPath(object? sender, FileSystemEventArgs e) + => _pending.TryAdd(e.FullPath); + + private async Task ConsumerLoopAsync(CancellationToken token) + { + while (true) + { + var path = _pending.FirstOrDefault(); + if (path is null || _pausedConsumer) + { + await Task.Delay(500, token).ConfigureAwait(false); + continue; + } + + try + { + await ProcessOneAsync(path, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Penumbra.Log.Debug("[FileWatcher] Canceled via Token."); + } + catch (Exception ex) + { + Penumbra.Log.Warning($"[FileWatcher] Error during Processing: {ex}"); + } + finally + { + _pending.TryRemove(path); + } + } + } + + 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); + else + _messageService.AddMessage(new InstallNotification(_modImportManager, path), false); + 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 Dispose() + { + EndConsumerTask(); + EndFileWatcher(); + } +} diff --git a/Penumbra/Services/InstallNotification.cs b/Penumbra/Services/InstallNotification.cs new file mode 100644 index 00000000..7b0da414 --- /dev/null +++ b/Penumbra/Services/InstallNotification.cs @@ -0,0 +1,49 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.ImGuiNotification.EventArgs; +using ImSharp; +using Penumbra.Mods.Manager; + +namespace Penumbra.Services; + +public class InstallNotification(ModImportManager modImportManager, string filePath) : Luna.IMessage +{ + public NotificationType NotificationType + => NotificationType.Info; + + public string NotificationMessage + => "A new mod has been found!"; + + public TimeSpan NotificationDuration + => TimeSpan.MaxValue; + + public string NotificationTitle { get; } = Path.GetFileNameWithoutExtension(filePath); + + public string LogMessage + => $"A new mod has been found: {Path.GetFileName(filePath)}"; + + public SeString ChatMessage + => SeString.Empty; + + public StringU8 StoredMessage + => StringU8.Empty; + + public StringU8 StoredTooltip + => StringU8.Empty; + + public void OnNotificationActions(INotificationDrawArgs args) + { + var region = Im.ContentRegion.Available; + var buttonSize = new Vector2((region.X - Im.Style.ItemSpacing.X) / 2, 0); + if (Im.Button("Install"u8, buttonSize)) + { + modImportManager.AddUnpack(filePath); + args.Notification.DismissNow(); + } + + ImGui.SameLine(); + if (Im.Button("Ignore"u8, buttonSize)) + args.Notification.DismissNow(); + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs index d6657420..73ee867b 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -180,8 +180,8 @@ public partial class ModEditWindow : IndexedWindow, IDisposable public override void OnClose() { base.OnClose(); - _config.Ephemeral.AdvancedEditingOpen = false; - _config.Ephemeral.Save(); + if (Mod is not null && _config.Ephemeral.AdvancedEditingOpenForModPaths.Remove(Mod.Identifier)) + _config.Ephemeral.Save(); AppendTask(() => { _left.Dispose(); @@ -194,11 +194,8 @@ public partial class ModEditWindow : IndexedWindow, IDisposable public override void Draw() { - if (!_config.Ephemeral.AdvancedEditingOpen) - { - _config.Ephemeral.AdvancedEditingOpen = true; + if (Mod is not null && _config.Ephemeral.AdvancedEditingOpenForModPaths.Add(Mod.Identifier)) _config.Ephemeral.Save(); - } if (IsLoading) { diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index 08a3b472..6980117d 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -36,6 +36,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; @@ -64,7 +65,8 @@ 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, @@ -81,6 +83,7 @@ public class SettingsTab : ITab, IUiService _characterUtility = characterUtility; _residentResources = residentResources; _modExportManager = modExportManager; + _fileWatcher = fileWatcher; _httpApi = httpApi; _dalamudSubstitutionProvider = dalamudSubstitutionProvider; _compactor = compactor; @@ -722,6 +725,13 @@ public class SettingsTab : ITab, IUiService DrawPcpFolder(); DrawPcpExtension(); 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, _fileWatcher.Toggle); + 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(); } @@ -801,6 +811,46 @@ public class SettingsTab : ITab, IUiService + "Keep this empty to use the root directory."); } + private string? _tempWatchDirectory; + + /// Draw input for the Automatic Mod import path. + private void DrawFileWatcherPath() + { + var tmp = _tempWatchDirectory ?? _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.IsItemDeactivated() && _tempWatchDirectory is not null) + { + if (ImGui.IsItemDeactivatedAfterEdit()) + _fileWatcher.UpdateDirectory(_tempWatchDirectory); + _tempWatchDirectory = null; + } + + 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); + }, startDir, false); + } + + style.Pop(); + ImGuiUtil.LabeledHelpMarker("Automatic Import Director", + "Choose the Directory the File Watcher listens to."); + } + /// Draw input for the default name to input as author into newly generated mods. private void DrawDefaultModAuthor() { diff --git a/repo.json b/repo.json index 2a31b75e..34405eb6 100644 --- a/repo.json +++ b/repo.json @@ -6,7 +6,7 @@ "Description": "Runtime mod loader and manager.", "InternalName": "Penumbra", "AssemblyVersion": "1.5.1.6", - "TestingAssemblyVersion": "1.5.1.6", + "TestingAssemblyVersion": "1.5.1.7", "RepoUrl": "https://github.com/xivdev/Penumbra", "ApplicableVersion": "any", "DalamudApiLevel": 13, @@ -19,7 +19,7 @@ "LoadRequiredState": 2, "LoadSync": true, "DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.6/Penumbra.zip", - "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.6/Penumbra.zip", + "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/testing_1.5.1.7/Penumbra.zip", "DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.6/Penumbra.zip", "IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png" }