Merge branch '1.12.4' into notification-reworks

This commit is contained in:
2025-11-10 21:00:01 +01:00
22 changed files with 1884 additions and 467 deletions

View File

@@ -115,6 +115,8 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public bool StorageisNTFS { get; private set; } = false;
public bool StorageIsBtrfs { get ; private set; } = false;
public void StartLightlessWatcher(string? lightlessPath)
{
LightlessWatcher?.Dispose();
@@ -124,10 +126,19 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
Logger.LogWarning("Lightless file path is not set, cannot start the FSW for Lightless.");
return;
}
var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder, _dalamudUtil.IsWine);
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName);
StorageisNTFS = string.Equals("NTFS", di.DriveFormat, StringComparison.OrdinalIgnoreCase);
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
if (fsType == FileSystemHelper.FilesystemType.NTFS)
{
StorageisNTFS = true;
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
}
if (fsType == FileSystemHelper.FilesystemType.Btrfs)
{
StorageIsBtrfs = true;
Logger.LogInformation("Lightless Storage is on BTRFS drive: {isBtrfs}", StorageIsBtrfs);
}
Logger.LogDebug("Initializing Lightless FSW on {path}", lightlessPath);
LightlessWatcher = new()
@@ -392,51 +403,94 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public void RecalculateFileCacheSize(CancellationToken token)
{
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || !Directory.Exists(_configService.Current.CacheFolder))
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) ||
!Directory.Exists(_configService.Current.CacheFolder))
{
FileCacheSize = 0;
return;
}
FileCacheSize = -1;
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName);
bool isWine = _dalamudUtil?.IsWine ?? false;
try
{
FileCacheDriveFree = di.AvailableFreeSpace;
var drive = DriveInfo.GetDrives()
.FirstOrDefault(d => _configService.Current.CacheFolder
.StartsWith(d.Name, StringComparison.OrdinalIgnoreCase));
if (drive != null)
FileCacheDriveFree = drive.AvailableFreeSpace;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Could not determine drive size for Storage Folder {folder}", _configService.Current.CacheFolder);
Logger.LogWarning(ex, "Could not determine drive size for storage folder {folder}", _configService.Current.CacheFolder);
}
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder).Select(f => new FileInfo(f))
.OrderBy(f => f.LastAccessTime).ToList();
FileCacheSize = files
.Sum(f =>
{
token.ThrowIfCancellationRequested();
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder)
.Select(f => new FileInfo(f))
.OrderBy(f => f.LastAccessTime)
.ToList();
try
long totalSize = 0;
foreach (var f in files)
{
token.ThrowIfCancellationRequested();
try
{
long size = 0;
if (!isWine)
{
return _fileCompactor.GetFileSizeOnDisk(f, StorageisNTFS);
try
{
size = _fileCompactor.GetFileSizeOnDisk(f);
}
catch (Exception ex)
{
Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName);
size = f.Length;
}
}
catch
else
{
return 0;
size = f.Length;
}
});
totalSize += size;
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Error getting size for {file}", f.FullName);
}
}
FileCacheSize = totalSize;
var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
if (FileCacheSize < maxCacheInBytes) return;
if (FileCacheSize < maxCacheInBytes)
return;
var maxCacheBuffer = maxCacheInBytes * 0.05d;
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer)
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer && files.Count > 0)
{
var oldestFile = files[0];
FileCacheSize -= _fileCompactor.GetFileSizeOnDisk(oldestFile);
File.Delete(oldestFile.FullName);
files.Remove(oldestFile);
try
{
long fileSize = oldestFile.Length;
File.Delete(oldestFile.FullName);
FileCacheSize -= fileSize;
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullName);
}
files.RemoveAt(0);
}
}
@@ -644,44 +698,44 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
if (ct.IsCancellationRequested) return;
// scan new files
if (allScannedFiles.Any(c => !c.Value))
var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList();
foreach (var cachePath in newFiles)
{
Parallel.ForEach(allScannedFiles.Where(c => !c.Value).Select(c => c.Key),
new ParallelOptions()
{
MaxDegreeOfParallelism = threadCount,
CancellationToken = ct
}, (cachePath) =>
{
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
{
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}", _fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
return;
}
if (ct.IsCancellationRequested) break;
ProcessOne(cachePath);
Interlocked.Increment(ref _currentFileProgress);
}
if (ct.IsCancellationRequested) return;
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
void ProcessOne(string? cachePath)
{
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
{
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
_fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
return;
}
try
{
var entry = _fileDbManager.CreateFileEntry(cachePath);
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
}
if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
Interlocked.Increment(ref _currentFileProgress);
});
Logger.LogTrace("Scanner added {notScanned} new files to db", allScannedFiles.Count(c => !c.Value));
try
{
var entry = _fileDbManager.CreateFileEntry(cachePath);
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
}
catch (IOException ioex)
{
Logger.LogDebug(ioex, "File busy or locked: {file}", cachePath);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
}
}
Logger.LogDebug("Scan complete");

View File

@@ -203,42 +203,72 @@ public sealed class FileCacheManager : IHostedService
return output;
}
public Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int, int, FileCacheEntity)> progress, CancellationToken cancellationToken)
public async Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int completed, int total, FileCacheEntity current)> progress, CancellationToken cancellationToken)
{
_lightlessMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity)));
_logger.LogInformation("Validating local storage");
var cacheEntries = _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).Where(v => v.IsCacheEntry).ToList();
List<FileCacheEntity> brokenEntities = [];
int i = 0;
foreach (var fileCache in cacheEntries)
var cacheEntries = _fileCaches.Values
.SelectMany(v => v.Values)
.Where(v => v.IsCacheEntry)
.ToList();
int total = cacheEntries.Count;
int processed = 0;
var brokenEntities = new ConcurrentBag<FileCacheEntity>();
_logger.LogInformation("Checking {count} cache entries...", total);
await Parallel.ForEachAsync(cacheEntries, new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount,
CancellationToken = cancellationToken
},
async (fileCache, token) =>
{
if (cancellationToken.IsCancellationRequested) break;
_logger.LogInformation("Validating {file}", fileCache.ResolvedFilepath);
progress.Report((i, cacheEntries.Count, fileCache));
i++;
if (!File.Exists(fileCache.ResolvedFilepath))
{
brokenEntities.Add(fileCache);
continue;
}
try
{
var computedHash = Crypto.GetFileHash(fileCache.ResolvedFilepath);
int current = Interlocked.Increment(ref processed);
if (current % 10 == 0)
progress.Report((current, total, fileCache));
if (!File.Exists(fileCache.ResolvedFilepath))
{
brokenEntities.Add(fileCache);
return;
}
string computedHash;
try
{
computedHash = await Crypto.GetFileHashAsync(fileCache.ResolvedFilepath, token).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error hashing {file}", fileCache.ResolvedFilepath);
brokenEntities.Add(fileCache);
return;
}
if (!string.Equals(computedHash, fileCache.Hash, StringComparison.Ordinal))
{
_logger.LogInformation("Failed to validate {file}, got hash {computedHash}, expected hash {hash}", fileCache.ResolvedFilepath, computedHash, fileCache.Hash);
_logger.LogInformation(
"Hash mismatch: {file} (got {computedHash}, expected {expected})",
fileCache.ResolvedFilepath, computedHash, fileCache.Hash);
brokenEntities.Add(fileCache);
}
}
catch (Exception e)
catch (OperationCanceledException)
{
_logger.LogWarning(e, "Error during validation of {file}", fileCache.ResolvedFilepath);
_logger.LogError("Validation got cancelled for {file}", fileCache.ResolvedFilepath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error validating {file}", fileCache.ResolvedFilepath);
brokenEntities.Add(fileCache);
}
}
}).ConfigureAwait(false);
foreach (var brokenEntity in brokenEntities)
{
@@ -250,12 +280,14 @@ public sealed class FileCacheManager : IHostedService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not delete {file}", brokenEntity.ResolvedFilepath);
_logger.LogWarning(ex, "Failed to delete invalid cache file {file}", brokenEntity.ResolvedFilepath);
}
}
_lightlessMediator.Publish(new ResumeScanMessage(nameof(ValidateLocalIntegrity)));
return Task.FromResult(brokenEntities);
_logger.LogInformation("Validation complete. Found {count} invalid entries.", brokenEntities.Count);
return [.. brokenEntities];
}
public string GetCacheFilePath(string hash, string extension)

