Compare commits

...

13 commits

Author SHA1 Message Date
Ottermandias
d79e687162 Update file watcher for Luna.
Some checks are pending
.NET Build / build (push) Waiting to run
2025-10-24 15:44:26 +02:00
Ottermandias
c47d920961 Merge remote-tracking branch 'origin/master' into luna
# Conflicts:
#	Penumbra.GameData
2025-10-24 15:22:38 +02:00
Ottermandias
90cffb1759 Use FirstOrDefault instead of manual iteration. 2025-10-24 15:21:57 +02:00
Ottermandias
349b62e549 Merge remote-tracking branch 'Exter-N/luna-multi-advedit' into luna 2025-10-24 15:17:45 +02:00
Exter-N
2742e4d485 Update AdvancedEditingOpen ephemeral setting 2025-10-24 14:12:42 +02:00
Actions User
c4b6e4e00b [CI] Updating repo.json for testing_1.5.1.7
Some checks failed
.NET Build / build (push) Has been cancelled
2025-10-23 21:50:20 +00:00
Ottermandias
912c183fc6 Improve file watcher. 2025-10-23 23:45:20 +02:00
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
15 changed files with 421 additions and 94 deletions

2
Luna

@ -1 +1 @@
Subproject commit 78216203f4570a6194fce9422204d8abb536c828 Subproject commit c764db88097c88cd49f2bed4f60268d617f97fdb

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

@ -1 +1 @@
Subproject commit 97fe622e4ec0a5469a26aba8a8c3933fa8ef7fd6 Subproject commit c23ee05c1e9fa103eaa52e6aa7e855ef568ee669

@ -1 +1 @@
Subproject commit 182cca56a49411430233d73d7a8a6bb3d983f8f0 Subproject commit cf3d868eeeb4ea3ea728ae15a8d09ec127ce80e9

View file

