Compare commits

...

57 Commits

Author SHA1 Message Date
defnotken
a68595d7b1 Building Dev version
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 34s
2025-11-11 12:45:13 -06:00
defnotken
becba8e221 Building Dev
Some checks failed
Tag and Release Lightless / tag-and-release (push) Has been cancelled
2025-11-11 12:44:12 -06:00
defnotken
9c794137c1 changelog added 2025-11-11 12:43:09 -06:00
defnotken
4a256f7807 update penumbra api 2025-11-11 12:03:18 -06:00
defnotken
25756561b9 updating lightlessapi 2025-11-11 12:02:37 -06:00
defnotken
e8c546c128 update lightless API pt 1 2025-11-11 11:54:21 -06:00
d4ba1cf437 Merge pull request 'Turned off btrfs compression for now as not completed' (#85) from linux-improvements into 1.12.4
Reviewed-on: #85
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-11-11 18:41:05 +01:00
e0d1f98c70 Merge branch '1.12.4' into linux-improvements 2025-11-11 17:12:51 +01:00
cake
1862689b1b Changed some commands in file getting, redone compression check commands and turned off btrfs compactor for 1.12.4 2025-11-11 17:09:50 +01:00
325dc8947d Merge pull request 'download notification stuck fix, more x and y offset positions' (#84) from notification-reworks into 1.12.4
Reviewed-on: #84
2025-11-10 22:00:58 +01:00
choco
95e7f2daa7 download notification progress and download bar, chat only option for notifications (idk why you would bother even enabling the lightless nofis then) 2025-11-10 21:59:20 +01:00
choco
41a303dc91 auto dismiss notifs if no updates 2025-11-10 21:29:55 +01:00
choco
25b03aea15 download dismiss message if downloads are complete 2025-11-10 21:22:28 +01:00
choco
b6564156f0 Merge remote-tracking branch 'origin/notification-reworks' into notification-reworks 2025-11-10 21:05:48 +01:00
choco
f89ce900c7 more offset changes accepting minus for notifications till -2500 2025-11-10 21:05:39 +01:00
299abc21ee Merge branch '1.12.4' into notification-reworks 2025-11-10 21:00:01 +01:00
choco
c02a8ed2ee notification clickthrougable, update notes centered in the middle of the screen unmovable 2025-11-10 20:57:43 +01:00
choco
8692e877cf download notification stuck fix, more x and y offset positions 2025-11-10 10:59:42 +01:00
cake
7de72471bb Refactored 2025-11-10 06:25:35 +01:00
cake
d7182e9d57 Hopefully fixes all issues with linux based path finding 2025-11-10 03:52:37 +01:00
2b02de731a Merge pull request 'Some changes on the file compression for linux and windows regards threading.' (#83) from linux-improvements into 1.12.4
Reviewed-on: #83
2025-11-09 06:11:34 +01:00
cake
e9082ab8d0 forget semicolomn.. 2025-11-07 06:07:34 +01:00
2a06a11cbc Merge branch '1.12.4' into linux-improvements 2025-11-07 05:29:34 +01:00
cake
557121a9b7 Added batching for the File Frag command for the iscompressed calls. 2025-11-07 05:27:58 +01:00
b22140a8d4 Merge pull request 'Added more checks in nameplate handler' (#82) from more-checks-nameplate into 1.12.4
Reviewed-on: #82
2025-11-06 18:14:45 +01:00
4db468a480 Merge pull request 'Btrfs Compactor work, defaulted linux on websockets.' (#77) from linux-improvements into 1.12.4
Reviewed-on: #77
2025-11-06 18:13:52 +01:00
8d8f8d20cd Merge pull request 'Syncshell grouped folders pausing function' (#76) from grouped-folder-pause into 1.12.4
Reviewed-on: #76
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
Reviewed-by: choco <choco@noreply.git.lightless-sync.org>
2025-11-06 18:13:41 +01:00
3722b79615 Merge pull request 'Fixed amount of lightfinder users, added user names in the server info list for lightfinder. Added /lightless command' (#75) from lightfinder-dtr-changes into 1.12.4
Reviewed-on: #75
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
Reviewed-by: choco <choco@noreply.git.lightless-sync.org>
2025-11-06 18:13:33 +01:00
cf97e7e800 Merge pull request 'pair button now has additional checks to show if the user isnt directly paired, and only shows if your own lightfinder is on' (#74) from direct-pair-button into 1.12.4
Reviewed-on: #74
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-11-06 18:13:22 +01:00
azyges
1d672d2552 improve checks and add logging 2025-11-06 23:40:58 +09:00
cake
35636f27f6 Cleanup 2025-11-03 21:47:15 +01:00
cake
1b686e45dc Added more null checks and redid active broadcasting cache. 2025-11-03 20:19:02 +01:00
cake
b6aa2bebb1 Added more checks. 2025-11-03 19:59:12 +01:00
cake
cfc9f60176 Added safe checks on enqueue. 2025-11-03 19:27:47 +01:00
cake
d4dca455ba Clean-up, added extra checks on linux in cache monitor, documentation added 2025-11-03 18:54:35 +01:00
76c2777f00 Merge pull request 'Changed warning text for Brio #78' (#81) from character-hub-brio-change into 1.12.4
Reviewed-on: #81
2025-11-01 00:12:49 +01:00
cake
0af2a6134b Changed warning text for Brio 2025-11-01 00:09:54 +01:00
cake
6e3c60f627 Changes in file compression for windows, redone linux side because wine issues. 2025-10-31 23:47:41 +01:00
cake
5feb74c1c0 Added another wine check in parralel with dalamud 2025-10-30 03:46:55 +01:00
cake
c1770528f3 Added wine checks, path fixing on wine -> linux 2025-10-30 03:34:56 +01:00
cake
bf139c128b Added fail safes in compact of WOF incase 2025-10-30 03:11:38 +01:00
cake
b3cc41382f Refactored a bit, added comments on the file systems. 2025-10-30 03:05:53 +01:00
cake
7c4d0fd5e9 Added comments, clean-up 2025-10-29 22:54:50 +01:00
cake
c37e3badf1 Check if wine is used. 2025-10-29 06:09:44 +01:00
f4478f653a Merge branch '1.12.4' into linux-improvements 2025-10-29 04:53:36 +01:00
cake
3f85852618 Added string comparisons 2025-10-29 04:52:17 +01:00
cake
3e626c5e47 Cleanup some code, removed ntfs usage on cache monitor 2025-10-29 04:49:46 +01:00
cake
9a846a37d4 Redone handling of windows compactor handling. 2025-10-29 04:43:18 +01:00
cake
177534d78b Implemented compactor to work on BTRFS, redid cache a bit for better function on linux. Removed error for websockets, it will be forced on wine again. 2025-10-29 04:37:24 +01:00
cake
de75b90703 Added spacing on function 2025-10-28 18:23:01 +01:00
cake
c16891021c Added pause button for all syncshells and grouped syncshells. 2025-10-28 18:20:57 +01:00
d19d1c0a3a Merge branch '1.12.4' into lightfinder-dtr-changes 2025-10-28 15:47:42 +01:00
choco
cabc4ec0fe removed lightfinder on check 2025-10-28 00:57:49 +01:00
cake
8bccdc5ef1 Added lightless command. 2025-10-27 22:26:03 +01:00
cake
ce5f8a43a2 Added list of users names in the dtr entry whenever lightfinder is active 2025-10-27 16:16:40 +01:00
choco
437731749f pair button now has additional checks to show if the user isnt directly paired, and only shows if your own lightfinder is on 2025-10-26 17:34:17 +01:00
defnotken
55e78e088a Initialize .4 2025-10-24 09:45:24 -05:00
30 changed files with 2171 additions and 503 deletions

View File

@@ -1,11 +1,42 @@
tagline: "Lightless Sync v1.12.3"
subline: "LightSpeed, Welcome Screen, and More!"
tagline: "Lightless Sync v1.12.4"
subline: "Bugfixes and various improvements across Lightless"
changelog:
- name: "v1.12.4"
tagline: "Preparation for future features"
date: "November 11th 2025"
# be sure to set this every new version
isCurrent: true
versions:
- number: "Syncshells"
icon: ""
items:
- "Added a pause button for syncshells in grouped folders"
- number: "Notifications"
icon: ""
items:
- "Fixed download notifications getting stuck at times"
- "Added more offset positions for the notifications"
- number: "Lightfinder"
icon: ""
items:
- "Pair button will now show up when you're not directly paired. ie. You are technically paired in a syncshell, but you may not be directly paired'"
- "Fixed a problem where the number of LightFinder users were cached, displaying the wrong information"
- "When LightFinder is enabled, if you hover over the number of users, it will show you how many users are also using LightFinder in your area"
-
- number: "Bugfixes"
icon: ""
items:
- "Added even more checks to nameplate handler to help not only us debug, but also other plugins writing to the plate"
- number: "Miscellaneous Changes"
icon: ""
items:
- "Default Linux to Websockets"
- "Revised Brio warning"
- "Added /lightless command to open Lightless UI (you can still use /light)"
- "Initial groundwork for future features"
- name: "v1.12.3"
tagline: "LightSpeed, Welcome Screen, and More!"
date: "October 15th 2025"
# be sure to set this every new version
isCurrent: true
versions:
- number: "LightSpeed"
icon: ""

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

@@ -113,7 +113,7 @@ public class LightlessConfig : ILightlessConfiguration
public int WarningNotificationDurationSeconds { get; set; } = 15;
public int ErrorNotificationDurationSeconds { get; set; } = 20;
public int PairRequestDurationSeconds { get; set; } = 180;
public int DownloadNotificationDurationSeconds { get; set; } = 300;
public int DownloadNotificationDurationSeconds { get; set; } = 30;
public int PerformanceNotificationDurationSeconds { get; set; } = 20;
public uint CustomInfoSoundId { get; set; } = 2; // Se2
public uint CustomWarningSoundId { get; set; } = 16; // Se15

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.3.1</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

@@ -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,228 @@
using Microsoft.Extensions.Logging;
using System.Text.RegularExpressions;
using System.Threading.Channels;
namespace LightlessSync.Services.Compactor
{
/// <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 delegate (bool ok, string stdout, string stderr, int exitCode) RunDirect(string fileName, IEnumerable<string> args, string? workingDir, int timeoutMs);
private readonly RunDirect _runDirect;
public delegate (bool ok, string stdout, string stderr, int exitCode) RunShell(string command, string? workingDir, int timeoutMs);
private readonly RunShell _runShell;
public BatchFilefragService(bool useShell, ILogger log, int batchSize = 128, int flushMs = 25, RunDirect? runDirect = null, RunShell? runShell = null)
{
_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 });
// require runners to be setup, wouldnt start otherwise
if (runDirect is null || runShell is null)
throw new ArgumentNullException(nameof(runDirect), "Provide process runners from FileCompactor");
_runDirect = runDirect;
_runShell = runShell;
_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 = RunBatch(pending.Select(p => p.path));
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 Dictionary<string, bool> RunBatch(IEnumerable<string> paths)
{
var list = paths.Distinct(StringComparer.Ordinal).ToList();
var result = list.ToDictionary(p => p, _ => false, StringComparer.Ordinal);
(bool ok, string stdout, string stderr, int code) res;
if (_useShell)
{
var inner = "filefrag -v " + string.Join(' ', list.Select(QuoteSingle));
res = _runShell(inner, timeoutMs: 15000, workingDir: "/");
}
else
{
var args = new List<string> { "-v" };
foreach (var path in list)
{
args.Add(' ' + path);
}
res = _runDirect("filefrag", args, workingDir: "/", timeoutMs: 15000);
}
if (!string.IsNullOrWhiteSpace(res.stderr))
_log.LogTrace("filefrag stderr (batch): {err}", res.stderr.Trim());
ParseFilefrag(res.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) + "'";
/// <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

@@ -342,12 +342,6 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
_ => download.Status
};
private bool AreAllDownloadsCompleted(List<(string PlayerName, float Progress, string Status)> userDownloads) =>
userDownloads.Any() && userDownloads.All(x => x.Progress >= 1.0f);
public void DismissPairDownloadNotification() =>
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch
{
NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds),
@@ -607,11 +601,6 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
};
Mediator.Publish(new LightlessNotificationMessage(notification));
if (userDownloads.Count == 0 || AreAllDownloadsCompleted(userDownloads))
{
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
}
}
private void HandlePerformanceNotification(PerformanceNotificationMessage msg)

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

@@ -58,13 +58,21 @@ public class DownloadUi : WindowMediatorSubscriberBase
IsOpen = true;
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus);
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) =>
{
_currentDownloads[msg.DownloadId] = msg.DownloadStatus;
_notificationDismissed = false;
});
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
{
_currentDownloads.TryRemove(msg.DownloadId, out _);
if (!_currentDownloads.Any())
// Dismiss notification if all downloads are complete
if (!_currentDownloads.Any() && !_notificationDismissed)
{
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
_notificationDismissed = true;
_lastDownloadStateHash = 0;
}
});
Mediator.Subscribe<GposeStartMessage>(this, (_) => IsOpen = false);
@@ -361,5 +369,4 @@ public class DownloadUi : WindowMediatorSubscriberBase
}
}
}
}

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

