Merge branch '1.12.3' into syncshell-profiles

This commit is contained in:
2025-10-19 22:00:16 +02:00
23 changed files with 1483 additions and 473 deletions

View File

@@ -27,6 +27,7 @@ public sealed class FileCacheManager : IHostedService
private readonly Lock _fileWriteLock = new();
private readonly IpcManager _ipcManager;
private readonly ILogger<FileCacheManager> _logger;
private bool _csvHeaderEnsured;
public string CacheFolder => _configService.Current.CacheFolder;
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
@@ -462,6 +463,7 @@ public sealed class FileCacheManager : IHostedService
string[] existingLines = File.ReadAllLines(_csvPath);
if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion)
{
_csvHeaderEnsured = true;
return;
}
@@ -481,6 +483,18 @@ public sealed class FileCacheManager : IHostedService
}
File.WriteAllText(_csvPath, rebuilt.ToString());
_csvHeaderEnsured = true;
}
private void EnsureCsvHeaderLockedCached()
{
if (_csvHeaderEnsured)
{
return;
}
EnsureCsvHeaderLocked();
_csvHeaderEnsured = true;
}
private void BackupUnsupportedCache(string suffix)
@@ -540,10 +554,11 @@ public sealed class FileCacheManager : IHostedService
if (!File.Exists(_csvPath))
{
File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry });
_csvHeaderEnsured = true;
}
else
{
EnsureCsvHeaderLocked();
EnsureCsvHeaderLockedCached();
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry });
}
}

View File

@@ -2,25 +2,33 @@
using LightlessSync.Services;
using Microsoft.Extensions.Logging;
using System.Runtime.InteropServices;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace LightlessSync.FileCache;
public sealed class FileCompactor
public sealed class FileCompactor : IDisposable
{
public const uint FSCTL_DELETE_EXTERNAL_BACKING = 0x90314U;
public const ulong WOF_PROVIDER_FILE = 2UL;
private readonly Dictionary<string, int> _clusterSizes;
private readonly ConcurrentDictionary<string, byte> _pendingCompactions;
private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo;
private readonly ILogger<FileCompactor> _logger;
private readonly LightlessConfigService _lightlessConfigService;
private readonly DalamudUtilService _dalamudUtilService;
private readonly Channel<string> _compactionQueue;
private readonly CancellationTokenSource _compactionCts = new();
private readonly Task _compactionWorker;
public FileCompactor(ILogger<FileCompactor> logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService)
{
_clusterSizes = new(StringComparer.Ordinal);
_pendingCompactions = new(StringComparer.OrdinalIgnoreCase);
_logger = logger;
_lightlessConfigService = lightlessConfigService;
_dalamudUtilService = dalamudUtilService;
@@ -29,6 +37,18 @@ public sealed class FileCompactor
Algorithm = CompressionAlgorithm.XPRESS8K,
Flags = 0
};
_compactionQueue = Channel.CreateUnbounded<string>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
_compactionWorker = Task.Factory.StartNew(
() => ProcessQueueAsync(_compactionCts.Token),
_compactionCts.Token,
TaskCreationOptions.LongRunning,
TaskScheduler.Default)
.Unwrap();
}
private enum CompressionAlgorithm
@@ -87,7 +107,30 @@ public sealed class FileCompactor
return;
}
CompactFile(filePath);
EnqueueCompaction(filePath);
}
public void Dispose()
{
_compactionQueue.Writer.TryComplete();
_compactionCts.Cancel();
try
{
if (!_compactionWorker.Wait(TimeSpan.FromSeconds(5)))
{
_logger.LogDebug("Compaction worker did not shut down within timeout");
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Error shutting down compaction worker");
}
finally
{
_compactionCts.Dispose();
}
GC.SuppressFinalize(this);
}
[DllImport("kernel32.dll")]
@@ -226,4 +269,67 @@ public sealed class FileCompactor
public CompressionAlgorithm Algorithm;
public ulong Flags;
}
}
private void EnqueueCompaction(string filePath)
{
if (!_pendingCompactions.TryAdd(filePath, 0))
{
return;
}
if (!_compactionQueue.Writer.TryWrite(filePath))
{
_pendingCompactions.TryRemove(filePath, out _);
_logger.LogDebug("Failed to enqueue compaction job for {file}", filePath);
}
}
private async Task ProcessQueueAsync(CancellationToken token)
{
try
{
while (await _compactionQueue.Reader.WaitToReadAsync(token).ConfigureAwait(false))
{
while (_compactionQueue.Reader.TryRead(out var filePath))
{
try
{
if (token.IsCancellationRequested)
{
return;
}
if (_dalamudUtilService.IsWine || !_lightlessConfigService.Current.UseCompactor)
{
continue;
}
if (!File.Exists(filePath))
{
_logger.LogTrace("Skipping compaction for missing file {file}", filePath);
continue;
}
CompactFile(filePath);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error compacting file {file}", filePath);
}
finally
{
_pendingCompactions.TryRemove(filePath, out _);
}
}
}
}
catch (OperationCanceledException)
{
// expected during shutdown
}
}
}

View File

@@ -67,6 +67,7 @@ public class LightlessConfig : ILightlessConfiguration
public bool ShowUploading { get; set; } = true;
public bool ShowUploadingBigText { get; set; } = true;
public bool ShowVisibleUsersSeparately { get; set; } = true;
public bool EnableDirectDownloads { get; set; } = true;
public int TimeSpanBetweenScansInSeconds { get; set; } = 30;
public int TransferBarsHeight { get; set; } = 12;
public bool TransferBarsShowText { get; set; } = true;
@@ -86,6 +87,7 @@ public class LightlessConfig : ILightlessConfiguration
public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi;
public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi;
public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay;
public NotificationLocation LightlessPerformanceNotification { get; set; } = NotificationLocation.LightlessUi;
// Basic Settings
public float NotificationOpacity { get; set; } = 0.95f;
@@ -95,6 +97,7 @@ public class LightlessConfig : ILightlessConfiguration
public bool ShowNotificationTimestamp { get; set; } = false;
// Position & Layout
public NotificationCorner NotificationCorner { get; set; } = NotificationCorner.Right;
public int NotificationOffsetY { get; set; } = 50;
public int NotificationOffsetX { get; set; } = 0;
public float NotificationWidth { get; set; } = 350f;
@@ -102,6 +105,7 @@ public class LightlessConfig : ILightlessConfiguration
// Animation & Effects
public float NotificationAnimationSpeed { get; set; } = 10f;
public float NotificationSlideSpeed { get; set; } = 10f;
public float NotificationAccentBarWidth { get; set; } = 3f;
// Duration per Type
@@ -110,16 +114,19 @@ public class LightlessConfig : ILightlessConfiguration
public int ErrorNotificationDurationSeconds { get; set; } = 20;
public int PairRequestDurationSeconds { get; set; } = 180;
public int DownloadNotificationDurationSeconds { get; set; } = 300;
public int PerformanceNotificationDurationSeconds { get; set; } = 20;
public uint CustomInfoSoundId { get; set; } = 2; // Se2
public uint CustomWarningSoundId { get; set; } = 16; // Se15
public uint CustomErrorSoundId { get; set; } = 16; // Se15
public uint PairRequestSoundId { get; set; } = 5; // Se5
public uint DownloadSoundId { get; set; } = 15; // Se14
public uint PerformanceSoundId { get; set; } = 16; // Se15
public bool DisableInfoSound { get; set; } = true;
public bool DisableWarningSound { get; set; } = true;
public bool DisableErrorSound { get; set; } = true;
public bool DisablePairRequestSound { get; set; } = true;
public bool DisableDownloadSound { get; set; } = true;
public bool DisablePerformanceSound { get; set; } = true;
public bool ShowPerformanceNotificationActions { get; set; } = true;
public bool ShowPairRequestNotificationActions { get; set; } = true;
public bool UseFocusTarget { get; set; } = false;
public bool overrideFriendColor { get; set; } = false;
public bool overridePartyColor { get; set; } = false;

View File

@@ -17,5 +17,12 @@ public enum NotificationType
Warning,
Error,
PairRequest,
Download
Download,
Performance
}
public enum NotificationCorner
{
Right,
Left
}

View File

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

View File

@@ -1,4 +1,6 @@
using LightlessSync.FileCache;
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging;
@@ -10,21 +12,38 @@ public class FileDownloadManagerFactory
private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator;
private readonly LightlessConfigService _configService;
public FileDownloadManagerFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager, FileCompactor fileCompactor)
public FileDownloadManagerFactory(
ILoggerFactory loggerFactory,
LightlessMediator lightlessMediator,
FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager,
FileCompactor fileCompactor,
PairProcessingLimiter pairProcessingLimiter,
LightlessConfigService configService)
{
_loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator;
_fileTransferOrchestrator = fileTransferOrchestrator;
_fileCacheManager = fileCacheManager;
_fileCompactor = fileCompactor;
_pairProcessingLimiter = pairProcessingLimiter;
_configService = configService;
}
public FileDownloadManager Create()
{
return new FileDownloadManager(_loggerFactory.CreateLogger<FileDownloadManager>(), _lightlessMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor);
return new FileDownloadManager(
_loggerFactory.CreateLogger<FileDownloadManager>(),
_lightlessMediator,
_fileTransferOrchestrator,
_fileCacheManager,
_fileCompactor,
_pairProcessingLimiter,
_configService);
}
}
}

View File

@@ -98,7 +98,19 @@ public class PlayerDataFactory
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
{
return ((Character*)playerPointer)->GameObject.DrawObject == null;
if (playerPointer == IntPtr.Zero)
return true;
var character = (Character*)playerPointer;
if (character == null)
return true;
var gameObject = &character->GameObject;
if (gameObject == null)
return true;
return gameObject->DrawObject == null;
}
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)

View File

@@ -48,20 +48,23 @@ public record PetNamesMessage(string PetNicknamesData) : MessageBase;
public record HonorificReadyMessage : MessageBase;
public record TransientResourceChangedMessage(IntPtr Address) : MessageBase;
public record HaltScanMessage(string Source) : MessageBase;
public record ResumeScanMessage(string Source) : MessageBase;
public record NotificationMessage
(string Title, string Message, NotificationType Type, TimeSpan? TimeShownOnScreen = null) : MessageBase;
public record PerformanceNotificationMessage
(string Title, string Message, UserData UserData, bool IsPaused, string PlayerName) : MessageBase;
public record CreateCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage;
public record LightlessNotificationMessage(LightlessSync.UI.Models.LightlessNotification Notification) : MessageBase;
public record LightlessNotificationDismissMessage(string NotificationId) : MessageBase;
public record ClearAllNotificationsMessage : MessageBase;
public record CharacterDataAnalyzedMessage : MessageBase;
public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase;
public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase;
public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage;
public record HubReconnectedMessage(string? Arg) : SameThreadMessage;
public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
public record ResumeScanMessage(string Source) : MessageBase;
public record DownloadReadyMessage(Guid RequestId) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;

View File