File diff suppressed because it is too large Load Diff

View File

@@ -13,5 +13,4 @@ public class ServerStorage
public bool UseOAuth2 { get; set; } = false;
public string? OAuthToken { get; set; } = null;
public HttpTransportType HttpTransportType { get; set; } = HttpTransportType.WebSockets;
public bool ForceWebSockets { get; set; } = false;
}

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<Authors></Authors>
<Company></Company>
<Version>1.12.3</Version>
<Version>1.12.4</Version>
<Description></Description>
<Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>

View File

@@ -28,7 +28,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
private readonly CancellationTokenSource _cleanupCts = new();
private Task? _cleanupTask;
private int _checkEveryFrames = 20;
private readonly int _checkEveryFrames = 20;
private int _frameCounter = 0;
private int _lookupsThisFrame = 0;
private const int MaxLookupsPerFrame = 30;
@@ -221,6 +221,16 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)));
}
public List<KeyValuePair<string, BroadcastEntry>> GetActiveBroadcasts(string? excludeHashedCid = null)
{
var now = DateTime.UtcNow;
var comparer = StringComparer.Ordinal;
return [.. _broadcastCache.Where(entry =>
entry.Value.IsBroadcasting &&
entry.Value.ExpiryTime > now &&
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)))];
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);

View File

@@ -144,11 +144,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
IsLightFinderAvailable = false;
ApplyBroadcastDisabled(forcePublish: true);
_logger.LogDebug("Cleared Lightfinder state due to disconnect.");
_mediator.Publish(new NotificationMessage(
"Disconnected from Server",
"Your Lightfinder broadcast has been disabled due to disconnection.",
NotificationType.Warning));
}
public Task StartAsync(CancellationToken cancellationToken)

View File