@ -51,6 +51,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService
public string ModDirectory { get; set; } = string.Empty; public string ModDirectory { get; set; } = string.Empty;
public string ExportDirectory { 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? UseCrashHandler { get; set; } = null;
public bool OpenWindowAtStart { get; set; } = false; public bool OpenWindowAtStart { get; set; } = false;
@ -74,6 +75,8 @@ public class Configuration : IPluginConfiguration, ISavable, IService
public bool HideRedrawBar { get; set; } = false; public bool HideRedrawBar { get; set; } = false;
public bool HideMachinistOffhandFromChangedItems { get; set; } = true; public bool HideMachinistOffhandFromChangedItems { get; set; } = true;
public bool DefaultTemporaryMode { get; set; } = false; 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 bool EnableCustomShapes { get; set; } = true;
public PcpSettings PcpSettings = new(); public PcpSettings PcpSettings = new();
public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; public RenameField ShowRename { get; set; } = RenameField.BothDataPrio;

View file

@ -41,7 +41,7 @@ public class EphemeralConfig : ISavable, IDisposable, IService
public ChangedItemIconFlag ChangedItemFilter { get; set; } = ChangedItemFlagExtensions.DefaultFlags; public ChangedItemIconFlag ChangedItemFilter { get; set; } = ChangedItemFlagExtensions.DefaultFlags;
public bool FixMainWindow { get; set; } = false; public bool FixMainWindow { get; set; } = false;
public string LastModPath { get; set; } = string.Empty; public string LastModPath { get; set; } = string.Empty;
public bool AdvancedEditingOpen { get; set; } = false; public HashSet<string> AdvancedEditingOpenForModPaths { get; set; } = [];
public bool ForceRedrawOnFileChange { get; set; } = false; public bool ForceRedrawOnFileChange { get; set; } = false;
public bool IncognitoMode { get; set; } = false; public bool IncognitoMode { get; set; } = false;

View file

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

View file

@ -1,76 +1,88 @@
using OtterGui.Classes; using OtterGui.Classes;
using OtterGui.Widgets; using OtterGui.Widgets;
namespace Penumbra.Mods.Manager; namespace Penumbra.Mods.Manager;
public class ModCombo(Func<IReadOnlyList<Mod>> generator) : FilterComboCache<Mod>(generator, MouseWheelType.None, Penumbra.Log) public class ModCombo(Func<IReadOnlyList<Mod>> generator) : FilterComboCache<Mod>(generator, MouseWheelType.None, Penumbra.Log)
{ {
protected override bool IsVisible(int globalIndex, LowerString filter) protected override bool IsVisible(int globalIndex, LowerString filter)
=> Items[globalIndex].Name.Contains(filter); => Items[globalIndex].Name.Contains(filter);
protected override string ToString(Mod obj) protected override string ToString(Mod obj)
=> obj.Name; => obj.Name;
} }
public class ModStorage : IReadOnlyList<Mod> public class ModStorage : IReadOnlyList<Mod>
{ {
/// <summary> The actual list of mods. </summary> /// <summary> The actual list of mods. </summary>
protected readonly List<Mod> Mods = []; protected readonly List<Mod> Mods = [];
public int Count public int Count
=> Mods.Count; => Mods.Count;
public Mod this[int idx] public Mod this[int idx]
=> Mods[idx]; => Mods[idx];
public Mod this[Index idx] public Mod this[Index idx]
=> Mods[idx]; => Mods[idx];
public IEnumerator<Mod> GetEnumerator() public IEnumerator<Mod> GetEnumerator()
=> Mods.GetEnumerator(); => Mods.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() IEnumerator IEnumerable.GetEnumerator()
=> GetEnumerator(); => GetEnumerator();
/// <summary> /// <summary>
/// Try to obtain a mod by its directory name (unique identifier, preferred), /// Try to obtain a mod by its directory name (unique identifier).
/// or the first mod of the given name if no directory fits. /// </summary>
/// </summary> public bool TryGetMod(string identifier, [NotNullWhen(true)] out Mod? mod)
public bool TryGetMod(string identifier, string modName, [NotNullWhen(true)] out Mod? mod) {
{ mod = this.FirstOrDefault(m => string.Equals(m.Identifier, identifier, StringComparison.OrdinalIgnoreCase));
mod = null; return mod is not null;
foreach (var m in Mods) }
{
if (string.Equals(m.Identifier, identifier, StringComparison.OrdinalIgnoreCase)) /// <summary>
{ /// Try to obtain a mod by its directory name (unique identifier, preferred),
mod = m; /// or the first mod of the given name if no directory fits.
return true; /// </summary>
} public bool TryGetMod(string identifier, string modName, [NotNullWhen(true)] out Mod? mod)
{
if (m.Name == modName) if (modName.Length is 0)
mod ??= m; return TryGetMod(identifier, out mod);
}
mod = null;
return mod != null; foreach (var m in Mods)
} {
if (string.Equals(m.Identifier, identifier, StringComparison.OrdinalIgnoreCase))
/// <summary> {
/// An easily accessible set of new mods. mod = m;
/// Mods are added when they are created or imported. return true;
/// Mods are removed when they are deleted or when they are toggled in any collection. }
/// Also gets cleared on mod rediscovery.
/// </summary> if (m.Name == modName)
private readonly HashSet<Mod> _newMods = []; mod ??= m;
}
public bool IsNew(Mod mod)
=> _newMods.Contains(mod); return mod != null;
}
public void SetNew(Mod mod)
=> _newMods.Add(mod); /// <summary>
/// An easily accessible set of new mods.
public void SetKnown(Mod mod) /// Mods are added when they are created or imported.
=> _newMods.Remove(mod); /// Mods are removed when they are deleted or when they are toggled in any collection.
/// Also gets cleared on mod rediscovery.
public void ClearNewMods() /// </summary>
=> _newMods.Clear(); private readonly HashSet<Mod> _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();
}

View file

@ -27,8 +27,8 @@ public class ModSelection : EventBase<ModSelection.Arguments, ModSelection.Prior
_communicator = communicator; _communicator = communicator;
_collections = collections; _collections = collections;
_config = config; _config = config;
if (_config.LastModPath.Length > 0) if (_config.LastModPath.Length > 0 && mods.TryGetMod(config.LastModPath, out var mod))
SelectMod(mods.FirstOrDefault(m => string.Equals(m.Identifier, config.LastModPath, StringComparison.OrdinalIgnoreCase))); SelectMod(mod);
_communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.ModSelection); _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.ModSelection);
_communicator.CollectionInheritanceChanged.Subscribe(OnInheritanceChange, CollectionInheritanceChanged.Priority.ModSelection); _communicator.CollectionInheritanceChanged.Subscribe(OnInheritanceChange, CollectionInheritanceChanged.Priority.ModSelection);

View file

@ -141,8 +141,14 @@ public class Penumbra : IDalamudPlugin
if (!_disposed) if (!_disposed)
{ {
_windowSystem = system; _windowSystem = system;
if (_config is { OpenWindowAtStart: true, Ephemeral.AdvancedEditingOpen: true } && _services.GetService<ModSelection>().Mod is {} mod) if (_config is { OpenWindowAtStart: true, Ephemeral.AdvancedEditingOpenForModPaths.Count: > 0 })
_services.GetService<ModEditWindowFactory>().OpenForMod(mod); {
var mods = _services.GetService<ModManager>();
var editWindowFactory = _services.GetService<ModEditWindowFactory>();
foreach (var identifier in _config.Ephemeral.AdvancedEditingOpenForModPaths)
if (mods.TryGetMod(identifier, out var mod))
editWindowFactory.OpenForMod(mod);
}
} }
else else
system.Dispose(); system.Dispose();