@@ -208,7 +208,13 @@ public unsafe class NameplateHandler : IMediatorSubscriber
for (int i = 0; i < ui3DModule->NamePlateObjectInfoCount; ++i)
{
var objectInfo = ui3DModule->NamePlateObjectInfoPointers[i].Value;
if (ui3DModule->NamePlateObjectInfoPointers.IsEmpty) continue;
var objectInfoPtr = ui3DModule->NamePlateObjectInfoPointers[i];
if (objectInfoPtr == null) continue;
var objectInfo = objectInfoPtr.Value;
if (objectInfo == null || objectInfo->GameObject == null)
continue;

View File

@@ -10,9 +10,11 @@ using LightlessSync.UI.Models;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using FFXIVClientStructs.FFXIV.Client.UI;
using LightlessSync.API.Data;
using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType;
namespace LightlessSync.Services;
public class NotificationService : DisposableMediatorSubscriberBase, IHostedService
{
private readonly ILogger<NotificationService> _logger;
@@ -44,6 +46,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
{
Mediator.Subscribe<NotificationMessage>(this, HandleNotificationMessage);
Mediator.Subscribe<PairRequestsUpdatedMessage>(this, HandlePairRequestsUpdated);
Mediator.Subscribe<PerformanceNotificationMessage>(this, HandlePerformanceNotification);
return Task.CompletedTask;
}
@@ -107,23 +110,42 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline)
{
var notification = new LightlessNotification
var location = GetNotificationLocation(NotificationType.PairRequest);
// Show in chat if configured
if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi)
{
Id = $"pair_request_{senderId}",
Title = "Pair Request Received",
Message = $"{senderName} wants to directly pair with you.",
Type = NotificationType.PairRequest,
Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
SoundEffectId = GetPairRequestSoundId(),
Actions = CreatePairRequestActions(onAccept, onDecline)
};
if (notification.SoundEffectId.HasValue)
{
PlayNotificationSound(notification.SoundEffectId.Value);
ShowChat(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest));
}
// Show Lightless notification if configured and action buttons are enabled
if ((location == NotificationLocation.LightlessUi || location == NotificationLocation.ChatAndLightlessUi)
&& _configService.Current.UseLightlessNotifications
&& _configService.Current.ShowPairRequestNotificationActions)
{
var notification = new LightlessNotification
{
Id = $"pair_request_{senderId}",
Title = "Pair Request Received",
Message = $"{senderName} wants to directly pair with you.",
Type = NotificationType.PairRequest,
Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
SoundEffectId = GetPairRequestSoundId(),
Actions = CreatePairRequestActions(onAccept, onDecline)
};
Mediator.Publish(new LightlessNotificationMessage(notification));
if (notification.SoundEffectId.HasValue)
{
PlayNotificationSound(notification.SoundEffectId.Value);
}
Mediator.Publish(new LightlessNotificationMessage(notification));
}
else if (location != NotificationLocation.Nowhere && location != NotificationLocation.Chat)
{
// Fall back to regular notification without action buttons
HandleNotificationMessage(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest));
}
}
private uint? GetPairRequestSoundId() =>
@@ -356,6 +378,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds),
NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
NotificationType.Performance => TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds),
_ => TimeSpan.FromSeconds(10)
};
@@ -371,7 +394,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Info => _configService.Current.DisableInfoSound,
NotificationType.Warning => _configService.Current.DisableWarningSound,
NotificationType.Error => _configService.Current.DisableErrorSound,
NotificationType.Download => _configService.Current.DisableDownloadSound,
NotificationType.Performance => _configService.Current.DisablePerformanceSound,
NotificationType.Download => true, // Download sounds always disabled
_ => false
};
@@ -380,7 +404,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Info => _configService.Current.CustomInfoSoundId,
NotificationType.Warning => _configService.Current.CustomWarningSoundId,
NotificationType.Error => _configService.Current.CustomErrorSoundId,
NotificationType.Download => _configService.Current.DownloadSoundId,
NotificationType.Performance => _configService.Current.PerformanceSoundId,
_ => NotificationSounds.GetDefaultSound(type)
};
@@ -418,6 +442,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Error => _configService.Current.LightlessErrorNotification,
NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification,
NotificationType.Download => _configService.Current.LightlessDownloadNotification,
NotificationType.Performance => _configService.Current.LightlessPerformanceNotification,
_ => NotificationLocation.LightlessUi
};
@@ -505,6 +530,18 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
case NotificationType.Error:
PrintErrorChat(msg.Message);
break;
case NotificationType.PairRequest:
PrintPairRequestChat(msg.Title, msg.Message);
break;
case NotificationType.Performance:
PrintPerformanceChat(msg.Title, msg.Message);
break;
// Download notifications don't support chat output, will be a giga spam otherwise
case NotificationType.Download:
break;
}
}
@@ -528,6 +565,22 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
_chatGui.Print(se.BuiltString);
}
private void PrintPairRequestChat(string? title, string? message)
{
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ")
.AddUiForeground("Pair Request: ", 541).AddUiForegroundOff()
.AddText(title ?? message ?? string.Empty);
_chatGui.Print(se.BuiltString);
}
private void PrintPerformanceChat(string? title, string? message)
{
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ")
.AddUiForeground("Performance: ", 508).AddUiForegroundOff()
.AddText(title ?? message ?? string.Empty);
_chatGui.Print(se.BuiltString);
}
private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _)
{
var activeRequests = _pairRequestService.GetActiveRequests();
@@ -557,5 +610,144 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
);
}
}
}
private void HandlePerformanceNotification(PerformanceNotificationMessage msg)
{
var location = GetNotificationLocation(NotificationType.Performance);
// Show in chat if configured
if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi)
{
ShowChat(new NotificationMessage(msg.Title, msg.Message, NotificationType.Performance));
}
// Show Lightless notification if configured and action buttons are enabled
if ((location == NotificationLocation.LightlessUi || location == NotificationLocation.ChatAndLightlessUi)
&& _configService.Current.UseLightlessNotifications
&& _configService.Current.ShowPerformanceNotificationActions)
{
var actions = CreatePerformanceActions(msg.UserData, msg.IsPaused, msg.PlayerName);
var notification = new LightlessNotification
{
Title = msg.Title,
Message = msg.Message,
Type = NotificationType.Performance,
Duration = TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds),
Actions = actions,
SoundEffectId = GetSoundEffectId(NotificationType.Performance, null)
};
if (notification.SoundEffectId.HasValue)
{
PlayNotificationSound(notification.SoundEffectId.Value);
}
Mediator.Publish(new LightlessNotificationMessage(notification));
}
else if (location != NotificationLocation.Nowhere && location != NotificationLocation.Chat)
{
// Fall back to regular notification without action buttons
HandleNotificationMessage(new NotificationMessage(msg.Title, msg.Message, NotificationType.Performance));
}
}
private List<LightlessNotificationAction> CreatePerformanceActions(UserData userData, bool isPaused, string playerName)
{
var actions = new List<LightlessNotificationAction>();
if (isPaused)
{
actions.Add(new LightlessNotificationAction
{
Label = "Unpause",
Icon = FontAwesomeIcon.Play,
Color = UIColors.Get("LightlessGreen"),
IsPrimary = true,
OnClick = (notification) =>
{
try
{
Mediator.Publish(new CyclePauseMessage(userData));
DismissNotification(notification);
var displayName = GetUserDisplayName(userData, playerName);
ShowNotification(
"Player Unpaused",
$"Successfully unpaused {displayName}",
NotificationType.Info,
TimeSpan.FromSeconds(3));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to unpause player {uid}", userData.UID);
var displayName = GetUserDisplayName(userData, playerName);
ShowNotification(
"Unpause Failed",
$"Failed to unpause {displayName}",
NotificationType.Error,
TimeSpan.FromSeconds(5));
}
}
});
}
else
{
actions.Add(new LightlessNotificationAction
{
Label = "Pause",
Icon = FontAwesomeIcon.Pause,
Color = UIColors.Get("LightlessOrange"),
IsPrimary = true,
OnClick = (notification) =>
{
try
{
Mediator.Publish(new PauseMessage(userData));
DismissNotification(notification);
var displayName = GetUserDisplayName(userData, playerName);
ShowNotification(
"Player Paused",
$"Successfully paused {displayName}",
NotificationType.Info,
TimeSpan.FromSeconds(3));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to pause player {uid}", userData.UID);
var displayName = GetUserDisplayName(userData, playerName);
ShowNotification(
"Pause Failed",
$"Failed to pause {displayName}",
NotificationType.Error,
TimeSpan.FromSeconds(5));
}
}
});
}
// Add dismiss button
actions.Add(new LightlessNotificationAction
{
Label = "Dismiss",
Icon = FontAwesomeIcon.Times,
Color = UIColors.Get("DimRed"),
IsPrimary = false,
OnClick = (notification) =>
{
DismissNotification(notification);
}
});
return actions;
}
private string GetUserDisplayName(UserData userData, string playerName)
{
if (!string.IsNullOrEmpty(userData.Alias) && !string.Equals(userData.Alias, userData.UID, StringComparison.Ordinal))
{
return $"{playerName} ({userData.Alias})";
}
return $"{playerName} ({userData.UID})";
}
}

View File

@@ -15,6 +15,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
private readonly SemaphoreSlim _semaphore;
private int _currentLimit;
private int _pendingReductions;
private int _pendingIncrements;
private int _waiting;
private int _inFlight;
@@ -70,7 +71,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
if (!IsEnabled)
{
_semaphore.Release();
TryReleaseSemaphore();
return NoopReleaser.Instance;
}
@@ -90,18 +91,12 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
var releaseAmount = HardLimit - _semaphore.CurrentCount;
if (releaseAmount > 0)
{
try
{
_semaphore.Release(releaseAmount);
}
catch (SemaphoreFullException)
{
// ignore, already at max
}
TryReleaseSemaphore(releaseAmount);
}
_currentLimit = desiredLimit;
_pendingReductions = 0;
_pendingIncrements = 0;
return;
}
@@ -113,10 +108,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
if (desiredLimit > _currentLimit)
{
var increment = desiredLimit - _currentLimit;
var allowed = Math.Min(increment, HardLimit - _semaphore.CurrentCount);
if (allowed > 0)
_pendingIncrements += increment;
var available = HardLimit - _semaphore.CurrentCount;
var toRelease = Math.Min(_pendingIncrements, available);
if (toRelease > 0 && TryReleaseSemaphore(toRelease))
{
_semaphore.Release(allowed);
_pendingIncrements -= toRelease;
}
}
else
@@ -133,6 +131,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{
_pendingReductions += remaining;
}
if (_pendingIncrements > 0)
{
var offset = Math.Min(_pendingIncrements, _pendingReductions);
_pendingIncrements -= offset;
_pendingReductions -= offset;
}
}
_currentLimit = desiredLimit;
@@ -146,6 +151,25 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
return Math.Clamp(configured, 1, HardLimit);
}
private bool TryReleaseSemaphore(int count = 1)
{
if (count <= 0)
{
return true;
}
try
{
_semaphore.Release(count);
return true;
}
catch (SemaphoreFullException ex)
{
Logger.LogDebug(ex, "Attempted to release {count} pair processing slots but semaphore is already at the hard limit.", count);
return false;
}
}
private void ReleaseOne()
{
var inFlight = Interlocked.Decrement(ref _inFlight);
@@ -166,9 +190,20 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
_pendingReductions--;
return;
}
if (_pendingIncrements > 0)
{
if (!TryReleaseSemaphore())
{
return;
}
_pendingIncrements--;
return;
}
}
_semaphore.Release();
TryReleaseSemaphore();
}
protected override void Dispose(bool disposing)