@@ -13,7 +13,8 @@ namespace LightlessSync.Services;
public sealed class CommandManagerService : IDisposable
{
private const string _commandName = "/light";
private const string _longName = "/lightless";
private const string _shortName = "/light";
private readonly ApiController _apiController;
private readonly ICommandManager _commandManager;
@@ -34,7 +35,11 @@ public sealed class CommandManagerService : IDisposable
_apiController = apiController;
_mediator = mediator;
_lightlessConfigService = lightlessConfigService;
_commandManager.AddHandler(_commandName, new CommandInfo(OnCommand)
_commandManager.AddHandler(_longName, new CommandInfo(OnCommand)
{
HelpMessage = $"\u2191;"
});
_commandManager.AddHandler(_shortName, new CommandInfo(OnCommand)
{
HelpMessage = "Opens the Lightless Sync UI" + Environment.NewLine + Environment.NewLine +
"Additionally possible commands:" + Environment.NewLine +
@@ -49,7 +54,8 @@ public sealed class CommandManagerService : IDisposable
public void Dispose()
{
_commandManager.RemoveHandler(_commandName);
_commandManager.RemoveHandler(_longName);
_commandManager.RemoveHandler(_shortName);
}
private void OnCommand(string command, string args)

View File

@@ -0,0 +1,245 @@
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Threading.Channels;
namespace LightlessSync.Services.Compression
{
/// <summary>
/// This batch service is made for the File Frag command, because of each file needing to use this command.
/// It's better to combine into one big command in batches then doing each command on each compressed call.
/// </summary>
public sealed partial class BatchFilefragService : IDisposable
{
private readonly Channel<(string path, TaskCompletionSource<bool> tcs)> _ch;
private readonly Task _worker;
private readonly bool _useShell;
private readonly ILogger _log;
private readonly int _batchSize;
private readonly TimeSpan _flushDelay;
private readonly CancellationTokenSource _cts = new();
public BatchFilefragService(bool useShell, ILogger log, int batchSize = 128, int flushMs = 25)
{
_useShell = useShell;
_log = log;
_batchSize = Math.Max(8, batchSize);
_flushDelay = TimeSpan.FromMilliseconds(Math.Max(5, flushMs));
_ch = Channel.CreateUnbounded<(string, TaskCompletionSource<bool>)>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
_worker = Task.Run(ProcessAsync, _cts.Token);
}
/// <summary>
/// Checks if the file is compressed using Btrfs using tasks
/// </summary>
/// <param name="linuxPath">Linux/Wine path given for the file.</param>
/// <param name="ct">Cancellation Token</param>
/// <returns>If it was compressed or not</returns>
public Task<bool> IsCompressedAsync(string linuxPath, CancellationToken ct = default)
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
if (!_ch.Writer.TryWrite((linuxPath, tcs)))
{
tcs.TrySetResult(false);
return tcs.Task;
}
if (ct.CanBeCanceled)
{
var reg = ct.Register(() => tcs.TrySetCanceled(ct));
_ = tcs.Task.ContinueWith(_ => reg.Dispose(), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
}
return tcs.Task;
}
/// <summary>
/// Process the pending compression tasks asynchronously
/// </summary>
/// <returns>Task</returns>
private async Task ProcessAsync()
{
var reader = _ch.Reader;
var pending = new List<(string path, TaskCompletionSource<bool> tcs)>(_batchSize);
try
{
while (await reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false))
{
if (!reader.TryRead(out var first)) continue;
pending.Add(first);
var flushAt = DateTime.UtcNow + _flushDelay;
while (pending.Count < _batchSize && DateTime.UtcNow < flushAt)
{
if (reader.TryRead(out var item))
{
pending.Add(item);
continue;
}
if ((flushAt - DateTime.UtcNow) <= TimeSpan.Zero) break;
try
{
await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false);
}
catch
{
break;
}
}
try
{
var map = await RunBatchAsync(pending.Select(p => p.path)).ConfigureAwait(false);
foreach (var (path, tcs) in pending)
{
tcs.TrySetResult(map.TryGetValue(path, out var c) && c);
}
}
catch (Exception ex)
{
_log.LogDebug(ex, "filefrag batch failed. falling back to false");
foreach (var (_, tcs) in pending)
{
tcs.TrySetResult(false);
}
}
finally
{
pending.Clear();
}
}
}
catch (OperationCanceledException)
{
//Shutting down worker, exception called
}
}
/// <summary>
/// Running the batch of each file in the queue in one file frag command.
/// </summary>
/// <param name="paths">Paths that are needed for the command building for the batch return</param>
/// <returns>Path of the file and if it went correctly</returns>
/// <exception cref="InvalidOperationException">Failing to start filefrag on the system if this exception is found</exception>
private async Task<Dictionary<string, bool>> RunBatchAsync(IEnumerable<string> paths)
{
var list = paths.Distinct(StringComparer.Ordinal).ToList();
var result = list.ToDictionary(p => p, _ => false, StringComparer.Ordinal);
ProcessStartInfo psi;
if (_useShell)
{
var inner = "filefrag -v -- " + string.Join(' ', list.Select(QuoteSingle));
psi = new ProcessStartInfo
{
FileName = "/bin/bash",
Arguments = "-c " + QuoteDouble(inner),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = "/"
};
}
else
{
psi = new ProcessStartInfo
{
FileName = "filefrag",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
psi.ArgumentList.Add("-v");
psi.ArgumentList.Add("--");
foreach (var p in list) psi.ArgumentList.Add(p);
}
using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start filefrag");
var stdoutTask = proc.StandardOutput.ReadToEndAsync(_cts.Token);
var stderrTask = proc.StandardError.ReadToEndAsync(_cts.Token);
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
try
{
await proc.WaitForExitAsync(_cts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Error in the batch frag service. proc = {proc}", proc);
}
var stdout = await stdoutTask.ConfigureAwait(false);
var stderr = await stderrTask.ConfigureAwait(false);
if (proc.ExitCode != 0 && !string.IsNullOrWhiteSpace(stderr))
_log.LogTrace("filefrag exited {code}: {err}", proc.ExitCode, stderr.Trim());
ParseFilefrag(stdout, result);
return result;
}
/// <summary>
/// Parsing the string given from the File Frag command into mapping
/// </summary>
/// <param name="output">Output of the process from the File Frag</param>
/// <param name="map">Mapping of the processed files</param>
private static void ParseFilefrag(string output, Dictionary<string, bool> map)
{
var reHeaderColon = ColonRegex();
var reHeaderSize = SizeRegex();
string? current = null;
using var sr = new StringReader(output);
for (string? line = sr.ReadLine(); line != null; line = sr.ReadLine())
{
var m1 = reHeaderColon.Match(line);
if (m1.Success) { current = m1.Groups[1].Value; continue; }
var m2 = reHeaderSize.Match(line);
if (m2.Success) { current = m2.Groups[1].Value; continue; }
if (current is not null && line.Contains("flags:", StringComparison.OrdinalIgnoreCase) &&
line.Contains("compressed", StringComparison.OrdinalIgnoreCase) && map.ContainsKey(current))
{
map[current] = true;
}
}
}
private static string QuoteSingle(string s) => "'" + s.Replace("'", "'\\''", StringComparison.Ordinal) + "'";
private static string QuoteDouble(string s) => "\"" + s.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal).Replace("$", "\\$", StringComparison.Ordinal).Replace("`", "\\`", StringComparison.Ordinal) + "\"";
/// <summary>
/// Regex of the File Size return on the Linux/Wine systems, giving back the amount
/// </summary>
/// <returns>Regex of the File Size</returns>
[GeneratedRegex(@"^File size of (/.+?) is ", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant,matchTimeoutMilliseconds: 500)]
private static partial Regex SizeRegex();
/// <summary>
/// Regex on colons return on the Linux/Wine systems
/// </summary>
/// <returns>Regex of the colons in the given path</returns>
[GeneratedRegex(@"^(/.+?):\s", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 500)]
private static partial Regex ColonRegex();
public void Dispose()
{
_ch.Writer.TryComplete();
_cts.Cancel();
try
{
_worker.Wait(TimeSpan.FromSeconds(2), _cts.Token);
}
catch
{
// Ignore the catch in dispose
}
_cts.Dispose();
}
}
}

View File

@@ -98,7 +98,7 @@ internal class ContextMenuService : IHostedService
if (targetData == null || targetData.Address == nint.Zero)
return;
//Check if user is paired or is own.
//Check if user is directly paired or is own.
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId)
return;
@@ -116,7 +116,7 @@ internal class ContextMenuService : IHostedService
args.AddMenuItem(new MenuItem
{
Name = "Send Pair Request",
Name = "Send Direct Pair Request",
PrefixChar = 'L',
UseDefaultPrefix = false,
PrefixColor = 708,
@@ -159,7 +159,7 @@ internal class ContextMenuService : IHostedService
}
}
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
private HashSet<ulong> VisibleUserIds => [.. _pairManager.DirectPairs
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)];

View File

@@ -1,5 +1,6 @@
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.Text;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
@@ -15,8 +16,9 @@ using LightlessSync.UtilsEnum.Enum;
// Created using https://github.com/PunishedPineapple/Distance as a reference, thank you!
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
namespace LightlessSync.Services;
@@ -32,10 +34,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private readonly LightlessMediator _mediator;
public LightlessMediator Mediator => _mediator;
private bool mEnabled = false;
private bool _mEnabled = false;
private bool _needsLabelRefresh = false;
private AddonNamePlate* mpNameplateAddon = null;
private readonly AtkTextNode*[] mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects];
private AddonNamePlate* _mpNameplateAddon = null;
private readonly AtkTextNode*[] _mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects];
@@ -44,10 +46,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
internal const uint mNameplateNodeIDBase = 0x7D99D500;
private const string DefaultLabelText = "LightFinder";
private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn;
private const int ContainerOffsetX = 50;
private const int _containerOffsetX = 50;
private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon);
private volatile HashSet<string> _activeBroadcastingCids = [];
private ImmutableHashSet<string> _activeBroadcastingCids = [];
public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager)
{
@@ -74,17 +76,17 @@ public unsafe class NameplateHandler : IMediatorSubscriber
DisableNameplate();
DestroyNameplateNodes();
_mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this);
mpNameplateAddon = null;
_mpNameplateAddon = null;
}
internal void EnableNameplate()
{
if (!mEnabled)
if (!_mEnabled)
{
try
{
_addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour);
mEnabled = true;
_mEnabled = true;
}
catch (Exception e)
{
@@ -96,7 +98,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
internal void DisableNameplate()
{
if (mEnabled)
if (_mEnabled)
{
try
{
@@ -107,24 +109,30 @@ public unsafe class NameplateHandler : IMediatorSubscriber
_logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}");
}
mEnabled = false;
_mEnabled = false;
HideAllNameplateNodes();
}
}
private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
{
if (args.Addon.Address == nint.Zero)
{
_logger.LogWarning("Nameplate draw detour received a null addon address, skipping update.");
return;
}
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
if (mpNameplateAddon != pNameplateAddon)
if (_mpNameplateAddon != pNameplateAddon)
{
for (int i = 0; i < mTextNodes.Length; ++i) mTextNodes[i] = null;
for (int i = 0; i < _mTextNodes.Length; ++i) _mTextNodes[i] = null;
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
mpNameplateAddon = pNameplateAddon;
if (mpNameplateAddon != null) CreateNameplateNodes();
_mpNameplateAddon = pNameplateAddon;
if (_mpNameplateAddon != null) CreateNameplateNodes();
}
UpdateNameplateNodes();
@@ -138,7 +146,16 @@ public unsafe class NameplateHandler : IMediatorSubscriber
if (nameplateObject == null)
continue;
var rootNode = nameplateObject.Value.RootComponentNode;
if (rootNode == null || rootNode->Component == null)
continue;
var pNameplateResNode = nameplateObject.Value.NameContainer;
if (pNameplateResNode == null)
continue;
if (pNameplateResNode->ChildNode == null)
continue;
var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare);
if (pNewNode != null)
@@ -148,24 +165,43 @@ public unsafe class NameplateHandler : IMediatorSubscriber
pNewNode->AtkResNode.NextSiblingNode = pLastChild;
pNewNode->AtkResNode.ParentNode = pNameplateResNode;
pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode;
nameplateObject.Value.RootComponentNode->Component->UldManager.UpdateDrawNodeList();
rootNode->Component->UldManager.UpdateDrawNodeList();
pNewNode->AtkResNode.SetUseDepthBasedPriority(true);
mTextNodes[i] = pNewNode;
_mTextNodes[i] = pNewNode;
}
}
}
private void DestroyNameplateNodes()
{
var pCurrentNameplateAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate", 1).Address;
if (mpNameplateAddon == null || mpNameplateAddon != pCurrentNameplateAddon)
var currentHandle = _gameGui.GetAddonByName("NamePlate", 1);
if (currentHandle.Address == nint.Zero)
{
_logger.LogWarning("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
return;
}
var pCurrentNameplateAddon = (AddonNamePlate*)currentHandle.Address;
if (_mpNameplateAddon == null)
return;
if (_mpNameplateAddon != pCurrentNameplateAddon)
{
_logger.LogWarning("Skipping nameplate node destroy due to addon address mismatch (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)pCurrentNameplateAddon);
return;
}
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
{
var pTextNode = mTextNodes[i];
var pTextNode = _mTextNodes[i];
var pNameplateNode = GetNameplateComponentNode(i);
if (pTextNode != null && pNameplateNode != null)
if (pTextNode != null && (pNameplateNode == null || pNameplateNode->Component == null))
{
_logger.LogDebug("Skipping destroy for nameplate {Index} because its component node is unavailable.", i);
continue;
}
if (pTextNode != null && pNameplateNode != null && pNameplateNode->Component != null)
{
try
{
@@ -175,7 +211,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode;
pNameplateNode->Component->UldManager.UpdateDrawNodeList();
pTextNode->AtkResNode.Destroy(true);
mTextNodes[i] = null;
_mTextNodes[i] = null;
}
catch (Exception e)
{
@@ -192,7 +228,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private void HideAllNameplateNodes()
{
for (int i = 0; i < mTextNodes.Length; ++i)
for (int i = 0; i < _mTextNodes.Length; ++i)
{
HideNameplateTextNode(i);
}
@@ -200,22 +236,62 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private void UpdateNameplateNodes()
{
var framework = Framework.Instance();
var ui3DModule = framework->GetUIModule()->GetUI3DModule();
var currentHandle = _gameGui.GetAddonByName("NamePlate");
if (currentHandle.Address == nint.Zero)
{
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
return;
}
var currentAddon = (AddonNamePlate*)currentHandle.Address;
if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon)
{
if (_mpNameplateAddon != null)
_logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon);
return;
}
var framework = Framework.Instance();
if (framework == null)
{
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
return;
}
var uiModule = framework->GetUIModule();
if (uiModule == null)
{
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
return;
}
var ui3DModule = uiModule->GetUI3DModule();
if (ui3DModule == null)
{
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
return;
}
var vec = ui3DModule->NamePlateObjectInfoPointers;
if (vec.IsEmpty)
return;
for (int i = 0; i < ui3DModule->NamePlateObjectInfoCount; ++i)
var visibleUserIdsSnapshot = VisibleUserIds;
var safeCount = System.Math.Min(
ui3DModule->NamePlateObjectInfoCount,
vec.Length
);
for (int i = 0; i < safeCount; ++i)
{
if (ui3DModule->NamePlateObjectInfoPointers.IsEmpty) continue;
var config = _configService.Current;
var objectInfoPtr = ui3DModule->NamePlateObjectInfoPointers[i];
if (objectInfoPtr == null) continue;
var objectInfoPtr = vec[i];
if (objectInfoPtr == null)
continue;
var objectInfo = objectInfoPtr.Value;
if (objectInfo == null || objectInfo->GameObject == null)
continue;
@@ -223,62 +299,68 @@ public unsafe class NameplateHandler : IMediatorSubscriber
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
continue;
var pNode = mTextNodes[nameplateIndex];
var pNode = _mTextNodes[nameplateIndex];
if (pNode == null)
continue;
if (mpNameplateAddon == null)
var gameObject = objectInfo->GameObject;
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
{
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject);
}
// CID gating
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
if (cid == null || !_activeBroadcastingCids.Contains(cid))
{
pNode->AtkResNode.ToggleVisibility(false);
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
if (!_configService.Current.LightfinderLabelShowOwn && (objectInfo->GameObject->GetGameObjectId() == _clientState.LocalPlayer.GameObjectId))
var local = _clientState.LocalPlayer;
if (!config.LightfinderLabelShowOwn && local != null &&
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
{
pNode->AtkResNode.ToggleVisibility(false);
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
if (!_configService.Current.LightfinderLabelShowPaired && VisibleUserIds.Any(u => u == objectInfo->GameObject->GetGameObjectId()))
var hidePaired = !config.LightfinderLabelShowPaired;
var goId = (ulong)gameObject->GetGameObjectId();
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
{
pNode->AtkResNode.ToggleVisibility(false);
continue;
}
var nameplateObject = mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
nameplateObject.RootComponentNode->Component->UldManager.UpdateDrawNodeList();
var pNameplateIconNode = nameplateObject.MarkerIcon;
var pNameplateResNode = nameplateObject.NameContainer;
var pNameplateTextNode = nameplateObject.NameText;
bool IsVisible = pNameplateIconNode->AtkResNode.IsVisible() || (pNameplateResNode->IsVisible() && pNameplateTextNode->AtkResNode.IsVisible()) || _configService.Current.LightfinderLabelShowHidden;
pNode->AtkResNode.ToggleVisibility(IsVisible);
if (nameplateObject.RootComponentNode == null ||
nameplateObject.NameContainer == null ||
nameplateObject.NameText == null)
{
pNode->AtkResNode.ToggleVisibility(false);
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
var root = nameplateObject.RootComponentNode;
var nameContainer = nameplateObject.NameContainer;
var nameText = nameplateObject.NameText;
var marker = nameplateObject.MarkerIcon;
if (nameContainer == null || nameText == null)
if (root == null || root->Component == null || nameContainer == null || nameText == null)
{
pNode->AtkResNode.ToggleVisibility(false);
_logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
root->Component->UldManager.UpdateDrawNodeList();
bool isVisible =
((marker != null) && marker->AtkResNode.IsVisible()) ||
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
config.LightfinderLabelShowHidden;
pNode->AtkResNode.ToggleVisibility(isVisible);
if (!isVisible)
continue;
var labelColor = UIColors.Get("Lightfinder");
var edgeColor = UIColors.Get("LightfinderEdge");
var config = _configService.Current;
var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
@@ -437,7 +519,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
positionY += config.LightfinderLabelOffsetY;
alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8);
pNode->AtkResNode.SetUseDepthBasedPriority(true);
pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
pNode->AtkResNode.Color.A = 255;
@@ -545,7 +627,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
}
private void HideNameplateTextNode(int i)
{
var pNode = mTextNodes[i];
var pNode = _mTextNodes[i];
if (pNode != null)
{
pNode->AtkResNode.ToggleVisibility(false);
@@ -555,10 +637,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private AddonNamePlate.NamePlateObject? GetNameplateObject(int i)
{
if (i < AddonNamePlate.NumNamePlateObjects &&
mpNameplateAddon != null &&
mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null)
_mpNameplateAddon != null &&
_mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null)
{
return mpNameplateAddon->NamePlateObjectArray[i];
return _mpNameplateAddon->NamePlateObjectArray[i];
}
else
{
@@ -571,10 +653,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber
var nameplateObject = GetNameplateObject(i);
return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null;
}
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)];
public void FlagRefresh()
{
_needsLabelRefresh = true;
@@ -591,18 +675,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber
public void UpdateBroadcastingCids(IEnumerable<string> cids)
{
var newSet = cids.ToHashSet();
var changed = !_activeBroadcastingCids.SetEquals(newSet);
if (!changed)
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
return;
_activeBroadcastingCids.Clear();
foreach (var cid in newSet)
_activeBroadcastingCids.Add(cid);
_logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(",", _activeBroadcastingCids));
_activeBroadcastingCids = newSet;
_logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids));
FlagRefresh();
}

View File

@@ -1,7 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
@@ -14,10 +10,10 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
private readonly DalamudUtilService _dalamudUtil;
private readonly PairManager _pairManager;
private readonly Lazy<WebAPI.ApiController> _apiController;
private readonly object _syncRoot = new();
private readonly Lock _syncRoot = new();
private readonly List<PairRequestEntry> _requests = [];
private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(5);
private static readonly TimeSpan _expiration = TimeSpan.FromMinutes(5);
public PairRequestService(
ILogger<PairRequestService> logger,
@@ -189,7 +185,7 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
}
var now = DateTime.UtcNow;
return _requests.RemoveAll(r => now - r.ReceivedAt > Expiration) > 0;
return _requests.RemoveAll(r => now - r.ReceivedAt > _expiration) > 0;
}
public void AcceptPairRequest(string hashedCid, string displayName)

View File

@@ -170,7 +170,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
if (!_charaDataManager.BrioAvailable)
{
ImGuiHelpers.ScaledDummy(3);
UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters you require to have Brio installed.", ImGuiColors.DalamudRed);
UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters, you are required to have Brio installed.", ImGuiColors.DalamudRed);
UiSharedService.DistanceSeparator();
}

View File

@@ -1,4 +1,3 @@
using System;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility;
@@ -16,12 +15,14 @@ using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Components;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.Files;
using LightlessSync.WebAPI.Files.Models;
using LightlessSync.WebAPI.SignalR.Utils;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Globalization;
@@ -708,23 +709,23 @@ public class CompactUi : WindowMediatorSubscriberBase
}
//Filter of not foldered syncshells
var groupFolders = new List<IDrawFolder>();
var groupFolders = new List<GroupFolder>();
foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
{
GetGroups(allPairs, filteredPairs, group, out ImmutableList<Pair> allGroupPairs, out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
if (FilterNotTaggedSyncshells(group))
{
groupFolders.Add(_drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs));
groupFolders.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs)));
}
}
//Filter of grouped up syncshells (All Syncshells Folder)
if (_configService.Current.GroupUpSyncshells)
drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _uiSharedService,
drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _apiController, _uiSharedService,
_selectSyncshellForTagUi, _renameSyncshellTagUi, ""));
else
drawFolders.AddRange(groupFolders);
drawFolders.AddRange(groupFolders.Select(v => v.GroupDrawFolder));
//Filter of grouped/foldered pairs
foreach (var tag in _tagHandler.GetAllPairTagsSorted())
@@ -738,7 +739,7 @@ public class CompactUi : WindowMediatorSubscriberBase
//Filter of grouped/foldered syncshells
foreach (var syncshellTag in _tagHandler.GetAllSyncshellTagsSorted())
{
var syncshellFolderTags = new List<IDrawFolder>();
var syncshellFolderTags = new List<GroupFolder>();
foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
{
if (_tagHandler.HasSyncshellTag(group.GID, syncshellTag))
@@ -747,11 +748,11 @@ public class CompactUi : WindowMediatorSubscriberBase
out ImmutableList<Pair> allGroupPairs,
out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
syncshellFolderTags.Add(_drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs));
syncshellFolderTags.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs)));
}
}
drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag));
drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _apiController, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag));
}
//Filter of not grouped/foldered and offline pairs