@@ -45,6 +45,9 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
ImGuiWindowFlags.NoNav |
ImGuiWindowFlags.NoBackground |
ImGuiWindowFlags.NoCollapse |
ImGuiWindowFlags.NoInputs |
ImGuiWindowFlags.NoTitleBar |
ImGuiWindowFlags.NoScrollbar |
ImGuiWindowFlags.AlwaysAutoResize;
PositionCondition = ImGuiCond.Always;
@@ -86,6 +89,13 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
existing.Progress = updated.Progress;
existing.ShowProgress = updated.ShowProgress;
existing.Title = updated.Title;
// Reset the duration timer on every update for download notifications
if (updated.Type == NotificationType.Download)
{
existing.CreatedAt = DateTime.UtcNow;
}
_logger.LogDebug("Updated existing notification: {Title}", updated.Title);
}
@@ -335,6 +345,13 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
DrawBackground(drawList, windowPos, windowSize, bgColor);
DrawAccentBar(drawList, windowPos, windowSize, accentColor);
DrawDurationProgressBar(notification, alpha, windowPos, windowSize, drawList);
// Draw download progress bar above duration bar for download notifications
if (notification.Type == NotificationType.Download && notification.ShowProgress)
{
DrawDownloadProgressBar(notification, alpha, windowPos, windowSize, drawList);
}
DrawNotificationText(notification, alpha);
}
@@ -416,7 +433,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList)
{
var progress = CalculateProgress(notification);
var progress = CalculateDurationProgress(notification);
var progressBarColor = UIColors.Get("LightlessBlue");
var progressHeight = 2f;
var progressY = windowPos.Y + windowSize.Y - progressHeight;
@@ -430,13 +447,26 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
}
}
private float CalculateProgress(LightlessNotification notification)
private void DrawDownloadProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList)
{
if (notification.Type == NotificationType.Download && notification.ShowProgress)
{
return Math.Clamp(notification.Progress, 0f, 1f);
}
var progress = Math.Clamp(notification.Progress, 0f, 1f);
var progressBarColor = UIColors.Get("LightlessGreen");
var progressHeight = 3f;
// Position above the duration bar (2px duration bar + 1px spacing)
var progressY = windowPos.Y + windowSize.Y - progressHeight - 3f;
var progressWidth = windowSize.X * progress;
DrawProgressBackground(drawList, windowPos, windowSize, progressY, progressHeight, progressBarColor, alpha);
if (progress > 0)
{
DrawProgressForeground(drawList, windowPos, progressY, progressHeight, progressWidth, progressBarColor, alpha);
}
}
private float CalculateDurationProgress(LightlessNotification notification)
{
// Calculate duration timer progress
var elapsed = DateTime.UtcNow - notification.CreatedAt;
return Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds));
}

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.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.StorageisNTFS)
{
ImGui.EndDisabled();
ImGui.TextUnformatted("The file compactor is only available on Windows and NTFS drives.");
ImGui.TextUnformatted("The file compactor is only available NTFS drives, soon for btrfs.");
}
if (_cacheMonitor.StorageisNTFS)
{
ImGui.TextUnformatted("The file compactor detected an NTFS Drive.");
}
if (_cacheMonitor.StorageIsBtrfs)
{
ImGui.TextUnformatted("The file compactor detected an 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))
@@ -3851,9 +3845,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
_uiShared.DrawHelpText("Choose which corner of the screen notifications appear in.");
int offsetY = _configService.Current.NotificationOffsetY;
if (ImGui.SliderInt("Vertical Offset", ref offsetY, 0, 1000))
if (ImGui.SliderInt("Vertical Offset", ref offsetY, -2500, 2500))
{
_configService.Current.NotificationOffsetY = Math.Clamp(offsetY, 0, 1000);
_configService.Current.NotificationOffsetY = Math.Clamp(offsetY, -2500, 2500);
_configService.Save();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
@@ -3866,9 +3860,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
_uiShared.DrawHelpText("Distance from the top edge of the screen.");
int offsetX = _configService.Current.NotificationOffsetX;
if (ImGui.SliderInt("Horizontal Offset", ref offsetX, 0, 500))
if (ImGui.SliderInt("Horizontal Offset", ref offsetX, -2500, 2500))
{
_configService.Current.NotificationOffsetX = Math.Clamp(offsetX, 0, 500);
_configService.Current.NotificationOffsetX = Math.Clamp(offsetX, -2500, 2500);
_configService.Save();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
@@ -3985,9 +3979,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.SetTooltip("Right click to reset to default (20).");
int pairRequestDuration = _configService.Current.PairRequestDurationSeconds;
if (ImGui.SliderInt("Pair Request Duration (seconds)", ref pairRequestDuration, 30, 600))
if (ImGui.SliderInt("Pair Request Duration (seconds)", ref pairRequestDuration, 30, 1800))
{
_configService.Current.PairRequestDurationSeconds = Math.Clamp(pairRequestDuration, 30, 600);
_configService.Current.PairRequestDurationSeconds = Math.Clamp(pairRequestDuration, 30, 1800);
_configService.Save();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
@@ -3999,23 +3993,23 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.SetTooltip("Right click to reset to default (180).");
int downloadDuration = _configService.Current.DownloadNotificationDurationSeconds;
if (ImGui.SliderInt("Download Duration (seconds)", ref downloadDuration, 60, 600))
if (ImGui.SliderInt("Download Duration (seconds)", ref downloadDuration, 15, 120))
{
_configService.Current.DownloadNotificationDurationSeconds = Math.Clamp(downloadDuration, 60, 600);
_configService.Current.DownloadNotificationDurationSeconds = Math.Clamp(downloadDuration, 15, 120);
_configService.Save();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
{
_configService.Current.DownloadNotificationDurationSeconds = 300;
_configService.Current.DownloadNotificationDurationSeconds = 30;
_configService.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Right click to reset to default (300).");
ImGui.SetTooltip("Right click to reset to default (30).");
int performanceDuration = _configService.Current.PerformanceNotificationDurationSeconds;
if (ImGui.SliderInt("Performance Duration (seconds)", ref performanceDuration, 5, 60))
if (ImGui.SliderInt("Performance Duration (seconds)", ref performanceDuration, 5, 120))
{
_configService.Current.PerformanceNotificationDurationSeconds = Math.Clamp(performanceDuration, 5, 60);
_configService.Current.PerformanceNotificationDurationSeconds = Math.Clamp(performanceDuration, 5, 120);
_configService.Save();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
@@ -4150,7 +4144,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
{
return new[]
{
NotificationLocation.LightlessUi, NotificationLocation.ChatAndLightlessUi, NotificationLocation.Nowhere
NotificationLocation.LightlessUi, NotificationLocation.Chat, NotificationLocation.ChatAndLightlessUi, NotificationLocation.Nowhere
};
}

View File

@@ -76,13 +76,15 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase
ShowCloseButton = true;
Flags = ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoCollapse |
ImGuiWindowFlags.NoTitleBar;
ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove;
SizeConstraints = new WindowSizeConstraints()
{
MinimumSize = new Vector2(800, 700), MaximumSize = new Vector2(800, 700),
};
PositionCondition = ImGuiCond.Always;
LoadEmbeddedResources();
logger.LogInformation("UpdateNotesUi constructor completed successfully");
}
@@ -93,11 +95,20 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase
_hasInitializedCollapsingHeaders = false;
}
private void CenterWindow()
{
var viewport = ImGui.GetMainViewport();
var center = viewport.GetCenter();
var windowSize = new Vector2(800f * ImGuiHelpers.GlobalScale, 700f * ImGuiHelpers.GlobalScale);
Position = center - windowSize / 2f;
}
protected override void DrawInternal()
{
if (_uiShared.IsInGpose)
return;
CenterWindow();
DrawHeader();
ImGuiHelpers.ScaledDummy(6);
DrawTabs();

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,286 @@
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() ?? "";
string _stderr = proc?.StandardError.ReadToEnd() ?? "";
try { proc?.WaitForExit(); }
catch (Exception ex) { logger?.LogTrace(ex, "stat WaitForExit failed under Wine; ignoring"); }
if (!(!int.TryParse(stdout, out int block) || block <= 0))
{
_blockSizeCache[root] = block;
logger?.LogTrace("Filesystem block size via stat for {root}: {block}", root, block);
return block;
}
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

@@ -2,6 +2,7 @@ using Dalamud.Utility;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.API.SignalR;
@@ -610,5 +611,40 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
{
throw new NotImplementedException();
}
public Task UpdateChatPresence(ChatPresenceUpdateDto presence)
{
throw new NotImplementedException();
}
public Task Client_ChatReceive(ChatMessageDto message)
{
throw new NotImplementedException();
}
public Task<IReadOnlyList<ZoneChatChannelInfoDto>> GetZoneChatChannels()
{
throw new NotImplementedException();
}
public Task<IReadOnlyList<GroupChatChannelInfoDto>> GetGroupChatChannels()
{
throw new NotImplementedException();
}
public Task SendChatMessage(ChatSendRequestDto request)
{
throw new NotImplementedException();
}
public Task ReportChatMessage(ChatReportSubmitDto request)
{
throw new NotImplementedException();
}
public Task<ChatParticipantResolveResultDto?> ResolveChatParticipant(ChatParticipantResolveRequestDto request)
{
throw new NotImplementedException();
}
}
#pragma warning restore MA0040

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