View File

@@ -78,23 +78,26 @@ public class PlayerPerformanceService
string warningText = string.Empty;
if (exceedsTris && !exceedsVram)
{
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured triangle warning threshold (" +
$"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles).";
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured triangle warning threshold\n" +
$"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
}
else if (!exceedsTris)
{
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold (" +
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB).";
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold\n" +
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB";
}
else
{
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds both VRAM warning threshold (" +
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB) and " +
$"triangle warning threshold ({triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles).";
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds both VRAM warning threshold and triangle warning threshold\n" +
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB and {triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
}
_mediator.Publish(new NotificationMessage($"{pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds performance threshold(s)",
warningText, LightlessConfiguration.Models.NotificationType.Warning));
_mediator.Publish(new PerformanceNotificationMessage(
$"{pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds performance threshold(s)",
warningText,
pairHandler.Pair.UserData,
pairHandler.Pair.IsPaused,
pairHandler.Pair.PlayerName));
}
return true;
@@ -138,11 +141,15 @@ public class PlayerPerformanceService
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000,
triUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
{
_mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
$"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold (" +
$"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)" +
$" and has been automatically paused.",
LightlessConfiguration.Models.NotificationType.Warning));
var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold and has been automatically paused\n" +
$"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles";
_mediator.Publish(new PerformanceNotificationMessage(
$"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
message,
pair.UserData,
true,
pair.PlayerName));
_mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
$"Exceeds triangle threshold: automatically paused ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)")));
@@ -214,11 +221,15 @@ public class PlayerPerformanceService
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024 * 1024,
vramUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
{
_mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
$"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold (" +
$"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB)" +
$" and has been automatically paused.",
LightlessConfiguration.Models.NotificationType.Warning));
var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" +
$"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB";
_mediator.Publish(new PerformanceNotificationMessage(
$"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
message,
pair.UserData,
true,
pair.PlayerName));
_mediator.Publish(new PauseMessage(pair.UserData));

View File

@@ -24,6 +24,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
private readonly NotificationService _notificationService;
private bool _notificationDismissed = true;
private int _lastDownloadStateHash = 0;
public DownloadUi(ILogger<DownloadUi> logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService,
PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared,
@@ -65,7 +66,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
_currentDownloads.TryRemove(msg.DownloadId, out _);
if (!_currentDownloads.Any())
{
_notificationService.DismissPairDownloadNotification();
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
}
});
Mediator.Subscribe<GposeStartMessage>(this, (_) => IsOpen = false);
@@ -116,7 +117,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
}
catch
{
// ignore errors thrown from UI
_logger.LogDebug("Error drawing upload progress");
}
try
@@ -131,17 +132,19 @@ public class DownloadUi : WindowMediatorSubscriberBase
// Use notification system
if (_currentDownloads.Any())
{
UpdateDownloadNotification(limiterSnapshot);
UpdateDownloadNotificationIfChanged(limiterSnapshot);
_notificationDismissed = false;
}
else if (!_notificationDismissed)
{
_notificationService.DismissPairDownloadNotification();
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
_notificationDismissed = true;
_lastDownloadStateHash = 0;
}
}
else
{
// Use text overlay
if (limiterSnapshot.IsEnabled)
{
var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey;
@@ -183,7 +186,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
}
catch
{
// ignore errors thrown from UI
_logger.LogDebug("Error drawing download progress");
}
}
@@ -255,7 +258,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
}
catch
{
// ignore errors thrown on UI
_logger.LogDebug("Error drawing upload progress");
}
}
}
@@ -298,20 +301,34 @@ public class DownloadUi : WindowMediatorSubscriberBase
};
}
private void UpdateDownloadNotification(PairProcessingLimiterSnapshot limiterSnapshot)
private void UpdateDownloadNotificationIfChanged(PairProcessingLimiterSnapshot limiterSnapshot)
{
var downloadStatus = new List<(string playerName, float progress, string status)>();
var downloadStatus = new List<(string playerName, float progress, string status)>(_currentDownloads.Count);
var hashCode = new HashCode();
foreach (var item in _currentDownloads.ToList())
foreach (var item in _currentDownloads)
{
var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot);
var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue);
var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading);
var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing);
var totalFiles = item.Value.Sum(c => c.Value.TotalFiles);
var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles);
var totalBytes = item.Value.Sum(c => c.Value.TotalBytes);
var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes);
var dlSlot = 0;
var dlQueue = 0;
var dlProg = 0;
var dlDecomp = 0;
long totalBytes = 0;
long transferredBytes = 0;
// Single pass through the dictionary to count everything - avoid multiple LINQ iterations
foreach (var entry in item.Value)
{
var fileStatus = entry.Value;
switch (fileStatus.DownloadStatus)
{
case DownloadStatus.WaitingForSlot: dlSlot++; break;
case DownloadStatus.WaitingForQueue: dlQueue++; break;
case DownloadStatus.Downloading: dlProg++; break;
case DownloadStatus.Decompressing: dlDecomp++; break;
}
totalBytes += fileStatus.TotalBytes;
transferredBytes += fileStatus.TransferredBytes;
}
var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f;
@@ -323,13 +340,27 @@ public class DownloadUi : WindowMediatorSubscriberBase
else status = "completed";
downloadStatus.Add((item.Key.Name, progress, status));
// Build hash from meaningful state
hashCode.Add(item.Key.Name);
hashCode.Add(transferredBytes);
hashCode.Add(totalBytes);
hashCode.Add(status);
}
// Pass queue waiting count separately, show notification if there are downloads or queue items
var queueWaiting = limiterSnapshot.IsEnabled ? limiterSnapshot.Waiting : 0;
if (downloadStatus.Any() || queueWaiting > 0)
hashCode.Add(queueWaiting);
var currentHash = hashCode.ToHashCode();
// Only update notification if state has actually changed
if (currentHash != _lastDownloadStateHash)
{
_notificationService.ShowPairDownloadNotification(downloadStatus, queueWaiting);
_lastDownloadStateHash = currentHash;
if (downloadStatus.Count > 0 || queueWaiting > 0)
{
_notificationService.ShowPairDownloadNotification(downloadStatus, queueWaiting);
}
}
}

View File