View File

@@ -1,7 +1,11 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using LightlessSync.WebAPI;
using System.Collections.Immutable;
using System.Numerics;
@@ -10,19 +14,20 @@ namespace LightlessSync.UI.Components;
public class DrawGroupedGroupFolder : IDrawFolder
{
private readonly string _tag;
private readonly IEnumerable<IDrawFolder> _groups;
private readonly IEnumerable<GroupFolder> _groups;
private readonly TagHandler _tagHandler;
private readonly UiSharedService _uiSharedService;
private readonly ApiController _apiController;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private bool _wasHovered = false;
private float _menuWidth;
public IImmutableList<DrawUserPair> DrawPairs => throw new NotSupportedException();
public int OnlinePairs => _groups.SelectMany(g => g.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count();
public int TotalPairs => _groups.Sum(g => g.TotalPairs);
public IImmutableList<DrawUserPair> DrawPairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList();
public int OnlinePairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count();
public int TotalPairs => _groups.Sum(g => g.GroupDrawFolder.TotalPairs);
public DrawGroupedGroupFolder(IEnumerable<IDrawFolder> groups, TagHandler tagHandler, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag)
public DrawGroupedGroupFolder(IEnumerable<GroupFolder> groups, TagHandler tagHandler, ApiController apiController, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag)
{
_groups = groups;
_tagHandler = tagHandler;
@@ -30,6 +35,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
_selectSyncshellForTagUi = selectSyncshellForTagUi;
_renameSyncshellTagUi = renameSyncshellTagUi;
_tag = tag;
_apiController = apiController;
}
public void Draw()
@@ -42,7 +48,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
using var id = ImRaii.PushId(_id);
var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered);
using (ImRaii.Child("folder__" + _id, new System.Numerics.Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight())))
using (ImRaii.Child("folder__" + _id, new Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight())))
{
ImGui.Dummy(new Vector2(0f, ImGui.GetFrameHeight()));
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0f, 0f)))
@@ -83,11 +89,16 @@ public class DrawGroupedGroupFolder : IDrawFolder
{
ImGui.TextUnformatted(_tag);
ImGui.SameLine();
DrawPauseButton();
ImGui.SameLine();
DrawMenu();
} else
{
ImGui.TextUnformatted("All Syncshells");
ImGui.SameLine();
DrawPauseButton();
}
}
color.Dispose();
@@ -100,11 +111,49 @@ public class DrawGroupedGroupFolder : IDrawFolder
using var indent = ImRaii.PushIndent(20f);
foreach (var entry in _groups)
{
entry.Draw();
entry.GroupDrawFolder.Draw();
}
}
}
protected void DrawPauseButton()
{
if (DrawPairs.Count > 0)
{
var isPaused = _groups.Select(g => g.GroupFullInfo).All(g => g.GroupUserPermissions.IsPaused());
FontAwesomeIcon pauseIcon = isPaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause;
var pauseButtonSize = _uiSharedService.GetIconButtonSize(pauseIcon);
var windowEndX = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth();
if (_tag != "")
{
var spacingX = ImGui.GetStyle().ItemSpacing.X;
var menuButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV);
ImGui.SameLine(windowEndX - pauseButtonSize.X - menuButtonSize.X - spacingX);
}
else
{
ImGui.SameLine(windowEndX - pauseButtonSize.X);
}
if (_uiSharedService.IconButton(pauseIcon))
{
ChangePauseStateGroups();
}
}
}
protected void ChangePauseStateGroups()
{
foreach(var group in _groups)
{
var perm = group.GroupFullInfo.GroupUserPermissions;
perm.SetPaused(!perm.IsPaused());
_ = _apiController.GroupChangeIndividualPermissionState(new GroupPairUserPermissionDto(group.GroupFullInfo.Group, new(_apiController.UID), perm));
}
}
protected void DrawMenu()
{
var barButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV);