View file

@ -0,0 +1,209 @@
using Luna;
using Penumbra.Mods.Manager;
namespace Penumbra.Services;
public 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 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<string>();
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();
}
}

View file

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

View file

@ -180,8 +180,8 @@ public partial class ModEditWindow : IndexedWindow, IDisposable
public override void OnClose() public override void OnClose()
{ {
base.OnClose(); base.OnClose();
_config.Ephemeral.AdvancedEditingOpen = false; if (Mod is not null && _config.Ephemeral.AdvancedEditingOpenForModPaths.Remove(Mod.Identifier))
_config.Ephemeral.Save(); _config.Ephemeral.Save();
AppendTask(() => AppendTask(() =>
{ {
_left.Dispose(); _left.Dispose();
@ -194,11 +194,8 @@ public partial class ModEditWindow : IndexedWindow, IDisposable
public override void Draw() public override void Draw()
{ {
if (!_config.Ephemeral.AdvancedEditingOpen) if (Mod is not null && _config.Ephemeral.AdvancedEditingOpenForModPaths.Add(Mod.Identifier))
{
_config.Ephemeral.AdvancedEditingOpen = true;
_config.Ephemeral.Save(); _config.Ephemeral.Save();
}
if (IsLoading) if (IsLoading)
{ {

View file

@ -36,6 +36,7 @@ public class SettingsTab : ITab, IUiService
private readonly Penumbra _penumbra; private readonly Penumbra _penumbra;
private readonly FileDialogService _fileDialog; private readonly FileDialogService _fileDialog;
private readonly ModManager _modManager; private readonly ModManager _modManager;
private readonly FileWatcher _fileWatcher;
private readonly ModExportManager _modExportManager; private readonly ModExportManager _modExportManager;
private readonly ModFileSystemSelector _selector; private readonly ModFileSystemSelector _selector;
private readonly CharacterUtility _characterUtility; private readonly CharacterUtility _characterUtility;
@ -64,7 +65,8 @@ public class SettingsTab : ITab, IUiService
public SettingsTab(IDalamudPluginInterface pluginInterface, Configuration config, FontReloader fontReloader, TutorialService tutorial, public SettingsTab(IDalamudPluginInterface pluginInterface, Configuration config, FontReloader fontReloader, TutorialService tutorial,
Penumbra penumbra, FileDialogService fileDialog, ModManager modManager, ModFileSystemSelector selector, 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, DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig,
IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService, IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService,
MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService, MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService,
@ -81,6 +83,7 @@ public class SettingsTab : ITab, IUiService
_characterUtility = characterUtility; _characterUtility = characterUtility;
_residentResources = residentResources; _residentResources = residentResources;
_modExportManager = modExportManager; _modExportManager = modExportManager;
_fileWatcher = fileWatcher;
_httpApi = httpApi; _httpApi = httpApi;
_dalamudSubstitutionProvider = dalamudSubstitutionProvider; _dalamudSubstitutionProvider = dalamudSubstitutionProvider;
_compactor = compactor; _compactor = compactor;
@ -722,6 +725,13 @@ public class SettingsTab : ITab, IUiService
DrawPcpFolder(); DrawPcpFolder();
DrawPcpExtension(); DrawPcpExtension();
DrawDefaultModExportPath(); 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."); + "Keep this empty to use the root directory.");
} }
private string? _tempWatchDirectory;
/// <summary> Draw input for the Automatic Mod import path. </summary>
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.");
}
/// <summary> Draw input for the default name to input as author into newly generated mods. </summary> /// <summary> Draw input for the default name to input as author into newly generated mods. </summary>
private void DrawDefaultModAuthor() private void DrawDefaultModAuthor()
{ {

View file

@ -6,7 +6,7 @@
"Description": "Runtime mod loader and manager.", "Description": "Runtime mod loader and manager.",
"InternalName": "Penumbra", "InternalName": "Penumbra",
"AssemblyVersion": "1.5.1.6", "AssemblyVersion": "1.5.1.6",
"TestingAssemblyVersion": "1.5.1.6", "TestingAssemblyVersion": "1.5.1.7",
"RepoUrl": "https://github.com/xivdev/Penumbra", "RepoUrl": "https://github.com/xivdev/Penumbra",
"ApplicableVersion": "any", "ApplicableVersion": "any",
"DalamudApiLevel": 13, "DalamudApiLevel": 13,
@ -19,7 +19,7 @@
"LoadRequiredState": 2, "LoadRequiredState": 2,
"LoadSync": true, "LoadSync": true,
"DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.6/Penumbra.zip", "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", "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" "IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png"
} }