@@ -22,10 +22,16 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
private const float WindowPaddingOffset = 6f;
private const float SlideAnimationDistance = 100f;
private const float OutAnimationSpeedMultiplier = 0.7f;
private const float ContentPaddingX = 10f;
private const float ContentPaddingY = 6f;
private const float TitleMessageSpacing = 4f;
private const float ActionButtonSpacing = 8f;
private readonly List<LightlessNotification> _notifications = new();
private readonly object _notificationLock = new();
private readonly LightlessConfigService _configService;
private readonly Dictionary<string, float> _notificationYOffsets = new();
private readonly Dictionary<string, float> _notificationTargetYOffsets = new();
public LightlessNotificationUI(ILogger<LightlessNotificationUI> logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService)
: base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector)
@@ -49,12 +55,11 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
Mediator.Subscribe<LightlessNotificationMessage>(this, HandleNotificationMessage);
Mediator.Subscribe<LightlessNotificationDismissMessage>(this, HandleNotificationDismissMessage);
Mediator.Subscribe<ClearAllNotificationsMessage>(this, HandleClearAllNotifications);
}
private void HandleNotificationMessage(LightlessNotificationMessage message) =>
AddNotification(message.Notification);
private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) =>
RemoveNotification(message.NotificationId);
private void HandleNotificationMessage(LightlessNotificationMessage message) => AddNotification(message.Notification);
private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) => RemoveNotification(message.NotificationId);
private void HandleClearAllNotifications(ClearAllNotificationsMessage message) => ClearAllNotifications();
public void AddNotification(LightlessNotification notification)
{
@@ -96,20 +101,34 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
}
}
public void ClearAllNotifications()
{
lock (_notificationLock)
{
foreach (var notification in _notifications)
{
StartOutAnimation(notification);
}
}
}
private void StartOutAnimation(LightlessNotification notification)
{
notification.IsAnimatingOut = true;
notification.IsAnimatingIn = false;
}
private bool ShouldRemoveNotification(LightlessNotification notification) =>
notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f;
protected override void DrawInternal()
{
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero);
lock (_notificationLock)
{
UpdateNotifications();
if (_notifications.Count == 0)
{
ImGui.PopStyleVar();
@@ -118,33 +137,45 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
}
var viewport = ImGui.GetMainViewport();
// Window auto-resizes based on content (AlwaysAutoResize flag)
Position = CalculateWindowPosition(viewport);
PositionCondition = ImGuiCond.Always;
DrawAllNotifications();
}
ImGui.PopStyleVar();
}
private Vector2 CalculateWindowPosition(ImGuiViewportPtr viewport)
{
var x = viewport.WorkPos.X + viewport.WorkSize.X -
_configService.Current.NotificationWidth -
_configService.Current.NotificationOffsetX -
WindowPaddingOffset;
var y = viewport.WorkPos.Y + _configService.Current.NotificationOffsetY;
return new Vector2(x, y);
var corner = _configService.Current.NotificationCorner;
var offsetX = _configService.Current.NotificationOffsetX;
var width = _configService.Current.NotificationWidth;
float posX = corner == NotificationCorner.Left
? viewport.WorkPos.X + offsetX - WindowPaddingOffset
: viewport.WorkPos.X + viewport.WorkSize.X - width - offsetX - WindowPaddingOffset;
return new Vector2(posX, viewport.WorkPos.Y);
}
private void DrawAllNotifications()
{
var offsetY = _configService.Current.NotificationOffsetY;
var startY = ImGui.GetCursorPosY() + offsetY;
for (int i = 0; i < _notifications.Count; i++)
{
DrawNotification(_notifications[i], i);
var notification = _notifications[i];
if (i < _notifications.Count - 1)
if (_notificationYOffsets.TryGetValue(notification.Id, out var yOffset))
{
ImGui.Dummy(new Vector2(0, _configService.Current.NotificationSpacing));
ImGui.SetCursorPosY(startY + yOffset);
}
DrawNotification(notification, i);
}
}
@@ -174,18 +205,65 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
private void UpdateAnimationsAndRemoveExpired(float deltaTime)
{
UpdateTargetYPositions();
for (int i = _notifications.Count - 1; i >= 0; i--)
{
var notification = _notifications[i];
UpdateNotificationAnimation(notification, deltaTime);
UpdateNotificationYOffset(notification, deltaTime);
if (ShouldRemoveNotification(notification))
{
_notifications.RemoveAt(i);
_notificationYOffsets.Remove(notification.Id);
_notificationTargetYOffsets.Remove(notification.Id);
}
}
}
private void UpdateTargetYPositions()
{
float currentY = 0f;
for (int i = 0; i < _notifications.Count; i++)
{
var notification = _notifications[i];
if (!_notificationTargetYOffsets.ContainsKey(notification.Id))
{
_notificationTargetYOffsets[notification.Id] = currentY;
_notificationYOffsets[notification.Id] = currentY;
}
else
{
_notificationTargetYOffsets[notification.Id] = currentY;
}
currentY += CalculateNotificationHeight(notification) + _configService.Current.NotificationSpacing;
}
}
private void UpdateNotificationYOffset(LightlessNotification notification, float deltaTime)
{
if (!_notificationYOffsets.ContainsKey(notification.Id) || !_notificationTargetYOffsets.ContainsKey(notification.Id))
return;
var current = _notificationYOffsets[notification.Id];
var target = _notificationTargetYOffsets[notification.Id];
var diff = target - current;
if (Math.Abs(diff) < 0.5f)
{
_notificationYOffsets[notification.Id] = target;
}
else
{
var speed = _configService.Current.NotificationSlideSpeed;
_notificationYOffsets[notification.Id] = current + (diff * deltaTime * speed);
}
}
private void UpdateNotificationAnimation(LightlessNotification notification, float deltaTime)
{
if (notification.IsAnimatingIn && notification.AnimationProgress < 1f)
@@ -209,20 +287,24 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
}
}
private bool ShouldRemoveNotification(LightlessNotification notification) =>
notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f;
private Vector2 CalculateSlideOffset(float alpha)
{
var distance = (1f - alpha) * SlideAnimationDistance;
var corner = _configService.Current.NotificationCorner;
return corner == NotificationCorner.Left ? new Vector2(-distance, 0) : new Vector2(distance, 0);
}
private void DrawNotification(LightlessNotification notification, int index)
{
var alpha = notification.AnimationProgress;
if (alpha <= 0f) return;
var slideOffset = (1f - alpha) * SlideAnimationDistance;
var slideOffset = CalculateSlideOffset(alpha);
var originalCursorPos = ImGui.GetCursorPos();
ImGui.SetCursorPosX(originalCursorPos.X + slideOffset);
ImGui.SetCursorPos(originalCursorPos + slideOffset);
var notificationHeight = CalculateNotificationHeight(notification);
var notificationWidth = _configService.Current.NotificationWidth - slideOffset;
var notificationWidth = _configService.Current.NotificationWidth;
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero);
@@ -308,15 +390,28 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
private void DrawAccentBar(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 accentColor)
{
var accentWidth = _configService.Current.NotificationAccentBarWidth;
if (accentWidth > 0f)
if (accentWidth <= 0f) return;
var corner = _configService.Current.NotificationCorner;
Vector2 accentStart, accentEnd;
if (corner == NotificationCorner.Left)
{
drawList.AddRectFilled(
windowPos,
windowPos + new Vector2(accentWidth, windowSize.Y),
ImGui.ColorConvertFloat4ToU32(accentColor),
3f
);
accentStart = windowPos + new Vector2(windowSize.X - accentWidth, 0);
accentEnd = windowPos + windowSize;
}
else
{
accentStart = windowPos;
accentEnd = windowPos + new Vector2(accentWidth, windowSize.Y);
}
drawList.AddRectFilled(
accentStart,
accentEnd,
ImGui.ColorConvertFloat4ToU32(accentColor),
3f
);
}
private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList)
@@ -371,81 +466,112 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
private void DrawNotificationText(LightlessNotification notification, float alpha)
{
var padding = new Vector2(10f, 6f);
var contentPos = new Vector2(padding.X, padding.Y);
var contentPos = new Vector2(ContentPaddingX, ContentPaddingY);
var windowSize = ImGui.GetWindowSize();
var contentSize = new Vector2(windowSize.X - padding.X, windowSize.Y - padding.Y * 2);
var contentWidth = CalculateContentWidth(windowSize.X);
ImGui.SetCursorPos(contentPos);
var titleHeight = DrawTitle(notification, contentSize.X, alpha);
DrawMessage(notification, contentPos, contentSize.X, titleHeight, alpha);
var titleHeight = DrawTitle(notification, contentWidth, alpha);
DrawMessage(notification, contentPos, contentWidth, titleHeight, alpha);
if (notification.Actions.Count > 0)
if (HasActions(notification))
{
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetStyle().ItemSpacing.Y);
ImGui.SetCursorPosX(contentPos.X);
DrawNotificationActions(notification, contentSize.X, alpha);
PositionActionsAtBottom(windowSize.Y);
DrawNotificationActions(notification, contentWidth, alpha);
}
}
private float CalculateContentWidth(float windowWidth) =>
windowWidth - (ContentPaddingX * 2);
private bool HasActions(LightlessNotification notification) =>
notification.Actions.Count > 0;
private void PositionActionsAtBottom(float windowHeight)
{
var actionHeight = ImGui.GetFrameHeight();
var bottomY = windowHeight - ContentPaddingY - actionHeight;
ImGui.SetCursorPosY(bottomY);
ImGui.SetCursorPosX(ContentPaddingX);
}
private float DrawTitle(LightlessNotification notification, float contentWidth, float alpha)
{
using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, alpha)))
var titleColor = new Vector4(1f, 1f, 1f, alpha);
var titleText = FormatTitleText(notification);
using (ImRaii.PushColor(ImGuiCol.Text, titleColor))
{
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentWidth);
var titleStartY = ImGui.GetCursorPosY();
var titleText = _configService.Current.ShowNotificationTimestamp
? $"[{notification.CreatedAt.ToLocalTime():HH:mm:ss}] {notification.Title}"
: notification.Title;
ImGui.TextWrapped(titleText);
var titleHeight = ImGui.GetCursorPosY() - titleStartY;
ImGui.PopTextWrapPos();
return titleHeight;
return DrawWrappedText(titleText, contentWidth);
}
}
private string FormatTitleText(LightlessNotification notification)
{
if (!_configService.Current.ShowNotificationTimestamp)
return notification.Title;
var timestamp = notification.CreatedAt.ToLocalTime().ToString("HH:mm:ss");
return $"[{timestamp}] {notification.Title}";
}
private float DrawWrappedText(string text, float wrapWidth)
{
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + wrapWidth);
var startY = ImGui.GetCursorPosY();
ImGui.TextWrapped(text);
var height = ImGui.GetCursorPosY() - startY;
ImGui.PopTextWrapPos();
return height;
}
private void DrawMessage(LightlessNotification notification, Vector2 contentPos, float contentWidth, float titleHeight, float alpha)
{
if (string.IsNullOrEmpty(notification.Message)) return;
ImGui.SetCursorPos(contentPos + new Vector2(0f, titleHeight + 4f));
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentWidth);
using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, alpha)))
var messagePos = contentPos + new Vector2(0f, titleHeight + TitleMessageSpacing);
var messageColor = new Vector4(0.9f, 0.9f, 0.9f, alpha);
ImGui.SetCursorPos(messagePos);
using (ImRaii.PushColor(ImGuiCol.Text, messageColor))
{
ImGui.TextWrapped(notification.Message);
DrawWrappedText(notification.Message, contentWidth);
}
ImGui.PopTextWrapPos();
}
private void DrawNotificationActions(LightlessNotification notification, float availableWidth, float alpha)
{
var buttonSpacing = 8f;
var rightPadding = 10f;
var usableWidth = availableWidth - rightPadding;
var totalSpacing = (notification.Actions.Count - 1) * buttonSpacing;
var buttonWidth = (usableWidth - totalSpacing) / notification.Actions.Count;
var buttonWidth = CalculateActionButtonWidth(notification.Actions.Count, availableWidth);
_logger.LogDebug("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}",
notification.Actions.Count, buttonWidth, availableWidth);
var startCursorPos = ImGui.GetCursorPos();
var startX = ImGui.GetCursorPosX();
for (int i = 0; i < notification.Actions.Count; i++)
{
var action = notification.Actions[i];
if (i > 0)
{
ImGui.SameLine();
var currentX = startCursorPos.X + i * (buttonWidth + buttonSpacing);
ImGui.SetCursorPosX(currentX);
PositionActionButton(i, startX, buttonWidth);
}
DrawActionButton(action, notification, alpha, buttonWidth);
DrawActionButton(notification.Actions[i], notification, alpha, buttonWidth);
}
}
private float CalculateActionButtonWidth(int actionCount, float availableWidth)
{
var totalSpacing = (actionCount - 1) * ActionButtonSpacing;
return (availableWidth - totalSpacing) / actionCount;
}
private void PositionActionButton(int index, float startX, float buttonWidth)
{
var xPosition = startX + index * (buttonWidth + ActionButtonSpacing);
ImGui.SetCursorPosX(xPosition);
}
private void DrawActionButton(LightlessNotificationAction action, LightlessNotification notification, float alpha, float buttonWidth)
{
@@ -543,7 +669,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
private float CalculateNotificationHeight(LightlessNotification notification)
{
var contentWidth = _configService.Current.NotificationWidth - 35f;
var contentWidth = CalculateContentWidth(_configService.Current.NotificationWidth);
var height = 12f;
height += CalculateTitleHeight(notification, contentWidth);
@@ -590,6 +716,8 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase
NotificationType.Error => UIColors.Get("DimRed"),
NotificationType.PairRequest => UIColors.Get("LightlessBlue"),
NotificationType.Download => UIColors.Get("LightlessGreen"),
NotificationType.Performance => UIColors.Get("LightlessOrange"),
_ => UIColors.Get("LightlessPurple")
};
}

View File