View File

@@ -1,5 +1,4 @@

using System.Collections.Immutable;
using System.Collections.Immutable;
namespace LightlessSync.UI.Components;

View File

@@ -2,19 +2,22 @@ using Dalamud.Game.Gui.Dtr;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.SignalR.Utils;
using LightlessSync.Utils;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using System.Runtime.InteropServices;
using System.Text;
using static LightlessSync.Services.PairRequestService;
namespace LightlessSync.UI;
@@ -106,7 +109,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
}
catch (OperationCanceledException)
{
_logger.LogInformation("Lightfinder operation was canceled.");
}
finally
{
@@ -363,29 +366,46 @@ public sealed class DtrEntry : IDisposable, IHostedService
}
}
private int GetNearbyBroadcastCount()
{
var localHashedCid = GetLocalHashedCid();
return _broadcastScannerService.CountActiveBroadcasts(
string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid);
}
private int GetPendingPairRequestCount()
private List<string> GetNearbyBroadcasts()
{
try
{
return _pairRequestService.GetActiveRequests().Count;
var localHashedCid = GetLocalHashedCid();
return [.. _broadcastScannerService
.GetActiveBroadcasts(string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid)
.Select(b => _dalamudUtilService.FindPlayerByNameHash(b.Key).Name)];
}
catch (Exception ex)
{
var now = DateTime.UtcNow;
if (now >= _pairRequestNextErrorLog)
{
_logger.LogDebug(ex, "Failed to retrieve nearby broadcasts for Lightfinder DTR entry.");
_pairRequestNextErrorLog = now + _localHashedCidErrorCooldown;
}
return [];
}
}
private IReadOnlyList<PairRequestDisplay> GetPendingPairRequest()
{
try
{
return _pairRequestService.GetActiveRequests();
}
catch (Exception ex)
{
var now = DateTime.UtcNow;
if (now >= _pairRequestNextErrorLog)
{
_logger.LogDebug(ex, "Failed to retrieve pair request count for Lightfinder DTR entry.");
_pairRequestNextErrorLog = now + _localHashedCidErrorCooldown;
}
return 0;
return [];
}
}
@@ -400,23 +420,15 @@ public sealed class DtrEntry : IDisposable, IHostedService
if (_broadcastService.IsBroadcasting)
{
var tooltipBuilder = new StringBuilder("Lightfinder - Enabled");
switch (config.LightfinderDtrDisplayMode)
{
case LightfinderDtrDisplayMode.PendingPairRequests:
{
var requestCount = GetPendingPairRequestCount();
tooltipBuilder.AppendLine();
tooltipBuilder.Append("Pending pair requests: ").Append(requestCount);
return ($"{icon} Requests {requestCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString());
return FormatTooltip("Pending pair requests", GetPendingPairRequest().Select(x => x.DisplayName), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled));
}
default:
{
var broadcastCount = GetNearbyBroadcastCount();
tooltipBuilder.AppendLine();
tooltipBuilder.Append("Nearby Lightfinder users: ").Append(broadcastCount);
return ($"{icon} {broadcastCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString());
return FormatTooltip("Nearby Lightfinder users", GetNearbyBroadcasts(), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled));
}
}
}
@@ -433,6 +445,18 @@ public sealed class DtrEntry : IDisposable, IHostedService
return ($"{icon} OFF", colors, tooltip.ToString());
}
private (string, Colors, string) FormatTooltip(string title, IEnumerable<string> names, string icon, Colors color)
{
var list = names.Where(x => !string.IsNullOrEmpty(x)).ToList();
var tooltip = new StringBuilder()
.Append($"Lightfinder - Enabled{Environment.NewLine}")
.Append($"{title}: {list.Count}{Environment.NewLine}")
.AppendJoin(Environment.NewLine, list)
.ToString();
return ($"{icon} {list.Count}", color, tooltip);
}
private static string BuildLightfinderTooltip(string baseTooltip)
{
var builder = new StringBuilder();

View File

@@ -0,0 +1,6 @@
using LightlessSync.API.Dto.Group;
using LightlessSync.UI.Components;
namespace LightlessSync.UI.Models;
public record GroupFolder(GroupFullInfoDto GroupFullInfo, IDrawFolder GroupDrawFolder);

View File

@@ -1227,16 +1227,16 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TextUnformatted($"Currently utilized local storage: Calculating...");
ImGui.TextUnformatted(
$"Remaining space free on drive: {UiSharedService.ByteToString(_cacheMonitor.FileCacheDriveFree)}");
bool useFileCompactor = _configService.Current.UseCompactor;
bool isLinux = _dalamudUtilService.IsWine;
if (!useFileCompactor && !isLinux)
if (!useFileCompactor)
{
UiSharedService.ColorTextWrapped(
"Hint: To free up space when using Lightless consider enabling the File Compactor",
UIColors.Get("LightlessYellow"));
}
if (isLinux || !_cacheMonitor.StorageisNTFS) ImGui.BeginDisabled();
if (!_cacheMonitor.StorageIsBtrfs && !_cacheMonitor.StorageisNTFS) ImGui.BeginDisabled();
if (ImGui.Checkbox("Use file compactor", ref useFileCompactor))
{
_configService.Current.UseCompactor = useFileCompactor;
@@ -1281,10 +1281,20 @@ public class SettingsUi : WindowMediatorSubscriberBase
UIColors.Get("LightlessYellow"));
}
if (isLinux || !_cacheMonitor.StorageisNTFS)
if (!_cacheMonitor.StorageIsBtrfs && !_cacheMonitor.StorageisNTFS)
{
ImGui.EndDisabled();
ImGui.TextUnformatted("The file compactor is only available on Windows and NTFS drives.");
ImGui.TextUnformatted("The file compactor is only available on BTRFS and NTFS drives.");
}
if (_cacheMonitor.StorageisNTFS)
{
ImGui.TextUnformatted("The file compactor is running on NTFS Drive.");
}
if (_cacheMonitor.StorageIsBtrfs)
{
ImGui.TextUnformatted("The file compactor is running on Btrfs Drive.");
}
ImGuiHelpers.ScaledDummy(new Vector2(10, 10));
@@ -3113,22 +3123,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
UiSharedService.TooltipSeparator
+ "Note: if the server does not support a specific Transport Type it will fall through to the next automatically: WebSockets > ServerSentEvents > LongPolling");
if (_dalamudUtilService.IsWine)
{
bool forceWebSockets = selectedServer.ForceWebSockets;
if (ImGui.Checkbox("[wine only] Force WebSockets", ref forceWebSockets))
{
selectedServer.ForceWebSockets = forceWebSockets;
_serverConfigurationManager.Save();
}
_uiShared.DrawHelpText(
"On wine, Lightless will automatically fall back to ServerSentEvents/LongPolling, even if WebSockets is selected. "
+ "WebSockets are known to crash XIV entirely on wine 8.5 shipped with Dalamud. "
+ "Only enable this if you are not running wine 8.5." + Environment.NewLine
+ "Note: If the issue gets resolved at some point this option will be removed.");
}
ImGuiHelpers.ScaledDummy(5);
if (ImGui.Checkbox("Use Discord OAuth2 Authentication", ref useOauth))