@@ -591,6 +591,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
bool limitPairApplications = _configService.Current.EnablePairProcessingLimiter;
bool useAlternativeUpload = _configService.Current.UseAlternativeFileUpload;
int downloadSpeedLimit = _configService.Current.DownloadSpeedLimitInBytes;
bool enableDirectDownloads = _configService.Current.EnableDirectDownloads;
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Global Download Speed Limit");
@@ -622,6 +623,13 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("0 = No limit/infinite");
if (ImGui.Checkbox("[BETA] Enable Lightspeed Downloads", ref enableDirectDownloads))
{
_configService.Current.EnableDirectDownloads = enableDirectDownloads;
_configService.Save();
}
_uiShared.DrawHelpText("Uses signed CDN links when available. Disable to force the legacy queued download flow.");
if (ImGui.SliderInt("Maximum Parallel Downloads", ref maxParallelDownloads, 1, 10))
{
_configService.Current.ParallelDownloads = maxParallelDownloads;
@@ -1990,7 +1998,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"),
("LightlessGreen", "Success Green", "Join buttons and success messages"),
("LightlessYellow", "Warning Yellow", "Warning colors"),
("LightlessYellow2", "Warning Yellow (Alt)", "Warning colors"),
("LightlessOrange", "Performance Orange", "Performance notifications and warnings"),
("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"),
("DimRed", "Error Red", "Error and offline colors")
};
@@ -3493,69 +3501,192 @@ public class SettingsUi : WindowMediatorSubscriberBase
if (useLightlessNotifications)
{
// Lightless notification locations
ImGui.Indent();
var lightlessLocations = GetLightlessNotificationLocations();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Info Notifications:");
ImGui.SameLine();
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
_uiShared.DrawCombo("###enhanced_info", lightlessLocations, GetNotificationLocationLabel, (location) =>
{
_configService.Current.LightlessInfoNotification = location;
_configService.Save();
}, _configService.Current.LightlessInfoNotification);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Warning Notifications:");
ImGui.SameLine();
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
_uiShared.DrawCombo("###enhanced_warning", lightlessLocations, GetNotificationLocationLabel,
(location) =>
{
_configService.Current.LightlessWarningNotification = location;
_configService.Save();
}, _configService.Current.LightlessWarningNotification);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Error Notifications:");
ImGui.SameLine();
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
_uiShared.DrawCombo("###enhanced_error", lightlessLocations, GetNotificationLocationLabel, (location) =>
{
_configService.Current.LightlessErrorNotification = location;
_configService.Save();
}, _configService.Current.LightlessErrorNotification);
ImGuiHelpers.ScaledDummy(3);
_uiShared.DrawHelpText("Special notification types:");
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Pair Request Notifications:");
ImGui.SameLine();
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
_uiShared.DrawCombo("###enhanced_pairrequest", lightlessLocations, GetNotificationLocationLabel,
(location) =>
{
_configService.Current.LightlessPairRequestNotification = location;
_configService.Save();
}, _configService.Current.LightlessPairRequestNotification);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Download Progress Notifications:");
ImGui.SameLine();
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
var downloadLocations = GetDownloadNotificationLocations();
_uiShared.DrawCombo("###enhanced_download", downloadLocations, GetNotificationLocationLabel,
(location) =>
if (ImGui.BeginTable("##NotificationLocationTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit))
{
ImGui.TableSetupColumn("Notification Type", ImGuiTableColumnFlags.WidthFixed, 200f * ImGuiHelpers.GlobalScale);
ImGui.TableSetupColumn("Location", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableSetupColumn("Test", ImGuiTableColumnFlags.WidthFixed, 40f * ImGuiHelpers.GlobalScale);
ImGui.TableHeadersRow();
ImGui.TableNextRow();
ImGui.TableSetColumnIndex(0);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Info Notifications");
ImGui.TableSetColumnIndex(1);
ImGui.SetNextItemWidth(-1);
_uiShared.DrawCombo("###enhanced_info", lightlessLocations, GetNotificationLocationLabel, (location) =>
{
_configService.Current.LightlessDownloadNotification = location;
_configService.Current.LightlessInfoNotification = location;
_configService.Save();
}, _configService.Current.LightlessDownloadNotification);
}, _configService.Current.LightlessInfoNotification);
ImGui.TableSetColumnIndex(2);
var availableWidth = ImGui.GetContentRegionAvail().X;
using (ImRaii.PushFont(UiBuilder.IconFont))
{
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_info", new Vector2(availableWidth, 0)))
{
Mediator.Publish(new NotificationMessage("Test Info",
"This is a test info notification to let you know Chocola is cute :3", NotificationType.Info));
}
}
UiSharedService.AttachToolTip("Test info notification");
ImGui.TableNextRow();
ImGui.TableSetColumnIndex(0);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Warning Notifications");
ImGui.TableSetColumnIndex(1);
ImGui.SetNextItemWidth(-1);
_uiShared.DrawCombo("###enhanced_warning", lightlessLocations, GetNotificationLocationLabel,
(location) =>
{
_configService.Current.LightlessWarningNotification = location;
_configService.Save();
}, _configService.Current.LightlessWarningNotification);
ImGui.TableSetColumnIndex(2);
availableWidth = ImGui.GetContentRegionAvail().X;
using (ImRaii.PushFont(UiBuilder.IconFont))
{
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_warning", new Vector2(availableWidth, 0)))
{
Mediator.Publish(new NotificationMessage("Test Warning", "This is a test warning notification!",
NotificationType.Warning));
}
}
UiSharedService.AttachToolTip("Test warning notification");
ImGui.Unindent();
ImGui.TableNextRow();
ImGui.TableSetColumnIndex(0);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Error Notifications");
ImGui.TableSetColumnIndex(1);
ImGui.SetNextItemWidth(-1);
_uiShared.DrawCombo("###enhanced_error", lightlessLocations, GetNotificationLocationLabel, (location) =>
{
_configService.Current.LightlessErrorNotification = location;
_configService.Save();
}, _configService.Current.LightlessErrorNotification);
ImGui.TableSetColumnIndex(2);
availableWidth = ImGui.GetContentRegionAvail().X;
using (ImRaii.PushFont(UiBuilder.IconFont))
{
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_error", new Vector2(availableWidth, 0)))
{
Mediator.Publish(new NotificationMessage("Test Error", "This is a test error notification!",
NotificationType.Error));
}
}
UiSharedService.AttachToolTip("Test error notification");
ImGui.TableNextRow();
ImGui.TableSetColumnIndex(0);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Pair Request Notifications");
ImGui.TableSetColumnIndex(1);
ImGui.SetNextItemWidth(-1);
_uiShared.DrawCombo("###enhanced_pairrequest", lightlessLocations, GetNotificationLocationLabel,
(location) =>
{
_configService.Current.LightlessPairRequestNotification = location;
_configService.Save();
}, _configService.Current.LightlessPairRequestNotification);
ImGui.TableSetColumnIndex(2);
availableWidth = ImGui.GetContentRegionAvail().X;
using (ImRaii.PushFont(UiBuilder.IconFont))
{
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_pair", new Vector2(availableWidth, 0)))
{
_lightlessNotificationService.ShowPairRequestNotification(
"Test User",
"test-uid-123",
() =>
{
Mediator.Publish(new NotificationMessage("Accepted", "You accepted the test pair request.",
NotificationType.Info));
},
() =>
{
Mediator.Publish(new NotificationMessage("Declined", "You declined the test pair request.",
NotificationType.Info));
}
);
}
}
UiSharedService.AttachToolTip("Test pair request notification");
ImGui.TableNextRow();
ImGui.TableSetColumnIndex(0);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Download Progress Notifications");
ImGui.TableSetColumnIndex(1);
ImGui.SetNextItemWidth(-1);
_uiShared.DrawCombo("###enhanced_download", downloadLocations, GetNotificationLocationLabel,
(location) =>
{
_configService.Current.LightlessDownloadNotification = location;
_configService.Save();
}, _configService.Current.LightlessDownloadNotification);
ImGui.TableSetColumnIndex(2);
availableWidth = ImGui.GetContentRegionAvail().X;
using (ImRaii.PushFont(UiBuilder.IconFont))
{
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_download", new Vector2(availableWidth, 0)))
{
_lightlessNotificationService.ShowPairDownloadNotification(
new List<(string playerName, float progress, string status)>
{
("Player One", 0.35f, "downloading"),
("Player Two", 0.75f, "downloading"),
("Player Three", 1.0f, "downloading")
},
queueWaiting: 2
);
}
}
UiSharedService.AttachToolTip("Test download progress notification");
ImGui.TableNextRow();
ImGui.TableSetColumnIndex(0);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Performance Notifications");
ImGui.TableSetColumnIndex(1);
ImGui.SetNextItemWidth(-1);
_uiShared.DrawCombo("###enhanced_performance", lightlessLocations, GetNotificationLocationLabel,
(location) =>
{
_configService.Current.LightlessPerformanceNotification = location;
_configService.Save();
}, _configService.Current.LightlessPerformanceNotification);
ImGui.TableSetColumnIndex(2);
availableWidth = ImGui.GetContentRegionAvail().X;
using (ImRaii.PushFont(UiBuilder.IconFont))
{
if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_performance", new Vector2(availableWidth, 0)))
{
var testUserData = new UserData("TEST123", "TestUser", false, false, false, null, null);
Mediator.Publish(new PerformanceNotificationMessage(
"Test Player (TestUser) exceeds performance threshold(s)",
"Player Test Player (TestUser) exceeds your configured VRAM warning threshold\n500 MB/300 MB",
testUserData,
false,
"Test Player"
));
}
}
UiSharedService.AttachToolTip("Test performance notification");
ImGui.EndTable();
}
ImGuiHelpers.ScaledDummy(5);
if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Clear All Notifications"))
{
Mediator.Publish(new ClearAllNotificationsMessage());
}
_uiShared.DrawHelpText("Dismiss all active notifications immediately.");
}
else
{
@@ -3602,73 +3733,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Separator();
if (useLightlessNotifications)
{
if (_uiShared.MediumTreeNode("Test Notifications", UIColors.Get("LightlessPurple")))
{
ImGui.Indent();
// Test notification buttons
if (_uiShared.IconTextButton(FontAwesomeIcon.Bell, "Test Info"))
{
Mediator.Publish(new NotificationMessage("Test Info",
"This is a test info notification to let you know Chocola is cute :3", NotificationType.Info));
}
ImGui.SameLine();
if (_uiShared.IconTextButton(FontAwesomeIcon.ExclamationTriangle, "Test Warning"))
{
Mediator.Publish(new NotificationMessage("Test Warning", "This is a test warning notification!",
NotificationType.Warning));
}
ImGui.SameLine();
if (_uiShared.IconTextButton(FontAwesomeIcon.ExclamationCircle, "Test Error"))
{
Mediator.Publish(new NotificationMessage("Test Error", "This is a test error notification!",
NotificationType.Error));
}
ImGuiHelpers.ScaledDummy(3);
if (_uiShared.IconTextButton(FontAwesomeIcon.UserPlus, "Test Pair Request"))
{
_lightlessNotificationService.ShowPairRequestNotification(
"Test User",
"test-uid-123",
() =>
{
Mediator.Publish(new NotificationMessage("Accepted", "You accepted the test pair request.",
NotificationType.Info));
},
() =>
{
Mediator.Publish(new NotificationMessage("Declined", "You declined the test pair request.",
NotificationType.Info));
}
);
}
ImGui.SameLine();
if (_uiShared.IconTextButton(FontAwesomeIcon.Download, "Test Download Progress"))
{
_lightlessNotificationService.ShowPairDownloadNotification(
new List<(string playerName, float progress, string status)>
{
("Player One", 0.35f, "downloading"),
("Player Two", 0.75f, "downloading"),
("Player Three", 1.0f, "downloading")
},
queueWaiting: 2
);
}
_uiShared.DrawHelpText("Preview how notifications will appear with your current settings.");
ImGui.Unindent();
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
ImGui.TreePop();
}
ImGui.Separator();
if (_uiShared.MediumTreeNode("Basic Settings", UIColors.Get("LightlessPurple")))
{
int maxNotifications = _configService.Current.MaxSimultaneousNotifications;
@@ -3768,10 +3832,28 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Spacing();
ImGui.TextUnformatted("Position");
int offsetY = _configService.Current.NotificationOffsetY;
if (ImGui.SliderInt("Vertical Offset", ref offsetY, 0, 500))
var currentCorner = _configService.Current.NotificationCorner;
if (ImGui.BeginCombo("Notification Position", GetNotificationCornerLabel(currentCorner)))
{
_configService.Current.NotificationOffsetY = Math.Clamp(offsetY, 0, 500);
foreach (NotificationCorner corner in Enum.GetValues(typeof(NotificationCorner)))
{
bool isSelected = currentCorner == corner;
if (ImGui.Selectable(GetNotificationCornerLabel(corner), isSelected))
{
_configService.Current.NotificationCorner = corner;
_configService.Save();
}
if (isSelected)
ImGui.SetItemDefaultFocus();
}
ImGui.EndCombo();
}
_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))
{
_configService.Current.NotificationOffsetY = Math.Clamp(offsetY, 0, 1000);
_configService.Save();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
@@ -3781,7 +3863,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Right click to reset to default (50).");
_uiShared.DrawHelpText("Move notifications down from the top-right corner.");
_uiShared.DrawHelpText("Distance from the top edge of the screen.");
int offsetX = _configService.Current.NotificationOffsetX;
if (ImGui.SliderInt("Horizontal Offset", ref offsetX, 0, 500))
@@ -3802,9 +3884,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TextUnformatted("Animation Settings");
float animSpeed = _configService.Current.NotificationAnimationSpeed;
if (ImGui.SliderFloat("Animation Speed", ref animSpeed, 1f, 30f, "%.1f"))
if (ImGui.SliderFloat("Animation Speed", ref animSpeed, 1f, 20f, "%.1f"))
{
_configService.Current.NotificationAnimationSpeed = Math.Clamp(animSpeed, 1f, 30f);
_configService.Current.NotificationAnimationSpeed = Math.Clamp(animSpeed, 1f, 20f);
_configService.Save();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
@@ -3816,6 +3898,21 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.SetTooltip("Right click to reset to default (10).");
_uiShared.DrawHelpText("How fast notifications slide in/out. Higher = faster.");
float slideSpeed = _configService.Current.NotificationSlideSpeed;
if (ImGui.SliderFloat("Slide Speed", ref slideSpeed, 1f, 20f, "%.1f"))
{
_configService.Current.NotificationSlideSpeed = Math.Clamp(slideSpeed, 1f, 20f);
_configService.Save();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
{
_configService.Current.NotificationSlideSpeed = 10f;
_configService.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Right click to reset to default (10).");
_uiShared.DrawHelpText("How fast notifications slide into position when others disappear. Higher = faster.");
ImGui.Spacing();
ImGui.TextUnformatted("Visual Effects");
@@ -3915,6 +4012,20 @@ public class SettingsUi : WindowMediatorSubscriberBase
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Right click to reset to default (300).");
int performanceDuration = _configService.Current.PerformanceNotificationDurationSeconds;
if (ImGui.SliderInt("Performance Duration (seconds)", ref performanceDuration, 5, 60))
{
_configService.Current.PerformanceNotificationDurationSeconds = Math.Clamp(performanceDuration, 5, 60);
_configService.Save();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
{
_configService.Current.PerformanceNotificationDurationSeconds = 20;
_configService.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Right click to reset to default (20).");
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
ImGui.TreePop();
}
@@ -3982,6 +4093,38 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TreePop();
}
if (_uiShared.MediumTreeNode("Pair Request Notifications", UIColors.Get("PairBlue")))
{
var showPairRequestActions = _configService.Current.ShowPairRequestNotificationActions;
if (ImGui.Checkbox("Show action buttons on pair requests", ref showPairRequestActions))
{
_configService.Current.ShowPairRequestNotificationActions = showPairRequestActions;
_configService.Save();
}
_uiShared.DrawHelpText(
"When you receive a pair request, show Accept/Decline buttons in the notification.");
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
ImGui.TreePop();
}
if (_uiShared.MediumTreeNode("Performance Notifications", UIColors.Get("LightlessOrange")))
{
var showPerformanceActions = _configService.Current.ShowPerformanceNotificationActions;
if (ImGui.Checkbox("Show action buttons on performance warnings", ref showPerformanceActions))
{
_configService.Current.ShowPerformanceNotificationActions = showPerformanceActions;
_configService.Save();
}
_uiShared.DrawHelpText(
"When a player exceeds performance thresholds or is auto-paused, show Pause/Unpause buttons in the notification.");
_uiShared.ColoredSeparator(UIColors.Get("LightlessOrange"), 1.5f);
ImGui.TreePop();
}
if (_uiShared.MediumTreeNode("System Notifications", UIColors.Get("LightlessYellow")))
{
var disableOptionalPluginWarnings = _configService.Current.DisableOptionalPluginWarnings;
@@ -3999,6 +4142,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Separator();
// Location descriptions removed - information is now inline with each setting
}
}
@@ -4014,8 +4158,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
{
return new[]
{
NotificationLocation.LightlessUi, NotificationLocation.ChatAndLightlessUi,
NotificationLocation.TextOverlay, NotificationLocation.Nowhere
NotificationLocation.LightlessUi, NotificationLocation.TextOverlay, NotificationLocation.Nowhere
};
}
@@ -4043,6 +4186,16 @@ public class SettingsUi : WindowMediatorSubscriberBase
};
}
private string GetNotificationCornerLabel(NotificationCorner corner)
{
return corner switch
{
NotificationCorner.Right => "Right",
NotificationCorner.Left => "Left",
_ => corner.ToString()
};
}
private void DrawSoundTable()
{
var soundEffects = new[]
@@ -4068,7 +4221,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
("Warning", 1, _configService.Current.CustomWarningSoundId, _configService.Current.DisableWarningSound, 16u),
("Error", 2, _configService.Current.CustomErrorSoundId, _configService.Current.DisableErrorSound, 16u),
("Pair Request", 3, _configService.Current.PairRequestSoundId, _configService.Current.DisablePairRequestSound, 5u),
("Download", 4, _configService.Current.DownloadSoundId, _configService.Current.DisableDownloadSound, 15u)
("Performance", 4, _configService.Current.PerformanceSoundId, _configService.Current.DisablePerformanceSound, 16u)
};
foreach (var (typeName, typeIndex, currentSoundId, isDisabled, defaultSoundId) in soundTypes)
@@ -4087,7 +4240,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
var currentIndex = Array.FindIndex(soundEffects, s => s.Item1 == currentSoundId);
if (currentIndex == -1) currentIndex = 1;
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
ImGui.SetNextItemWidth(-1);
if (ImGui.Combo($"##sound_{typeIndex}", ref currentIndex,
soundEffects.Select(s => s.Item2).ToArray(), soundEffects.Length))
{
@@ -4098,7 +4251,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
case 1: _configService.Current.CustomWarningSoundId = newSoundId; break;
case 2: _configService.Current.CustomErrorSoundId = newSoundId; break;
case 3: _configService.Current.PairRequestSoundId = newSoundId; break;
case 4: _configService.Current.DownloadSoundId = newSoundId; break;
case 4: _configService.Current.PerformanceSoundId = newSoundId; break;
}
_configService.Save();
@@ -4152,7 +4305,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
case 1: _configService.Current.DisableWarningSound = newDisabled; break;
case 2: _configService.Current.DisableErrorSound = newDisabled; break;
case 3: _configService.Current.DisablePairRequestSound = newDisabled; break;
case 4: _configService.Current.DisableDownloadSound = newDisabled; break;
case 4: _configService.Current.DisablePerformanceSound = newDisabled; break;
}
_configService.Save();
}
@@ -4178,7 +4331,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
case 1: _configService.Current.CustomWarningSoundId = defaultSoundId; break;
case 2: _configService.Current.CustomErrorSoundId = defaultSoundId; break;
case 3: _configService.Current.PairRequestSoundId = defaultSoundId; break;
case 4: _configService.Current.DownloadSoundId = defaultSoundId; break;
case 4: _configService.Current.PerformanceSoundId = defaultSoundId; break;
}
_configService.Save();
}

View File

@@ -88,7 +88,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
ImGuiHelpers.ScaledDummy(0.5f);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f);
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessYellow2"));
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("PairBlue"));
if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0)))
{
@@ -288,8 +288,6 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
return;
}
var currentGids = _nearbySyncshells.Select(s => s.Group.GID).ToHashSet(StringComparer.Ordinal);
if (updatedList != null)
{
var previousGid = GetSelectedGid();

View File

@@ -196,82 +196,6 @@ public class TopTabMenu
if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f);
#if DEBUG
if (ImGui.Button("Test Pair Request"))
{
_lightlessNotificationService.ShowPairRequestNotification(
"Debug User",
"debug-user-id",
onAccept: () =>
{
_lightlessMediator.Publish(new NotificationMessage(
"Pair Accepted",
"Debug pair request was accepted!",
NotificationType.Info,
TimeSpan.FromSeconds(3)));
},
onDecline: () =>
{
_lightlessMediator.Publish(new NotificationMessage(
"Pair Declined",
"Debug pair request was declined.",
NotificationType.Warning,
TimeSpan.FromSeconds(3)));
}
);
}
ImGui.SameLine();
if (ImGui.Button("Test Info"))
{
_lightlessMediator.Publish(new NotificationMessage(
"Information",
"This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps.",
NotificationType.Info,
TimeSpan.FromSeconds(5)));
}
ImGui.SameLine();
if (ImGui.Button("Test Warning"))
{
_lightlessMediator.Publish(new NotificationMessage(
"Warning",
"This is a test warning notification.",
NotificationType.Warning,
TimeSpan.FromSeconds(7)));
}
ImGui.SameLine();
if (ImGui.Button("Test Error"))
{
_lightlessMediator.Publish(new NotificationMessage(
"Error",
"This is a test error notification erp police",
NotificationType.Error,
TimeSpan.FromSeconds(10)));
}
if (ImGui.Button("Test Download Progress"))
{
var downloadStatus = new List<(string playerName, float progress, string status)>
{
("Mauwmauw Nekochan", 0.85f, "downloading"),
("Raelynn Kitsune", 0.34f, "downloading"),
("Jaina Elraeth", 0.67f, "downloading"),
("Vaelstra Bloodthorn", 0.19f, "downloading"),
("Lydia Hera Moondrop", 0.86f, "downloading"),
("C'liina Star", 1.0f, "completed")
};
_lightlessNotificationService.ShowPairDownloadNotification(downloadStatus);
}
ImGui.SameLine();
if (ImGui.Button("Dismiss Download"))
{
_lightlessNotificationService.DismissPairDownloadNotification();
}
#endif
DrawIncomingPairRequests(availableWidth);
ImGui.Separator();

View File

@@ -11,21 +11,17 @@ namespace LightlessSync.UI
{ "LightlessPurple", "#ad8af5" },
{ "LightlessPurpleActive", "#be9eff" },
{ "LightlessPurpleDefault", "#9375d1" },
{ "ButtonDefault", "#323232" },
{ "FullBlack", "#000000" },
{ "LightlessBlue", "#a6c2ff" },
{ "LightlessYellow", "#ffe97a" },
{ "LightlessYellow2", "#cfbd63" },
{ "LightlessGreen", "#7cd68a" },
{ "LightlessOrange", "#ffb366" },
{ "PairBlue", "#88a2db" },
{ "DimRed", "#d44444" },
{ "LightlessAdminText", "#ffd663" },
{ "LightlessAdminGlow", "#b09343" },
{ "LightlessModeratorText", "#94ffda" },
{ "LightlessModeratorGlow", "#599c84" },
{ "Lightfinder", "#ad8af5" },
{ "LightfinderEdge", "#000000" },

View File

@@ -1,4 +1,7 @@
using System.Security.Cryptography;
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace LightlessSync.Utils;
@@ -13,8 +16,9 @@ public static class Crypto
public static string GetFileHash(this string filePath)
{
using SHA1CryptoServiceProvider cryptoProvider = new();
return BitConverter.ToString(cryptoProvider.ComputeHash(File.ReadAllBytes(filePath))).Replace("-", "", StringComparison.Ordinal);
using SHA1 sha1 = SHA1.Create();
using FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return BitConverter.ToString(sha1.ComputeHash(stream)).Replace("-", "", StringComparison.Ordinal);
}
public static string GetHash256(this (string, ushort) playerToHash)

View File