View File

@@ -1,16 +1,15 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Security.Cryptography;
using System.Text;
namespace LightlessSync.Utils;
public static class Crypto
{
//This buffersize seems to be the best sweetpoint for Linux and Windows
private const int _bufferSize = 65536;
#pragma warning disable SYSLIB0021 // Type or member is obsolete
private static readonly Dictionary<(string, ushort), string> _hashListPlayersSHA256 = new();
private static readonly Dictionary<(string, ushort), string> _hashListPlayersSHA256 = [];
private static readonly Dictionary<string, string> _hashListSHA256 = new(StringComparer.Ordinal);
private static readonly SHA256CryptoServiceProvider _sha256CryptoProvider = new();
@@ -21,6 +20,26 @@ public static class Crypto
return BitConverter.ToString(sha1.ComputeHash(stream)).Replace("-", "", StringComparison.Ordinal);
}
public static async Task<string> GetFileHashAsync(string filePath, CancellationToken cancellationToken = default)
{
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: _bufferSize, options: FileOptions.Asynchronous);
await using (stream.ConfigureAwait(false))
{
using var sha1 = SHA1.Create();
var buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
sha1.TransformBlock(buffer, 0, bytesRead, outputBuffer: null, 0);
}
sha1.TransformFinalBlock([], 0, 0);
return Convert.ToHexString(sha1.Hash!);
}
}
public static string GetHash256(this (string, ushort) playerToHash)
{
if (_hashListPlayersSHA256.TryGetValue(playerToHash, out var hash))

View File

@@ -0,0 +1,282 @@
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace LightlessSync.Utils
{
public static class FileSystemHelper
{
public enum FilesystemType
{
Unknown = 0,
NTFS, // Compressable on file level
Btrfs, // Compressable on file level
Ext4, // Uncompressable
Xfs, // Uncompressable
Apfs, // Compressable on OS
HfsPlus, // Compressable on OS
Fat, // Uncompressable
Exfat, // Uncompressable
Zfs // Compressable, not on file level
}
private const string _mountPath = "/proc/mounts";
private const int _defaultBlockSize = 4096;
private static readonly Dictionary<string, int> _blockSizeCache = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, FilesystemType> _filesystemTypeCache = new(StringComparer.OrdinalIgnoreCase);
public static FilesystemType GetFilesystemType(string filePath, bool isWine = false)
{
try
{
string rootPath;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine))
{
var info = new FileInfo(filePath);
var dir = info.Directory ?? new DirectoryInfo(filePath);
rootPath = dir.Root.FullName;
}
else
{
rootPath = GetMountPoint(filePath);
if (string.IsNullOrEmpty(rootPath))
rootPath = "/";
}
if (_filesystemTypeCache.TryGetValue(rootPath, out var cachedType))
return cachedType;
FilesystemType detected;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine))
{
var root = new DriveInfo(rootPath);
var format = root.DriveFormat?.ToUpperInvariant() ?? string.Empty;
detected = format switch
{
"NTFS" => FilesystemType.NTFS,
"FAT32" => FilesystemType.Fat,
"EXFAT" => FilesystemType.Exfat,
_ => FilesystemType.Unknown
};
}
else
{
detected = GetLinuxFilesystemType(filePath);
}
if (isWine || IsProbablyWine())
{
switch (detected)
{
case FilesystemType.NTFS:
case FilesystemType.Unknown:
{
var linuxDetected = GetLinuxFilesystemType(filePath);
if (linuxDetected != FilesystemType.Unknown)
{
detected = linuxDetected;
}
break;
}
}
}
_filesystemTypeCache[rootPath] = detected;
return detected;
}
catch
{
return FilesystemType.Unknown;
}
}
private static string GetMountPoint(string filePath)
{
try
{
var path = Path.GetFullPath(filePath);
if (!File.Exists(_mountPath)) return "/";
var mounts = File.ReadAllLines(_mountPath);
string bestMount = "/";
foreach (var line in mounts)
{
var parts = line.Split(' ');
if (parts.Length < 3) continue;
var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
string normalizedMount;
try { normalizedMount = Path.GetFullPath(mountPoint); }
catch { normalizedMount = mountPoint; }
if (path.StartsWith(normalizedMount, StringComparison.Ordinal) &&
normalizedMount.Length > bestMount.Length)
{
bestMount = normalizedMount;
}
}
return bestMount;
}
catch
{
return "/";
}
}
public static string GetMountOptionsForPath(string path)
{
try
{
var fullPath = Path.GetFullPath(path);
var mounts = File.ReadAllLines("/proc/mounts");
string bestMount = string.Empty;
string mountOptions = string.Empty;
foreach (var line in mounts)
{
var parts = line.Split(' ');
if (parts.Length < 4) continue;
var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
string normalized;
try { normalized = Path.GetFullPath(mountPoint); }
catch { normalized = mountPoint; }
if (fullPath.StartsWith(normalized, StringComparison.Ordinal) &&
normalized.Length > bestMount.Length)
{
bestMount = normalized;
mountOptions = parts[3];
}
}
return mountOptions;
}
catch (Exception ex)
{
return string.Empty;
}
}
private static FilesystemType GetLinuxFilesystemType(string filePath)
{
try
{
var mountPoint = GetMountPoint(filePath);
var mounts = File.ReadAllLines(_mountPath);
foreach (var line in mounts)
{
var parts = line.Split(' ');
if (parts.Length < 3) continue;
var mount = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
if (string.Equals(mount, mountPoint, StringComparison.Ordinal))
{
var fstype = parts[2].ToLowerInvariant();
return fstype switch
{
"btrfs" => FilesystemType.Btrfs,
"ext4" => FilesystemType.Ext4,
"xfs" => FilesystemType.Xfs,
"zfs" => FilesystemType.Zfs,
"apfs" => FilesystemType.Apfs,
"hfsplus" => FilesystemType.HfsPlus,
_ => FilesystemType.Unknown
};
}
}
return FilesystemType.Unknown;
}
catch
{
return FilesystemType.Unknown;
}
}
public static int GetBlockSizeForPath(string path, ILogger? logger = null, bool isWine = false)
{
try
{
if (string.IsNullOrWhiteSpace(path))
return _defaultBlockSize;
var fi = new FileInfo(path);
if (!fi.Exists)
return _defaultBlockSize;
var root = fi.Directory?.Root.FullName.ToLowerInvariant() ?? "/";
if (_blockSizeCache.TryGetValue(root, out int cached))
return cached;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !isWine)
{
int result = GetDiskFreeSpaceW(root,
out uint sectorsPerCluster,
out uint bytesPerSector,
out _,
out _);
if (result == 0)
{
logger?.LogWarning("Failed to determine block size for {root}", root);
return _defaultBlockSize;
}
int clusterSize = (int)(sectorsPerCluster * bytesPerSector);
_blockSizeCache[root] = clusterSize;
logger?.LogTrace("NTFS cluster size for {root}: {cluster}", root, clusterSize);
return clusterSize;
}
string realPath = fi.FullName;
if (isWine && realPath.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase))
{
realPath = "/" + realPath.Substring(3).Replace('\\', '/');
}
var psi = new ProcessStartInfo
{
FileName = "/bin/bash",
Arguments = $"-c \"stat -f -c %s '{realPath.Replace("'", "'\\''")}'\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = "/"
};
using var proc = Process.Start(psi);
string stdout = proc?.StandardOutput.ReadToEnd().Trim() ?? "";
proc?.WaitForExit();
if (int.TryParse(stdout, out int blockSize) && blockSize > 0)
{
_blockSizeCache[root] = blockSize;
logger?.LogTrace("Filesystem block size via stat for {root}: {block}", root, blockSize);
return blockSize;
}
logger?.LogTrace("stat did not return valid block size for {file}, output: {out}", fi.FullName, stdout);
_blockSizeCache[root] = _defaultBlockSize;
return _defaultBlockSize;
}
catch (Exception ex)
{
logger?.LogTrace(ex, "Error determining block size for {path}", path);
return _defaultBlockSize;
}
}
[DllImport("kernel32.dll", SetLastError = true, PreserveSig = true)]
private static extern int GetDiskFreeSpaceW([In, MarshalAs(UnmanagedType.LPWStr)] string lpRootPathName, out uint lpSectorsPerCluster, out uint lpBytesPerSector, out uint lpNumberOfFreeClusters, out uint lpTotalNumberOfClusters);
//Extra check on
public static bool IsProbablyWine() => Environment.GetEnvironmentVariable("WINELOADERNOEXEC") != null || Environment.GetEnvironmentVariable("WINEDLLPATH") != null || Directory.Exists("/proc/self") && File.Exists("/proc/mounts");
}
}

View File

@@ -70,13 +70,6 @@ public class HubFactory : MediatorSubscriberBase
_ => HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling
};
if (_isWine && !_serverConfigurationManager.CurrentServer.ForceWebSockets
&& transportType.HasFlag(HttpTransportType.WebSockets))
{
Logger.LogDebug("Wine detected, falling back to ServerSentEvents / LongPolling");
transportType = HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling;
}
Logger.LogDebug("Building new HubConnection using transport {transport}", transportType);
_instance = new HubConnectionBuilder()