@@ -5,12 +5,18 @@ using LightlessSync.API.Dto.Files;
using LightlessSync.API.Routes;
using LightlessSync.FileCache;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Net;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using LightlessSync.LightlessConfiguration;
namespace LightlessSync.WebAPI.Files;
@@ -20,17 +26,27 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private readonly FileCompactor _fileCompactor;
private readonly FileCacheManager _fileDbManager;
private readonly FileTransferOrchestrator _orchestrator;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly LightlessConfigService _configService;
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
private static readonly TimeSpan DownloadStallTimeout = TimeSpan.FromSeconds(30);
private volatile bool _disableDirectDownloads;
private int _consecutiveDirectDownloadFailures;
private bool _lastConfigDirectDownloadsState;
public FileDownloadManager(ILogger<FileDownloadManager> logger, LightlessMediator mediator,
FileTransferOrchestrator orchestrator,
FileCacheManager fileCacheManager, FileCompactor fileCompactor) : base(logger, mediator)
FileCacheManager fileCacheManager, FileCompactor fileCompactor,
PairProcessingLimiter pairProcessingLimiter, LightlessConfigService configService) : base(logger, mediator)
{
_downloadStatus = new Dictionary<string, FileDownloadStatus>(StringComparer.Ordinal);
_orchestrator = orchestrator;
_fileDbManager = fileCacheManager;
_fileCompactor = fileCompactor;
_pairProcessingLimiter = pairProcessingLimiter;
_configService = configService;
_activeDownloadStreams = new();
_lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads;
Mediator.Subscribe<DownloadLimitChangedMessage>(this, (msg) =>
{
@@ -50,6 +66,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
public bool IsDownloading => CurrentDownloads.Any();
private bool ShouldUseDirectDownloads()
{
return _configService.Current.EnableDirectDownloads && !_disableDirectDownloads;
}
public static void MungeBuffer(Span<byte> buffer)
{
for (int i = 0; i < buffer.Length; ++i)
@@ -156,39 +177,47 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
Logger.LogWarning("Download status missing for {group} when starting download", downloadGroup);
}
var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId);
await DownloadFileThrottled(requestUrl, tempPath, progress, MungeBuffer, ct, withToken: true).ConfigureAwait(false);
}
private delegate void DownloadDataCallback(Span<byte> data);
private async Task DownloadFileThrottled(Uri requestUrl, string destinationFilename, IProgress<long> progress, DownloadDataCallback? callback, CancellationToken ct, bool withToken)
{
const int maxRetries = 3;
int retryCount = 0;
TimeSpan retryDelay = TimeSpan.FromSeconds(2);
HttpResponseMessage response = null!;
var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId);
HttpResponseMessage? response = null;
while (true)
{
try
{
Logger.LogDebug("Attempt {attempt} - Downloading {requestUrl} for request {id}", retryCount + 1, requestUrl, requestId);
response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
Logger.LogDebug("Attempt {attempt} - Downloading {requestUrl}", retryCount + 1, requestUrl);
response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead, withToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
break;
}
catch (HttpRequestException ex) when (ex.InnerException is TimeoutException || ex.StatusCode == null)
{
response?.Dispose();
retryCount++;
Logger.LogWarning(ex, "Timeout during download of {requestUrl}. Attempt {attempt} of {maxRetries}", requestUrl, retryCount, maxRetries);
if (retryCount >= maxRetries || ct.IsCancellationRequested)
{
Logger.LogError($"Max retries reached or cancelled. Failing download for {requestUrl}");
Logger.LogError("Max retries reached or cancelled. Failing download for {requestUrl}", requestUrl);
throw;
}
await Task.Delay(retryDelay, ct).ConfigureAwait(false); // Wait before retrying
await Task.Delay(retryDelay, ct).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
response?.Dispose();
Logger.LogWarning(ex, "Error during download of {requestUrl}, HttpStatusCode: {code}", requestUrl, ex.StatusCode);
if (ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Unauthorized)
@@ -196,42 +225,80 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
throw new InvalidDataException($"Http error {ex.StatusCode} (cancelled: {ct.IsCancellationRequested}): {requestUrl}", ex);
}
throw;
throw;
}
}
ThrottledStream? stream = null;
FileStream? fileStream = null;
try
{
fileStream = File.Create(tempPath);
fileStream = File.Create(destinationFilename);
await using (fileStream.ConfigureAwait(false))
{
var bufferSize = response.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196;
var bufferSize = response!.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196;
var buffer = new byte[bufferSize];
var bytesRead = 0;
var limit = _orchestrator.DownloadLimitPerSlot();
Logger.LogTrace("Starting Download of {id} with a speed limit of {limit} to {tempPath}", requestId, limit, tempPath);
Logger.LogTrace("Starting Download with a speed limit of {limit} to {destination}", limit, destinationFilename);
stream = new(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit);
_activeDownloadStreams.TryAdd(stream, 0);
while ((bytesRead = await stream.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
while (true)
{
ct.ThrowIfCancellationRequested();
int bytesRead;
try
{
var readTask = stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct).AsTask();
while (!readTask.IsCompleted)
{
var completedTask = await Task.WhenAny(readTask, Task.Delay(DownloadStallTimeout)).ConfigureAwait(false);
if (completedTask == readTask)
{
break;
}
MungeBuffer(buffer.AsSpan(0, bytesRead));
ct.ThrowIfCancellationRequested();
var snapshot = _pairProcessingLimiter.GetSnapshot();
if (snapshot.Waiting > 0)
{
throw new TimeoutException($"No data received for {DownloadStallTimeout.TotalSeconds} seconds while downloading {requestUrl} (waiting: {snapshot.Waiting})");
}
Logger.LogTrace("Download stalled for {requestUrl} but no queued pairs, continuing to wait", requestUrl);
}
bytesRead = await readTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
if (bytesRead == 0)
{
break;
}
callback?.Invoke(buffer.AsSpan(0, bytesRead));
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct).ConfigureAwait(false);
progress.Report(bytesRead);
}
Logger.LogDebug("{requestUrl} downloaded to {tempPath}", requestUrl, tempPath);
Logger.LogDebug("{requestUrl} downloaded to {destination}", requestUrl, destinationFilename);
}
}
catch (TimeoutException ex)
{
Logger.LogWarning(ex, "Detected stalled download for {requestUrl}, aborting transfer", requestUrl);
throw;
}
catch (OperationCanceledException)
{
throw;
@@ -240,18 +307,18 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
{
try
{
fileStream?.Close();
fileStream?.Close();
if (!string.IsNullOrEmpty(tempPath) && File.Exists(tempPath))
if (!string.IsNullOrEmpty(destinationFilename) && File.Exists(destinationFilename))
{
File.Delete(tempPath);
File.Delete(destinationFilename);
}
}
catch
{
// Ignore errors during cleanup
// ignore cleanup errors
}
throw;
throw;
}
finally
{
@@ -260,6 +327,134 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
_activeDownloadStreams.TryRemove(stream, out _);
await stream.DisposeAsync().ConfigureAwait(false);
}
response?.Dispose();
}
}
private async Task DecompressBlockFileAsync(string downloadStatusKey, string blockFilePath, List<FileReplacementData> fileReplacement, string downloadLabel)
{
if (_downloadStatus.TryGetValue(downloadStatusKey, out var status))
{
status.TransferredFiles = 1;
status.DownloadStatus = DownloadStatus.Decompressing;
}
FileStream? fileBlockStream = null;
try
{
fileBlockStream = File.OpenRead(blockFilePath);
while (fileBlockStream.Position < fileBlockStream.Length)
{
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
try
{
var fileExtension = fileReplacement.First(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase)).GamePaths[0].Split(".")[^1];
var filePath = _fileDbManager.GetCacheFilePath(fileHash, fileExtension);
Logger.LogDebug("{dlName}: Decompressing {file}:{le} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
byte[] compressedFileContent = new byte[fileLengthBytes];
var readBytes = await fileBlockStream.ReadAsync(compressedFileContent, CancellationToken.None).ConfigureAwait(false);
if (readBytes != fileLengthBytes)
{
throw new EndOfStreamException();
}
MungeBuffer(compressedFileContent);
var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent);
await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath);
}
catch (EndOfStreamException)
{
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash);
}
catch (Exception e)
{
Logger.LogWarning(e, "{dlName}: Error during decompression", downloadLabel);
}
}
}
catch (EndOfStreamException)
{
Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", downloadLabel);
}
catch (Exception ex)
{
Logger.LogError(ex, "{dlName}: Error during block file read", downloadLabel);
}
finally
{
if (fileBlockStream != null)
await fileBlockStream.DisposeAsync().ConfigureAwait(false);
}
}
private async Task PerformDirectDownloadFallbackAsync(DownloadFileTransfer directDownload, List<FileReplacementData> fileReplacement,
IProgress<long> progress, CancellationToken token, bool slotAlreadyAcquired)
{
if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl))
{
throw new InvalidOperationException("Direct download fallback requested without a direct download URL.");
}
var downloadKey = directDownload.DirectDownloadUrl!;
bool slotAcquiredHere = false;
string? blockFile = null;
try
{
if (!slotAlreadyAcquired)
{
if (_downloadStatus.TryGetValue(downloadKey, out var tracker))
{
tracker.DownloadStatus = DownloadStatus.WaitingForSlot;
}
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
slotAcquiredHere = true;
}
if (_downloadStatus.TryGetValue(downloadKey, out var queueTracker))
{
queueTracker.DownloadStatus = DownloadStatus.WaitingForQueue;
}
var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(directDownload.DownloadUri),
new[] { directDownload.Hash }, token).ConfigureAwait(false);
var requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync().ConfigureAwait(false)).Trim('"'));
blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk");
await DownloadAndMungeFileHttpClient(downloadKey, requestId, [directDownload], blockFile, progress, token).ConfigureAwait(false);
if (!File.Exists(blockFile))
{
throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile);
}
await DecompressBlockFileAsync(downloadKey, blockFile, fileReplacement, $"fallback-{directDownload.Hash}").ConfigureAwait(false);
}
finally
{
if (slotAcquiredHere)
{
_orchestrator.ReleaseDownloadSlot();
}
if (!string.IsNullOrEmpty(blockFile))
{
try
{
File.Delete(blockFile);
}
catch
{
// ignore cleanup errors
}
}
}
}
@@ -307,30 +502,76 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private async Task DownloadFilesInternal(GameObjectHandler gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct)
{
var downloadGroups = CurrentDownloads.GroupBy(f => f.DownloadUri.Host + ":" + f.DownloadUri.Port, StringComparer.Ordinal);
var objectName = gameObjectHandler?.Name ?? "Unknown";
foreach (var downloadGroup in downloadGroups)
var configAllowsDirect = _configService.Current.EnableDirectDownloads;
if (configAllowsDirect != _lastConfigDirectDownloadsState)
{
_downloadStatus[downloadGroup.Key] = new FileDownloadStatus()
_lastConfigDirectDownloadsState = configAllowsDirect;
if (configAllowsDirect)
{
_disableDirectDownloads = false;
_consecutiveDirectDownloadFailures = 0;
}
}
var allowDirectDownloads = ShouldUseDirectDownloads();
var directDownloads = new List<DownloadFileTransfer>();
var batchDownloads = new List<DownloadFileTransfer>();
foreach (var download in CurrentDownloads)
{
if (!string.IsNullOrEmpty(download.DirectDownloadUrl) && allowDirectDownloads)
{
directDownloads.Add(download);
}
else
{
batchDownloads.Add(download);
}
}
var downloadBatches = batchDownloads.GroupBy(f => f.DownloadUri.Host + ":" + f.DownloadUri.Port, StringComparer.Ordinal).ToArray();
foreach (var directDownload in directDownloads)
{
_downloadStatus[directDownload.DirectDownloadUrl!] = new FileDownloadStatus()
{
DownloadStatus = DownloadStatus.Initializing,
TotalBytes = downloadGroup.Sum(c => c.Total),
TotalBytes = directDownload.Total,
TotalFiles = 1,
TransferredBytes = 0,
TransferredFiles = 0
};
}
foreach (var downloadBatch in downloadBatches)
{
_downloadStatus[downloadBatch.Key] = new FileDownloadStatus()
{
DownloadStatus = DownloadStatus.Initializing,
TotalBytes = downloadBatch.Sum(c => c.Total),
TotalFiles = 1,
TransferredBytes = 0,
TransferredFiles = 0
};
}
if (directDownloads.Count > 0 || downloadBatches.Length > 0)
{
Logger.LogWarning("Downloading {direct} files directly, and {batchtotal} in {batches} batches.", directDownloads.Count, batchDownloads.Count, downloadBatches.Length);
}
Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus));
await Parallel.ForEachAsync(downloadGroups, new ParallelOptions()
Task batchDownloadsTask = downloadBatches.Length == 0 ? Task.CompletedTask : Parallel.ForEachAsync(downloadBatches, new ParallelOptions()
{
MaxDegreeOfParallelism = downloadGroups.Count(),
MaxDegreeOfParallelism = downloadBatches.Length,
CancellationToken = ct,
},
async (fileGroup, token) =>
{
// let server predownload files
var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(fileGroup.First().DownloadUri),
fileGroup.Select(c => c.Hash), token).ConfigureAwait(false);
Logger.LogDebug("Sent request for {n} files on server {uri} with result {result}", fileGroup.Count(), fileGroup.First().DownloadUri,
@@ -353,7 +594,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
downloadStatus.DownloadStatus = DownloadStatus.WaitingForSlot;
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
downloadStatus.DownloadStatus = DownloadStatus.WaitingForQueue;
Progress<long> progress = new((bytesDownloaded) =>
var progress = CreateInlineProgress((bytesDownloaded) =>
{
try
{
@@ -371,7 +612,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
}
catch (OperationCanceledException)
{
Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, gameObjectHandler);
Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, objectName);
}
catch (Exception ex)
{
@@ -382,72 +623,167 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
return;
}
FileStream? fileBlockStream = null;
try
{
if (_downloadStatus.TryGetValue(fileGroup.Key, out var status))
{
status.TransferredFiles = 1;
status.DownloadStatus = DownloadStatus.Decompressing;
}
if (!File.Exists(blockFile))
{
Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name);
return;
}
fileBlockStream = File.OpenRead(blockFile);
while (fileBlockStream.Position < fileBlockStream.Length)
{
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
try
{
var fileExtension = fileReplacement.First(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase)).GamePaths[0].Split(".")[^1];
var filePath = _fileDbManager.GetCacheFilePath(fileHash, fileExtension);
Logger.LogDebug("{dlName}: Decompressing {file}:{le} => {dest}", fi.Name, fileHash, fileLengthBytes, filePath);
byte[] compressedFileContent = new byte[fileLengthBytes];
var readBytes = await fileBlockStream.ReadAsync(compressedFileContent, CancellationToken.None).ConfigureAwait(false);
if (readBytes != fileLengthBytes)
{
throw new EndOfStreamException();
}
MungeBuffer(compressedFileContent);
var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent);
await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath);
}
catch (EndOfStreamException)
{
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", fi.Name, fileHash);
}
catch (Exception e)
{
Logger.LogWarning(e, "{dlName}: Error during decompression", fi.Name);
}
}
}
catch (EndOfStreamException)
{
Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", fi.Name);
}
catch (Exception ex)
{
Logger.LogError(ex, "{dlName}: Error during block file read", fi.Name);
await DecompressBlockFileAsync(fileGroup.Key, blockFile, fileReplacement, fi.Name).ConfigureAwait(false);
}
finally
{
_orchestrator.ReleaseDownloadSlot();
if (fileBlockStream != null)
await fileBlockStream.DisposeAsync().ConfigureAwait(false);
File.Delete(blockFile);
}
}).ConfigureAwait(false);
});
Logger.LogDebug("Download end: {id}", gameObjectHandler);
Task directDownloadsTask = directDownloads.Count == 0 ? Task.CompletedTask : Parallel.ForEachAsync(directDownloads, new ParallelOptions()
{
MaxDegreeOfParallelism = directDownloads.Count,
CancellationToken = ct,
},
async (directDownload, token) =>
{
if (!_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out var downloadTracker))
{
Logger.LogWarning("Download status missing for direct URL {url}", directDownload.DirectDownloadUrl);
return;
}
var progress = CreateInlineProgress((bytesDownloaded) =>
{
try
{
if (_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out FileDownloadStatus? value))
{
value.TransferredBytes += bytesDownloaded;
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Could not set download progress");
}
});
if (!ShouldUseDirectDownloads())
{
await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAlreadyAcquired: false).ConfigureAwait(false);
return;
}
var tempFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, "bin");
var slotAcquired = false;
try
{
downloadTracker.DownloadStatus = DownloadStatus.WaitingForSlot;
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
slotAcquired = true;
downloadTracker.DownloadStatus = DownloadStatus.Downloading;
Logger.LogDebug("Beginning direct download of {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl);
await DownloadFileThrottled(new Uri(directDownload.DirectDownloadUrl!), tempFilename, progress, null, token, withToken: false).ConfigureAwait(false);
Interlocked.Exchange(ref _consecutiveDirectDownloadFailures, 0);
downloadTracker.DownloadStatus = DownloadStatus.Decompressing;
try
{
var replacement = fileReplacement.FirstOrDefault(f => string.Equals(f.Hash, directDownload.Hash, StringComparison.OrdinalIgnoreCase));
if (replacement == null || replacement.GamePaths.Length == 0)
{
Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash);
return;
}
var fileExtension = replacement.GamePaths[0].Split(".")[^1];
var finalFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, fileExtension);
Logger.LogDebug("Decompressing direct download {hash} from {compressedFile} to {finalFile}", directDownload.Hash, tempFilename, finalFilename);
byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename).ConfigureAwait(false);
var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes);
await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, CancellationToken.None).ConfigureAwait(false);
PersistFileToStorage(directDownload.Hash, finalFilename);
downloadTracker.TransferredFiles = 1;
Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash);
}
catch (Exception ex)
{
Logger.LogError(ex, "Exception downloading {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl);
}
}
catch (OperationCanceledException ex)
{
Logger.LogDebug("{hash}: Detected cancellation of direct download, discarding file.", directDownload.Hash);
Logger.LogError(ex, "{hash}: Error during direct download.", directDownload.Hash);
ClearDownload();
return;
}
catch (Exception ex)
{
var expectedDirectDownloadFailure = ex is InvalidDataException;
var failureCount = 0;
if (expectedDirectDownloadFailure)
{
Logger.LogInformation(ex, "{hash}: Direct download unavailable, attempting queued fallback.", directDownload.Hash);
}
else
{
failureCount = Interlocked.Increment(ref _consecutiveDirectDownloadFailures);
Logger.LogWarning(ex, "{hash}: Direct download failed, attempting queued fallback.", directDownload.Hash);
}
try
{
downloadTracker.DownloadStatus = DownloadStatus.WaitingForQueue;
await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAcquired).ConfigureAwait(false);
if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads)
{
_disableDirectDownloads = true;
Logger.LogWarning("Disabling direct downloads for this session after {count} consecutive failures.", failureCount);
}
}
catch (Exception fallbackEx)
{
if (slotAcquired)
{
_orchestrator.ReleaseDownloadSlot();
slotAcquired = false;
}
Logger.LogError(fallbackEx, "{hash}: Error during direct download fallback.", directDownload.Hash);
ClearDownload();
return;
}
}
finally
{
if (slotAcquired)
{
_orchestrator.ReleaseDownloadSlot();
}
try
{
File.Delete(tempFilename);
}
catch
{
// ignore
}
}
});
await Task.WhenAll(batchDownloadsTask, directDownloadsTask).ConfigureAwait(false);
Logger.LogDebug("Download end: {id}", objectName);
ClearDownload();
}
@@ -554,4 +890,24 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
_orchestrator.ClearDownloadRequest(requestId);
}
}
}
private static IProgress<long> CreateInlineProgress(Action<long> callback)
{
return new InlineProgress(callback);
}
private sealed class InlineProgress : IProgress<long>
{
private readonly Action<long> _callback;
public InlineProgress(Action<long> callback)
{
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
}
public void Report(long value)
{
_callback(value);
}
}
}

View File

@@ -81,27 +81,30 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
}
public async Task<HttpResponseMessage> SendRequestAsync(HttpMethod method, Uri uri,
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
bool withToken = true)
{
using var requestMessage = new HttpRequestMessage(method, uri);
return await SendRequestInternalAsync(requestMessage, ct, httpCompletionOption).ConfigureAwait(false);
return await SendRequestInternalAsync(requestMessage, ct, httpCompletionOption, withToken).ConfigureAwait(false);
}
public async Task<HttpResponseMessage> SendRequestAsync<T>(HttpMethod method, Uri uri, T content, CancellationToken ct) where T : class
public async Task<HttpResponseMessage> SendRequestAsync<T>(HttpMethod method, Uri uri, T content, CancellationToken ct,
bool withToken = true) where T : class
{
using var requestMessage = new HttpRequestMessage(method, uri);
if (content is not ByteArrayContent)
requestMessage.Content = JsonContent.Create(content);
else
requestMessage.Content = content as ByteArrayContent;
return await SendRequestInternalAsync(requestMessage, ct).ConfigureAwait(false);
return await SendRequestInternalAsync(requestMessage, ct, withToken: withToken).ConfigureAwait(false);
}
public async Task<HttpResponseMessage> SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content, CancellationToken ct)
public async Task<HttpResponseMessage> SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content,
CancellationToken ct, bool withToken = true)
{
using var requestMessage = new HttpRequestMessage(method, uri);
requestMessage.Content = content;
return await SendRequestInternalAsync(requestMessage, ct).ConfigureAwait(false);
return await SendRequestInternalAsync(requestMessage, ct, withToken: withToken).ConfigureAwait(false);
}
public async Task WaitForDownloadSlotAsync(CancellationToken token)
@@ -144,10 +147,13 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
}
private async Task<HttpResponseMessage> SendRequestInternalAsync(HttpRequestMessage requestMessage,
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, bool withToken = true)
{
var token = await _tokenProvider.GetToken().ConfigureAwait(false);
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (withToken)
{
var token = await _tokenProvider.GetToken().ConfigureAwait(false);
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
if (requestMessage.Content != null && requestMessage.Content is not StreamContent && requestMessage.Content is not ByteArrayContent)
{

View File

@@ -18,6 +18,7 @@ public class DownloadFileTransfer : FileTransfer
}
get => Dto.Size;
}
public string? DirectDownloadUrl => ((DownloadFileDto)TransferDto).CDNDownloadUrl;
public long TotalRaw => Dto.RawSize;
private DownloadFileDto Dto => (DownloadFileDto)TransferDto;