From 68ba5f4b069b92b1baf93be1472cc46fb0d350fd Mon Sep 17 00:00:00 2001 From: defnotken Date: Sun, 19 Oct 2025 14:47:30 -0500 Subject: [PATCH 01/13] dev build --- LightlessSync/LightlessSync.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 5b31c88..5deca64 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -3,7 +3,7 @@ - 1.12.3 + 1.12.2.5 https://github.com/Light-Public-Syncshells/LightlessClient From 268fd471fef70af49328a818decdfcc2c14f2097 Mon Sep 17 00:00:00 2001 From: defnotken Date: Sun, 19 Oct 2025 15:50:02 -0500 Subject: [PATCH 02/13] welcome screen fix bump --- LightlessSync/LightlessSync.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 3033575..42fb61b 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -3,7 +3,7 @@ - 1.12.2.5 + 1.12.2.6 https://github.com/Light-Public-Syncshells/LightlessClient From 0cb71e5444a0c3a030618e861ef0aa0cdfdf2e90 Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 20 Oct 2025 14:00:54 +0200 Subject: [PATCH 03/13] service cleanups, containing logic directly now --- CONTRIBUTING.md | 1086 +++++++++++++++++ DEVELOPMENT.md | 111 ++ LightlessSync/Plugin.cs | 3 +- LightlessSync/Services/Mediator/Messages.cs | 2 + LightlessSync/Services/NotificationService.cs | 95 +- LightlessSync/Services/PairRequestService.cs | 7 +- LightlessSync/Services/UiService.cs | 3 +- LightlessSync/UI/DownloadUi.cs | 6 +- LightlessSync/UI/SettingsUi.cs | 31 +- .../ApiController.Functions.Callbacks.cs | 18 +- 10 files changed, 1274 insertions(+), 88 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 DEVELOPMENT.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4d9b8a8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,1086 @@ +# Lightless Sync Development Guidelines + +This document outlines the coding standards, architectural patterns, and best practices for contributing to the Lightless Sync project. + +## Table of Contents + +1. [Project Structure](#project-structure) +2. [Coding Standards](#coding-standards) +3. [Architecture Patterns](#architecture-patterns) +4. [Dependency Injection](#dependency-injection) +5. [Mediator Pattern](#mediator-pattern) +6. [Service Development](#service-development) +7. [UI Development](#ui-development) +8. [Dalamud Integration](#dalamud-integration) +9. [Performance Considerations](#performance-considerations) +10. [Testing](#testing) +11. [Common Patterns](#common-patterns) + +--- + +## Project Structure + +### Directory Organization + +``` +LightlessSync/ +├── Changelog/ # Version history and update notes +├── FileCache/ # File caching and management +├── Interop/ # Game interop and IPC with other plugins +│ └── Ipc/ # IPC callers for external plugins +├── LightlessConfiguration/ # Configuration management +├── PlayerData/ # Player data handling and pairs +│ ├── Factories/ +│ ├── Handlers/ +│ ├── Pairs/ +│ └── Services/ +├── Services/ # Core business logic services +│ ├── CharaData/ # Character data management +│ ├── Events/ # Event aggregation +│ ├── Mediator/ # Mediator pattern implementation +│ └── ServerConfiguration/ +├── UI/ # ImGui-based user interfaces +│ ├── Components/ # Reusable UI components +│ ├── Handlers/ # UI event handlers +│ └── Models/ # UI data models +├── Utils/ # Utility classes +└── WebAPI/ # SignalR and HTTP client logic + ├── Files/ # File transfer operations + └── SignalR/ # SignalR hub connections +``` + +### File Organization Rules + +- **One primary class per file**: File name must match the primary type name +- **Partial classes**: Use for large classes (e.g., `CharaDataManager.cs`, `CharaDataManager.Upload.cs`) +- **Nested types**: Keep in the same file unless they grow large enough to warrant separation +- **Interfaces**: Prefix with `I` (e.g., `IIpcCaller`, `IMediatorSubscriber`) + +--- + +## Coding Standards + +### General C# Conventions + +Follow the `.editorconfig` settings: + +```ini +# Indentation +indent_size = 4 +tab_width = 4 +end_of_line = crlf + +# Naming +- Interfaces: IPascalCase (begins with I) +- Types: PascalCase +- Methods: PascalCase +- Properties: PascalCase +- Private fields: _camelCase (underscore prefix) +- Local variables: camelCase +- Parameters: camelCase +- Constants: PascalCase or UPPER_CASE +``` + +### Code Style Preferences + +#### Namespaces +```csharp +// Use block-scoped namespaces (not file-scoped) +namespace LightlessSync.Services; + +public class MyService +{ + // ... +} +``` + +#### Braces +```csharp +// Always use braces for control statements +if (condition) +{ + DoSomething(); +} + +// Even for single-line statements +if (condition) +{ + return; +} +``` + +#### Expression-Bodied Members +```csharp +// Prefer expression bodies for properties and accessors +public string Name { get; init; } = string.Empty; +public bool IsEnabled => _config.Current.Enabled; + +// Avoid for methods, constructors, and operators +public void DoWork() +{ + // Full method body +} +``` + +#### Object Initialization +```csharp +// Prefer object initializers +var notification = new LightlessNotification +{ + Id = "example", + Title = "Example", + Message = "This is an example", + Type = NotificationType.Info +}; + +// Prefer collection initializers +var list = new List { "item1", "item2", "item3" }; +``` + +#### Null Handling +```csharp +// Prefer null coalescing +var value = possiblyNull ?? defaultValue; + +// Prefer null propagation +var length = text?.Length ?? 0; + +// Prefer pattern matching for null checks +if (value is null) +{ + return; +} + +// Use nullable reference types +public string? OptionalValue { get; set; } +public string RequiredValue { get; set; } = string.Empty; +``` + +#### Modern C# Features +```csharp +// Use target-typed new expressions when type is apparent +List items = new(); +Dictionary map = new(); + +// Use primary constructors sparingly (prefer traditional constructors for clarity) +// AVOID: public class MyService(ILogger logger) : IService + +// Use record types for DTOs and immutable data +public record UserData(string Name, string Id); +public record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt); + +// Use pattern matching +if (obj is MyType { Property: "value" } typedObj) +{ + // Use typedObj +} +``` + +--- + +## Architecture Patterns + +### Mediator Pattern (Core Communication) + +The Lightless Sync uses a **custom mediator pattern** for decoupled communication between components. + +#### Key Components + +1. **LightlessMediator**: Central message bus +2. **MessageBase**: Base class for all messages +3. **IMediatorSubscriber**: Interface for subscribers +4. **MediatorSubscriberBase**: Base class with auto-cleanup + +#### Creating Messages + +```csharp +// In Services/Mediator/Messages.cs +public record MyCustomMessage(string Data, int Value) : MessageBase; + +// For messages that need to stay on the same thread +public record SynchronousMessage : MessageBase +{ + public override bool KeepThreadContext => true; +} +``` + +#### Subscribing to Messages + +```csharp +public class MyService : DisposableMediatorSubscriberBase, IHostedService +{ + public MyService(ILogger logger, LightlessMediator mediator) + : base(logger, mediator) + { + } + + public Task StartAsync(CancellationToken cancellationToken) + { + // Subscribe to messages + Mediator.Subscribe(this, HandleMyMessage); + Mediator.Subscribe(this, HandleAnotherMessage); + return Task.CompletedTask; + } + + private void HandleMyMessage(MyCustomMessage message) + { + Logger.LogDebug("Received: {Data}, {Value}", message.Data, message.Value); + // Handle the message + } + + public Task StopAsync(CancellationToken cancellationToken) + { + // Cleanup is automatic with DisposableMediatorSubscriberBase + return Task.CompletedTask; + } +} +``` + +#### Publishing Messages + +```csharp +// Publish to all subscribers +Mediator.Publish(new MyCustomMessage("test", 42)); + +// Publish from non-mediator classes +_mediator.Publish(new NotificationMessage( + "Title", + "Message", + NotificationType.Info)); +``` + +#### Message Guidelines + +- **Use records** for messages (immutable and concise) +- **Keep messages simple**: Only carry data, no logic +- **Name clearly**: `Message` (e.g., `PairRequestReceivedMessage`) +- **Thread-safe data**: Messages are processed asynchronously unless `KeepThreadContext = true` +- **Avoid circular dependencies**: Messages should flow in one direction + +--- + +## Dependency Injection + +### Service Registration (Plugin.cs) + +```csharp +// In Plugin.cs constructor: +.ConfigureServices(collection => +{ + // Singleton services (shared state, long-lived) + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + + // Scoped services (per-resolution scope) + collection.AddScoped(); + collection.AddScoped(); + + // Hosted services (background services with lifecycle) + collection.AddHostedService(p => p.GetRequiredService()); + collection.AddHostedService(p => p.GetRequiredService()); + + // Lazy dependencies (avoid circular dependencies) + collection.AddSingleton(s => new Lazy(() => s.GetRequiredService())); + + // Factory pattern + collection.AddSingleton(); +}) +``` + +### Service Patterns + +#### Standard Service +```csharp +public class MyService +{ + private readonly ILogger _logger; + private readonly SomeDependency _dependency; + + public MyService( + ILogger logger, + SomeDependency dependency) + { + _logger = logger; + _dependency = dependency; + } + + public void DoWork() + { + _logger.LogInformation("Working..."); + _dependency.PerformAction(); + } +} +``` + +#### Hosted Service (Background Service) +```csharp +public class MyHostedService : IHostedService, IMediatorSubscriber +{ + private readonly ILogger _logger; + private readonly LightlessMediator _mediator; + + public LightlessMediator Mediator => _mediator; + + public MyHostedService( + ILogger logger, + LightlessMediator mediator) + { + _logger = logger; + _mediator = mediator; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting {Service}", nameof(MyHostedService)); + + // Subscribe to mediator messages + _mediator.Subscribe(this, HandleSomeMessage); + + // Initialize resources + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping {Service}", nameof(MyHostedService)); + + // Cleanup + _mediator.UnsubscribeAll(this); + + return Task.CompletedTask; + } + + private void HandleSomeMessage(SomeMessage msg) + { + // Handle message + } +} +``` + +#### Mediator Subscriber Service +```csharp +public sealed class MyService : DisposableMediatorSubscriberBase +{ + private readonly SomeDependency _dependency; + + public MyService( + ILogger logger, + LightlessMediator mediator, + SomeDependency dependency) + : base(logger, mediator) + { + _dependency = dependency; + + // Subscribe in constructor or in separate Init method + Mediator.Subscribe(this, HandleMessage); + } + + private void HandleMessage(SomeMessage msg) + { + Logger.LogDebug("Handling message: {Msg}", msg); + // Process message + } + + // Dispose is handled by base class +} +``` + +--- + +## Service Development + +### Service Responsibilities + +Services should follow **Single Responsibility Principle**: + +- **NotificationService**: Handles all in-game notifications +- **BroadcastService**: Manages Lightfinder broadcast state +- **PairRequestService**: Manages incoming pair requests +- **DalamudUtilService**: Utility methods for Dalamud framework operations + +### Service Guidelines + +1. **Logging**: Always use `ILogger` with appropriate log levels +2. **Error Handling**: Wrap risky operations in try-catch, log exceptions +3. **Async/Await**: Use `ConfigureAwait(false)` for non-UI operations +4. **Thread Safety**: Use locks, `ConcurrentDictionary`, or `SemaphoreSlim` for shared state +5. **Disposal**: Implement `IDisposable` or inherit from `DisposableMediatorSubscriberBase` + +### Example Service Template + +```csharp +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.Services; + +public sealed class TemplateService : DisposableMediatorSubscriberBase, IHostedService +{ + private readonly ILogger _logger; + private readonly SomeDependency _dependency; + private readonly SemaphoreSlim _lock = new(1); + + public TemplateService( + ILogger logger, + LightlessMediator mediator, + SomeDependency dependency) + : base(logger, mediator) + { + _logger = logger; + _dependency = dependency; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting {Service}", nameof(TemplateService)); + + Mediator.Subscribe(this, HandleSomeMessage); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping {Service}", nameof(TemplateService)); + + _lock.Dispose(); + + return Task.CompletedTask; + } + + private async void HandleSomeMessage(SomeMessage msg) + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + _logger.LogDebug("Processing message: {Msg}", msg); + + // Do work + await _dependency.ProcessAsync(msg).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process message"); + } + finally + { + _lock.Release(); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _lock?.Dispose(); + } + + base.Dispose(disposing); + } +} +``` + +--- + +## UI Development + +### UI Base Classes + +All UI windows inherit from `WindowMediatorSubscriberBase`: + +```csharp +public class MyWindow : WindowMediatorSubscriberBase +{ + private readonly UiSharedService _uiShared; + + public MyWindow( + ILogger logger, + LightlessMediator mediator, + UiSharedService uiShared, + PerformanceCollectorService performanceCollector) + : base(logger, mediator, "My Window Title", performanceCollector) + { + _uiShared = uiShared; + + // Window configuration + Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse; + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(600, 400), + MaximumSize = new Vector2(800, 600) + }; + } + + protected override void DrawInternal() + { + // ImGui drawing code here + ImGui.Text("Hello, World!"); + } +} +``` + +### UI Guidelines + +1. **Separation of Concerns**: UI should only handle rendering and user input +2. **Use UiSharedService**: Centralized UI utilities (icons, buttons, styling) +3. **Performance**: Use `PerformanceCollectorService` to track rendering performance +4. **Disposal**: Windows are scoped; don't store state that needs to persist +5. **Mediator**: Use mediator to communicate with services +6. **ImGui Best Practices**: + - Use `ImRaii` for push/pop operations + - Always pair `Begin`/`End` calls + - Use `ImGuiHelpers.ScaledDummy()` for responsive spacing + - Check `IsOpen` property for visibility + +### ImGui Patterns + +```csharp +// Using ImRaii for automatic cleanup +using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"))) +using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f)) +{ + if (ImGui.Button("Styled Button")) + { + // Handle click + } +} + +// Tab bars +using (var tabBar = ImRaii.TabBar("###my_tabs")) +{ + if (!tabBar) return; + + using (var tab1 = ImRaii.TabItem("Tab 1")) + { + if (tab1) + { + ImGui.Text("Tab 1 content"); + } + } + + using (var tab2 = ImRaii.TabItem("Tab 2")) + { + if (tab2) + { + ImGui.Text("Tab 2 content"); + } + } +} + +// Child windows for scrolling regions +using (var child = ImRaii.Child("###content", new Vector2(0, -40), false, + ImGuiWindowFlags.AlwaysVerticalScrollbar)) +{ + if (!child) return; + + // Scrollable content here +} +``` + +--- + +## Dalamud Integration + +### Dalamud Services + +Dalamud provides various services accessible through constructor injection: + +```csharp +// Common Dalamud services: +IClientState clientState // Player and world state +IFramework framework // Game framework events +IObjectTable objectTable // Game objects (players, NPCs, etc.) +IChatGui chatGui // Chat window access +ICommandManager commandManager // Command registration +IDataManager gameData // Game data sheets +IPluginLog pluginLog // Logging +INotificationManager notificationManager // Toast notifications +IGameGui gameGui // Game UI access +ITextureProvider textureProvider // Icon/texture loading +``` + +### Framework Threading + +**CRITICAL**: Dalamud operates on the **game's main thread**. + +```csharp +// Safe: Already on framework thread +private void OnFrameworkUpdate(IFramework framework) +{ + // Can safely access game state + var player = _clientState.LocalPlayer; +} + +// Unsafe: Called from background thread +private async Task BackgroundWork() +{ + // DO NOT access game state here! + + // Use DalamudUtilService to run on framework thread + await _dalamudUtil.RunOnFrameworkThread(() => + { + // Now safe to access game state + var player = _clientState.LocalPlayer; + }); +} +``` + +### Game Object Access + +```csharp +// Always check for null +var player = _clientState.LocalPlayer; +if (player == null) +{ + return; +} + +// Iterating game objects +foreach (var obj in _objectTable) +{ + if (obj is IPlayerCharacter pc) + { + // Handle player character + } +} + +// Finding specific objects +var target = _targetManager.Target; +if (target is IPlayerCharacter targetPlayer) +{ + // Handle targeted player +} +``` + +### IPC with Other Plugins + +Use the IPC pattern for communicating with other Dalamud plugins: + +```csharp +public sealed class IpcCallerExample : DisposableMediatorSubscriberBase, IIpcCaller +{ + private readonly IDalamudPluginInterface _pi; + private readonly ICallGateSubscriber _apiVersion; + + public string Name => "ExamplePlugin"; + public bool Available { get; private set; } + + public IpcCallerExample( + ILogger logger, + IDalamudPluginInterface pluginInterface, + LightlessMediator mediator) + : base(logger, mediator) + { + _pi = pluginInterface; + + try + { + _apiVersion = _pi.GetIpcSubscriber("ExamplePlugin.GetApiVersion"); + CheckAvailability(); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to initialize IPC for ExamplePlugin"); + } + } + + private void CheckAvailability() + { + try + { + var version = _apiVersion.InvokeFunc(); + Available = version >= 1; + Logger.LogInformation("ExamplePlugin available, version: {Version}", version); + } + catch + { + Available = false; + } + } + + public void DoSomething() + { + if (!Available) + { + Logger.LogWarning("ExamplePlugin not available"); + return; + } + + // Call IPC methods + } +} +``` + +--- + +## Performance Considerations + +### Framework Updates + +The game runs at ~60 FPS. Avoid heavy operations in framework updates: + +```csharp +Mediator.Subscribe(this, OnTick); + +private void OnTick(PriorityFrameworkUpdateMessage _) +{ + // Throttle expensive operations + if ((DateTime.UtcNow - _lastCheck).TotalSeconds < 1) + { + return; + } + _lastCheck = DateTime.UtcNow; + + // Do lightweight work only +} +``` + +### Async Operations + +```csharp +// Good: Non-blocking async +public async Task FetchDataAsync() +{ + var result = await _httpClient.GetAsync(url).ConfigureAwait(false); + return await result.Content.ReadFromJsonAsync().ConfigureAwait(false); +} + +// Bad: Blocking async +public Data FetchDataBlocking() +{ + return FetchDataAsync().GetAwaiter().GetResult(); // Blocks thread! +} +``` + +### Collection Performance + +```csharp +// Good: Thread-safe concurrent collections +private readonly ConcurrentDictionary _cache = new(); + +// Good: Lock-protected regular collections +private readonly List _items = new(); +private readonly object _itemsLock = new(); + +public void AddItem(Item item) +{ + lock (_itemsLock) + { + _items.Add(item); + } +} + +// Good: SemaphoreSlim for async locks +private readonly SemaphoreSlim _asyncLock = new(1); + +public async Task ProcessAsync() +{ + await _asyncLock.WaitAsync().ConfigureAwait(false); + try + { + // Critical section + } + finally + { + _asyncLock.Release(); + } +} +``` + +### Memory Management + +```csharp +// Dispose pattern for unmanaged resources +public class ResourceManager : IDisposable +{ + private bool _disposed; + private readonly SemaphoreSlim _semaphore = new(1); + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; + + if (disposing) + { + // Dispose managed resources + _semaphore?.Dispose(); + } + + // Free unmanaged resources + + _disposed = true; + } +} +``` + +--- + +## Testing + +### Unit Testing + +```csharp +[TestClass] +public class MyServiceTests +{ + private Mock> _mockLogger; + private Mock _mockMediator; + private MyService _service; + + [TestInitialize] + public void Setup() + { + _mockLogger = new Mock>(); + _mockMediator = new Mock(); + _service = new MyService(_mockLogger.Object, _mockMediator.Object); + } + + [TestMethod] + public void DoWork_WithValidInput_ReturnsExpectedResult() + { + // Arrange + var input = "test"; + + // Act + var result = _service.DoWork(input); + + // Assert + Assert.AreEqual("expected", result); + } +} +``` + +### Integration Testing + +Test with real Dalamud services in a controlled environment: + +```csharp +// Create test harness that mimics Dalamud environment +public class DalamudTestHarness +{ + public IClientState ClientState { get; } + public IFramework Framework { get; } + // ... other services + + public void SimulateFrameworkUpdate() + { + // Trigger framework update event + } +} +``` + +--- + +## Common Patterns + +### Notification Pattern + +```csharp +// Simple notification +Mediator.Publish(new NotificationMessage( + "Title", + "Message body", + NotificationType.Info)); + +// Rich notification with actions +var notification = new LightlessNotification +{ + Id = "unique_id", + Title = "Action Required", + Message = "Do you want to proceed?", + Type = NotificationType.Warning, + Duration = TimeSpan.FromSeconds(10), + Actions = new List + { + new() + { + Id = "confirm", + Label = "Confirm", + Icon = FontAwesomeIcon.Check, + Color = UIColors.Get("LightlessGreen"), + IsPrimary = true, + OnClick = (n) => + { + _logger.LogInformation("User confirmed"); + DoAction(); + n.IsDismissed = true; + } + }, + new() + { + Id = "cancel", + Label = "Cancel", + Icon = FontAwesomeIcon.Times, + OnClick = (n) => n.IsDismissed = true + } + } +}; + +Mediator.Publish(new LightlessNotificationMessage(notification)); +``` + +### Configuration Pattern + +```csharp +public class MyConfigService : ConfigurationServiceBase +{ + public MyConfigService(string configDirectory) + : base(Path.Combine(configDirectory, "myconfig.json")) + { + } +} + +// Usage +_configService.Current.SomeSetting = newValue; +_configService.Save(); +``` + +### Factory Pattern + +```csharp +public class GameObjectHandlerFactory +{ + private readonly DalamudUtilService _dalamudUtil; + private readonly LightlessMediator _mediator; + + public GameObjectHandlerFactory( + DalamudUtilService dalamudUtil, + LightlessMediator mediator) + { + _dalamudUtil = dalamudUtil; + _mediator = mediator; + } + + public GameObjectHandler Create(IntPtr address, string identifier) + { + return new GameObjectHandler( + _dalamudUtil, + _mediator, + () => address, + identifier); + } +} +``` + +--- + +## Error Handling + +### Logging Guidelines + +```csharp +// Debug: Verbose information for debugging +_logger.LogDebug("Processing request {RequestId}", requestId); + +// Information: General informational messages +_logger.LogInformation("User {UserId} connected", userId); + +// Warning: Recoverable issues +_logger.LogWarning("Cache miss for {Key}, will fetch from server", key); + +// Error: Errors that need attention +_logger.LogError(ex, "Failed to process message {MessageId}", messageId); + +// Critical: Application-breaking errors +_logger.LogCritical(ex, "Database connection failed"); +``` + +### Exception Handling + +```csharp +public async Task DoWorkAsync() +{ + try + { + // Risky operation + return await PerformOperationAsync().ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Network error during operation"); + // Show user-friendly notification + Mediator.Publish(new NotificationMessage( + "Network Error", + "Failed to connect to server. Please check your connection.", + NotificationType.Error)); + return Result.Failure; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error during operation"); + throw; // Re-throw unexpected exceptions + } +} +``` + +--- + +## Best Practices Summary + +### DO ✅ + +- Use `DisposableMediatorSubscriberBase` for services that subscribe to mediator +- Use `IHostedService` for background services +- Always inject `ILogger` for logging +- Use `ConfigureAwait(false)` for async operations not requiring UI thread +- Dispose of resources properly (SemaphoreSlim, HttpClient, etc.) +- Check for null when accessing Dalamud game objects +- Use thread-safe collections for shared state +- Keep UI logic separate from business logic +- Use mediator for cross-component communication +- Write unit tests for business logic +- Document public APIs with XML comments + +### DON'T ❌ + +- Don't access game objects from background threads without `RunOnFrameworkThread` +- Don't block async methods with `.Result` or `.Wait()` +- Don't store UI state in services (services are often singletons) +- Don't use `async void` except for event handlers +- Don't catch `Exception` without re-throwing or logging +- Don't create circular dependencies between services +- Don't perform heavy operations in framework updates +- Don't forget to unsubscribe from mediator messages +- Don't hardcode file paths (use `IDalamudPluginInterface.ConfigDirectory`) +- Don't use primary constructors (prefer traditional for clarity) + +--- + +## Code Review Checklist + +Before submitting a pull request, ensure: + +- [ ] Code follows naming conventions +- [ ] All new services are registered in `Plugin.cs` +- [ ] Mediator messages are defined in `Messages.cs` +- [ ] Logging is appropriate and informative +- [ ] Thread safety is considered for shared state +- [ ] Disposable resources are properly disposed +- [ ] Async operations use `ConfigureAwait(false)` +- [ ] UI code is separated from business logic +- [ ] No direct game object access from background threads +- [ ] Error handling is comprehensive +- [ ] Performance impact is considered +- [ ] Comments explain "why", not "what" +- [ ] No compiler warnings +- [ ] Tests pass (if applicable) + +--- + +## Resources + +- [Dalamud Documentation](https://dalamud.dev) +- [ImGui.NET Documentation](https://github.com/mellinoe/ImGui.NET) +- [C# Coding Conventions](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions) +- [.NET API Guidelines](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/api-guidelines) + +--- + +**Happy Coding!** 🚀 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..32c892c --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,111 @@ +# Development Setup for macOS + +This document explains how to set up the Lightless Sync development environment on macOS. + +## Problem: "Cannot resolve symbol 'Dalamud'" + +When developing Dalamud plugins on macOS, you may encounter the error: +``` +Cannot resolve symbol 'Dalamud' +Dalamud.NET.Sdk: Dalamud installation not found at /Users/.../Library/Application Support/XIV on Mac/dalamud/Hooks/dev/ +``` + +This happens because the Dalamud.NET.Sdk expects to find Dalamud assemblies at a specific path, but they don't exist if you don't have XIV on Mac or Dalamud installed. + +## Solution + +### Automated Setup (Recommended) + +Run the setup script to download the required Dalamud assemblies: + +```bash +./setup-dalamud.sh +``` + +This script will: +1. Create a development directory at `~/.dalamud/dev` +2. Download the latest Dalamud assemblies from the official distribution +3. Extract them to the development directory + +### Manual Setup + +If you prefer to set up manually: + +1. **Create the Dalamud directory:** + ```bash + mkdir -p ~/.dalamud/dev + ``` + +2. **Download Dalamud assemblies:** + ```bash + curl -L -o /tmp/dalamud.zip https://goatcorp.github.io/dalamud-distrib/latest.zip + unzip /tmp/dalamud.zip -d ~/.dalamud/dev + ``` + +3. **Set the DALAMUD_HOME environment variable (optional):** + ```bash + export DALAMUD_HOME="$HOME/.dalamud/dev" + ``` + +## How It Works + +The project includes a `Directory.Build.props` file that automatically configures the `DALAMUD_HOME` path to use `~/.dalamud/dev` if it exists. This overrides the default XIV on Mac path. + +The Dalamud.NET.Sdk will then use this path to find the required assemblies for compilation and IntelliSense. + +## Building the Project + +After setup, you can build the project normally: + +```bash +dotnet restore +dotnet build +``` + +## IDE Configuration + +### JetBrains Rider / IntelliJ IDEA + +After running the setup script, you may need to: +1. Invalidate caches and restart: **File → Invalidate Caches → Invalidate and Restart** +2. Reload the solution: **Right-click on solution → Reload All Projects** + +The IDE should now resolve all Dalamud symbols correctly. + +## Troubleshooting + +### Build still fails with "Dalamud installation not found" + +1. Verify the assemblies were downloaded: + ```bash + ls -la ~/.dalamud/dev/Dalamud.dll + ``` + +2. Check that `Directory.Build.props` exists in the project root + +3. Try cleaning and rebuilding: + ```bash + dotnet clean + dotnet build + ``` + +### IDE still shows "Cannot resolve symbol 'Dalamud'" + +1. Ensure the build succeeds first (run `dotnet build`) +2. Restart your IDE +3. Try invalidating caches (Rider/IntelliJ) +4. Check that the project references are loaded correctly + +## Files Modified + +- `Directory.Build.props` - Configures DALAMUD_HOME path +- `LightlessSync/LightlessSync.csproj` - Removed duplicate DalamudPackager reference +- `PenumbraAPI/Penumbra.Api.csproj` - Added DalamudLibPath configuration +- `setup-dalamud.sh` - Setup script to download Dalamud assemblies + +## Additional Notes + +- The Dalamud assemblies are only needed for development/compilation +- You don't need a running FFXIV or XIV on Mac installation to develop plugins +- The assemblies are downloaded from the official Dalamud distribution +- Updates to Dalamud may require re-running the setup script diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 9ec4bed..54a879e 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -269,8 +269,7 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService(), s.GetServices(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService())); + s.GetRequiredService())); collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 8a724b4..79434c2 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -108,7 +108,9 @@ public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase; public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase; public record SyncshellBroadcastsUpdatedMessage : MessageBase; +public record PairRequestReceivedMessage(string HashedCid, string Message) : MessageBase; public record PairRequestsUpdatedMessage : MessageBase; +public record PairDownloadStatusMessage(List<(string PlayerName, float Progress, string Status)> DownloadStatus, int QueueWaiting) : MessageBase; public record VisibilityChange : MessageBase; #pragma warning restore S2094 #pragma warning restore MA0048 // File name must match type name \ No newline at end of file diff --git a/LightlessSync/Services/NotificationService.cs b/LightlessSync/Services/NotificationService.cs index 755e756..56da126 100644 --- a/LightlessSync/Services/NotificationService.cs +++ b/LightlessSync/Services/NotificationService.cs @@ -45,7 +45,9 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ public Task StartAsync(CancellationToken cancellationToken) { Mediator.Subscribe(this, HandleNotificationMessage); + Mediator.Subscribe(this, HandlePairRequestReceived); Mediator.Subscribe(this, HandlePairRequestsUpdated); + Mediator.Subscribe(this, HandlePairDownloadStatus); Mediator.Subscribe(this, HandlePerformanceNotification); return Task.CompletedTask; } @@ -293,33 +295,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ return actions; } - public void ShowPairDownloadNotification(List<(string playerName, float progress, string status)> downloadStatus, - int queueWaiting = 0) - { - var userDownloads = downloadStatus.Where(x => x.playerName != "Pair Queue").ToList(); - var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.progress) : 0f; - var message = BuildPairDownloadMessage(userDownloads, queueWaiting); - var notification = new LightlessNotification - { - Id = "pair_download_progress", - Title = "Downloading Pair Data", - Message = message, - Type = NotificationType.Download, - Duration = TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds), - ShowProgress = true, - Progress = totalProgress - }; - - Mediator.Publish(new LightlessNotificationMessage(notification)); - - if (AreAllDownloadsCompleted(userDownloads)) - { - DismissPairDownloadNotification(); - } - } - - private string BuildPairDownloadMessage(List<(string playerName, float progress, string status)> userDownloads, + private string BuildPairDownloadMessage(List<(string PlayerName, float Progress, string Status)> userDownloads, int queueWaiting) { var messageParts = new List(); @@ -331,7 +308,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ if (userDownloads.Count > 0) { - var completedCount = userDownloads.Count(x => x.progress >= 1.0f); + var completedCount = userDownloads.Count(x => x.Progress >= 1.0f); messageParts.Add($"Progress: {completedCount}/{userDownloads.Count} completed"); } @@ -344,29 +321,29 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ return string.Join("\n", messageParts); } - private string BuildActiveDownloadLines(List<(string playerName, float progress, string status)> userDownloads) + private string BuildActiveDownloadLines(List<(string PlayerName, float Progress, string Status)> userDownloads) { var activeDownloads = userDownloads - .Where(x => x.progress < 1.0f) + .Where(x => x.Progress < 1.0f) .Take(_configService.Current.MaxConcurrentPairApplications); if (!activeDownloads.Any()) return string.Empty; - return string.Join("\n", activeDownloads.Select(x => $"• {x.playerName}: {FormatDownloadStatus(x)}")); + return string.Join("\n", activeDownloads.Select(x => $"• {x.PlayerName}: {FormatDownloadStatus(x)}")); } - private string FormatDownloadStatus((string playerName, float progress, string status) download) => - download.status switch + private string FormatDownloadStatus((string PlayerName, float Progress, string Status) download) => + download.Status switch { - "downloading" => $"{download.progress:P0}", + "downloading" => $"{download.Progress:P0}", "decompressing" => "decompressing", "queued" => "queued", "waiting" => "waiting for slot", - _ => download.status + _ => download.Status }; - private bool AreAllDownloadsCompleted(List<(string playerName, float progress, string status)> userDownloads) => - userDownloads.Any() && userDownloads.All(x => x.progress >= 1.0f); + private bool AreAllDownloadsCompleted(List<(string PlayerName, float Progress, string Status)> userDownloads) => + userDownloads.Any() && userDownloads.All(x => x.Progress >= 1.0f); public void DismissPairDownloadNotification() => Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); @@ -581,12 +558,25 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ _chatGui.Print(se.BuiltString); } + private void HandlePairRequestReceived(PairRequestReceivedMessage msg) + { + var request = _pairRequestService.RegisterIncomingRequest(msg.HashedCid, msg.Message); + var senderName = string.IsNullOrEmpty(request.DisplayName) ? "Unknown User" : request.DisplayName; + + _shownPairRequestNotifications.Add(request.HashedCid); + ShowPairRequestNotification( + senderName, + request.HashedCid, + onAccept: () => _pairRequestService.AcceptPairRequest(request.HashedCid, senderName), + onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid)); + } + private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _) { var activeRequests = _pairRequestService.GetActiveRequests(); var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet(); - // Dismiss notifications for requests that are no longer active + // Dismiss notifications for requests that are no longer active (expired) var notificationsToRemove = _shownPairRequestNotifications .Where(hashedCid => !activeRequestIds.Contains(hashedCid)) .ToList(); @@ -597,17 +587,30 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ Mediator.Publish(new LightlessNotificationDismissMessage(notificationId)); _shownPairRequestNotifications.Remove(hashedCid); } + } - // Show/update notifications for all active requests - foreach (var request in activeRequests) + private void HandlePairDownloadStatus(PairDownloadStatusMessage msg) + { + var userDownloads = msg.DownloadStatus.Where(x => x.PlayerName != "Pair Queue").ToList(); + var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.Progress) : 0f; + var message = BuildPairDownloadMessage(userDownloads, msg.QueueWaiting); + + var notification = new LightlessNotification { - _shownPairRequestNotifications.Add(request.HashedCid); - ShowPairRequestNotification( - request.DisplayName, - request.HashedCid, - () => _pairRequestService.AcceptPairRequest(request.HashedCid, request.DisplayName), - () => _pairRequestService.DeclinePairRequest(request.HashedCid) - ); + Id = "pair_download_progress", + Title = "Downloading Pair Data", + Message = message, + Type = NotificationType.Download, + Duration = TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds), + ShowProgress = true, + Progress = totalProgress + }; + + Mediator.Publish(new LightlessNotificationMessage(notification)); + + if (AreAllDownloadsCompleted(userDownloads)) + { + Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); } } diff --git a/LightlessSync/Services/PairRequestService.cs b/LightlessSync/Services/PairRequestService.cs index 92294e2..f2ee64f 100644 --- a/LightlessSync/Services/PairRequestService.cs +++ b/LightlessSync/Services/PairRequestService.cs @@ -19,7 +19,12 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(5); - public PairRequestService(ILogger logger, LightlessMediator mediator, DalamudUtilService dalamudUtil, PairManager pairManager, Lazy apiController) + public PairRequestService( + ILogger logger, + LightlessMediator mediator, + DalamudUtilService dalamudUtil, + PairManager pairManager, + Lazy apiController) : base(logger, mediator) { _dalamudUtil = dalamudUtil; diff --git a/LightlessSync/Services/UiService.cs b/LightlessSync/Services/UiService.cs index 3740114..f08b1fc 100644 --- a/LightlessSync/Services/UiService.cs +++ b/LightlessSync/Services/UiService.cs @@ -23,8 +23,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase LightlessConfigService lightlessConfigService, WindowSystem windowSystem, IEnumerable windows, UiFactory uiFactory, FileDialogManager fileDialogManager, - LightlessMediator lightlessMediator, - NotificationService notificationService) : base(logger, lightlessMediator) + LightlessMediator lightlessMediator) : base(logger, lightlessMediator) { _logger = logger; _logger.LogTrace("Creating {type}", GetType().Name); diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 1b1ec16..a592e43 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -22,13 +22,12 @@ public class DownloadUi : WindowMediatorSubscriberBase private readonly UiSharedService _uiShared; private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly ConcurrentDictionary _uploadingPlayers = new(); - private readonly NotificationService _notificationService; private bool _notificationDismissed = true; private int _lastDownloadStateHash = 0; public DownloadUi(ILogger logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService, PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, - PerformanceCollectorService performanceCollectorService, NotificationService notificationService) + PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "Lightless Sync Downloads", performanceCollectorService) { _dalamudUtilService = dalamudUtilService; @@ -36,7 +35,6 @@ public class DownloadUi : WindowMediatorSubscriberBase _pairProcessingLimiter = pairProcessingLimiter; _fileTransferManager = fileTransferManager; _uiShared = uiShared; - _notificationService = notificationService; SizeConstraints = new WindowSizeConstraints() { @@ -359,7 +357,7 @@ public class DownloadUi : WindowMediatorSubscriberBase _lastDownloadStateHash = currentHash; if (downloadStatus.Count > 0 || queueWaiting > 0) { - _notificationService.ShowPairDownloadNotification(downloadStatus, queueWaiting); + Mediator.Publish(new PairDownloadStatusMessage(downloadStatus, queueWaiting)); } } } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 3b87baa..febc142 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -63,7 +63,6 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress; private readonly NameplateService _nameplateService; private readonly NameplateHandler _nameplateHandler; - private readonly NotificationService _lightlessNotificationService; private (int, int, FileCacheEntity) _currentProgress; private bool _deleteAccountPopupModalShown = false; private bool _deleteFilesPopupModalShown = false; @@ -107,8 +106,7 @@ public class SettingsUi : WindowMediatorSubscriberBase IpcManager ipcManager, CacheMonitor cacheMonitor, DalamudUtilService dalamudUtilService, HttpClient httpClient, NameplateService nameplateService, - NameplateHandler nameplateHandler, - NotificationService lightlessNotificationService) : base(logger, mediator, "Lightless Sync Settings", + NameplateHandler nameplateHandler) : base(logger, mediator, "Lightless Sync Settings", performanceCollector) { _configService = configService; @@ -130,7 +128,6 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared = uiShared; _nameplateService = nameplateService; _nameplateHandler = nameplateHandler; - _lightlessNotificationService = lightlessNotificationService; AllowClickthrough = false; AllowPinning = true; _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); @@ -3616,20 +3613,7 @@ public class SettingsUi : WindowMediatorSubscriberBase { 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)); - } - ); + Mediator.Publish(new PairRequestReceivedMessage("test-uid-123", "Test User wants to pair with you.")); } } UiSharedService.AttachToolTip("Test pair request notification"); @@ -3652,15 +3636,14 @@ public class SettingsUi : WindowMediatorSubscriberBase { if (ImGui.Button($"{FontAwesomeIcon.Play.ToIconString()}##test_download", new Vector2(availableWidth, 0))) { - _lightlessNotificationService.ShowPairDownloadNotification( - new List<(string playerName, float progress, string status)> - { + Mediator.Publish(new PairDownloadStatusMessage( + [ ("Player One", 0.35f, "downloading"), ("Player Two", 0.75f, "downloading"), ("Player Three", 1.0f, "downloading") - }, - queueWaiting: 2 - ); + ], + 2 + )); } } UiSharedService.AttachToolTip("Test download progress notification"); diff --git a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs index da07460..8323fc3 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs @@ -107,17 +107,17 @@ public partial class ApiController } public Task Client_ReceiveBroadcastPairRequest(UserPairNotificationDto dto) { - if (dto == null) + Logger.LogDebug("Client_ReceiveBroadcastPairRequest: {dto}", dto); + + if (dto is null) + { return Task.CompletedTask; + } - var request = _pairRequestService.RegisterIncomingRequest(dto.myHashedCid, dto.message ?? string.Empty); - var senderName = string.IsNullOrEmpty(request.DisplayName) ? "Unknown User" : request.DisplayName; - - _lightlessNotificationService.ShowPairRequestNotification( - senderName, - request.HashedCid, - onAccept: () => _pairRequestService.AcceptPairRequest(request.HashedCid, senderName), - onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid)); + ExecuteSafely(() => + { + Mediator.Publish(new PairRequestReceivedMessage(dto.myHashedCid, dto.message ?? string.Empty)); + }); return Task.CompletedTask; } From 923f118a47591cc132a19f94c803d0026996babb Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 20 Oct 2025 14:05:45 +0200 Subject: [PATCH 04/13] Delete DEVELOPMENT.md --- DEVELOPMENT.md | 111 ------------------------------------------------- 1 file changed, 111 deletions(-) delete mode 100644 DEVELOPMENT.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 32c892c..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,111 +0,0 @@ -# Development Setup for macOS - -This document explains how to set up the Lightless Sync development environment on macOS. - -## Problem: "Cannot resolve symbol 'Dalamud'" - -When developing Dalamud plugins on macOS, you may encounter the error: -``` -Cannot resolve symbol 'Dalamud' -Dalamud.NET.Sdk: Dalamud installation not found at /Users/.../Library/Application Support/XIV on Mac/dalamud/Hooks/dev/ -``` - -This happens because the Dalamud.NET.Sdk expects to find Dalamud assemblies at a specific path, but they don't exist if you don't have XIV on Mac or Dalamud installed. - -## Solution - -### Automated Setup (Recommended) - -Run the setup script to download the required Dalamud assemblies: - -```bash -./setup-dalamud.sh -``` - -This script will: -1. Create a development directory at `~/.dalamud/dev` -2. Download the latest Dalamud assemblies from the official distribution -3. Extract them to the development directory - -### Manual Setup - -If you prefer to set up manually: - -1. **Create the Dalamud directory:** - ```bash - mkdir -p ~/.dalamud/dev - ``` - -2. **Download Dalamud assemblies:** - ```bash - curl -L -o /tmp/dalamud.zip https://goatcorp.github.io/dalamud-distrib/latest.zip - unzip /tmp/dalamud.zip -d ~/.dalamud/dev - ``` - -3. **Set the DALAMUD_HOME environment variable (optional):** - ```bash - export DALAMUD_HOME="$HOME/.dalamud/dev" - ``` - -## How It Works - -The project includes a `Directory.Build.props` file that automatically configures the `DALAMUD_HOME` path to use `~/.dalamud/dev` if it exists. This overrides the default XIV on Mac path. - -The Dalamud.NET.Sdk will then use this path to find the required assemblies for compilation and IntelliSense. - -## Building the Project - -After setup, you can build the project normally: - -```bash -dotnet restore -dotnet build -``` - -## IDE Configuration - -### JetBrains Rider / IntelliJ IDEA - -After running the setup script, you may need to: -1. Invalidate caches and restart: **File → Invalidate Caches → Invalidate and Restart** -2. Reload the solution: **Right-click on solution → Reload All Projects** - -The IDE should now resolve all Dalamud symbols correctly. - -## Troubleshooting - -### Build still fails with "Dalamud installation not found" - -1. Verify the assemblies were downloaded: - ```bash - ls -la ~/.dalamud/dev/Dalamud.dll - ``` - -2. Check that `Directory.Build.props` exists in the project root - -3. Try cleaning and rebuilding: - ```bash - dotnet clean - dotnet build - ``` - -### IDE still shows "Cannot resolve symbol 'Dalamud'" - -1. Ensure the build succeeds first (run `dotnet build`) -2. Restart your IDE -3. Try invalidating caches (Rider/IntelliJ) -4. Check that the project references are loaded correctly - -## Files Modified - -- `Directory.Build.props` - Configures DALAMUD_HOME path -- `LightlessSync/LightlessSync.csproj` - Removed duplicate DalamudPackager reference -- `PenumbraAPI/Penumbra.Api.csproj` - Added DalamudLibPath configuration -- `setup-dalamud.sh` - Setup script to download Dalamud assemblies - -## Additional Notes - -- The Dalamud assemblies are only needed for development/compilation -- You don't need a running FFXIV or XIV on Mac installation to develop plugins -- The assemblies are downloaded from the official Dalamud distribution -- Updates to Dalamud may require re-running the setup script From f5458c7f97309e0d027c3d5b3b7dda66faa84c19 Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 20 Oct 2025 14:05:51 +0200 Subject: [PATCH 05/13] Delete CONTRIBUTING.md --- CONTRIBUTING.md | 1086 ----------------------------------------------- 1 file changed, 1086 deletions(-) delete mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 4d9b8a8..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,1086 +0,0 @@ -# Lightless Sync Development Guidelines - -This document outlines the coding standards, architectural patterns, and best practices for contributing to the Lightless Sync project. - -## Table of Contents - -1. [Project Structure](#project-structure) -2. [Coding Standards](#coding-standards) -3. [Architecture Patterns](#architecture-patterns) -4. [Dependency Injection](#dependency-injection) -5. [Mediator Pattern](#mediator-pattern) -6. [Service Development](#service-development) -7. [UI Development](#ui-development) -8. [Dalamud Integration](#dalamud-integration) -9. [Performance Considerations](#performance-considerations) -10. [Testing](#testing) -11. [Common Patterns](#common-patterns) - ---- - -## Project Structure - -### Directory Organization - -``` -LightlessSync/ -├── Changelog/ # Version history and update notes -├── FileCache/ # File caching and management -├── Interop/ # Game interop and IPC with other plugins -│ └── Ipc/ # IPC callers for external plugins -├── LightlessConfiguration/ # Configuration management -├── PlayerData/ # Player data handling and pairs -│ ├── Factories/ -│ ├── Handlers/ -│ ├── Pairs/ -│ └── Services/ -├── Services/ # Core business logic services -│ ├── CharaData/ # Character data management -│ ├── Events/ # Event aggregation -│ ├── Mediator/ # Mediator pattern implementation -│ └── ServerConfiguration/ -├── UI/ # ImGui-based user interfaces -│ ├── Components/ # Reusable UI components -│ ├── Handlers/ # UI event handlers -│ └── Models/ # UI data models -├── Utils/ # Utility classes -└── WebAPI/ # SignalR and HTTP client logic - ├── Files/ # File transfer operations - └── SignalR/ # SignalR hub connections -``` - -### File Organization Rules - -- **One primary class per file**: File name must match the primary type name -- **Partial classes**: Use for large classes (e.g., `CharaDataManager.cs`, `CharaDataManager.Upload.cs`) -- **Nested types**: Keep in the same file unless they grow large enough to warrant separation -- **Interfaces**: Prefix with `I` (e.g., `IIpcCaller`, `IMediatorSubscriber`) - ---- - -## Coding Standards - -### General C# Conventions - -Follow the `.editorconfig` settings: - -```ini -# Indentation -indent_size = 4 -tab_width = 4 -end_of_line = crlf - -# Naming -- Interfaces: IPascalCase (begins with I) -- Types: PascalCase -- Methods: PascalCase -- Properties: PascalCase -- Private fields: _camelCase (underscore prefix) -- Local variables: camelCase -- Parameters: camelCase -- Constants: PascalCase or UPPER_CASE -``` - -### Code Style Preferences - -#### Namespaces -```csharp -// Use block-scoped namespaces (not file-scoped) -namespace LightlessSync.Services; - -public class MyService -{ - // ... -} -``` - -#### Braces -```csharp -// Always use braces for control statements -if (condition) -{ - DoSomething(); -} - -// Even for single-line statements -if (condition) -{ - return; -} -``` - -#### Expression-Bodied Members -```csharp -// Prefer expression bodies for properties and accessors -public string Name { get; init; } = string.Empty; -public bool IsEnabled => _config.Current.Enabled; - -// Avoid for methods, constructors, and operators -public void DoWork() -{ - // Full method body -} -``` - -#### Object Initialization -```csharp -// Prefer object initializers -var notification = new LightlessNotification -{ - Id = "example", - Title = "Example", - Message = "This is an example", - Type = NotificationType.Info -}; - -// Prefer collection initializers -var list = new List { "item1", "item2", "item3" }; -``` - -#### Null Handling -```csharp -// Prefer null coalescing -var value = possiblyNull ?? defaultValue; - -// Prefer null propagation -var length = text?.Length ?? 0; - -// Prefer pattern matching for null checks -if (value is null) -{ - return; -} - -// Use nullable reference types -public string? OptionalValue { get; set; } -public string RequiredValue { get; set; } = string.Empty; -``` - -#### Modern C# Features -```csharp -// Use target-typed new expressions when type is apparent -List items = new(); -Dictionary map = new(); - -// Use primary constructors sparingly (prefer traditional constructors for clarity) -// AVOID: public class MyService(ILogger logger) : IService - -// Use record types for DTOs and immutable data -public record UserData(string Name, string Id); -public record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt); - -// Use pattern matching -if (obj is MyType { Property: "value" } typedObj) -{ - // Use typedObj -} -``` - ---- - -## Architecture Patterns - -### Mediator Pattern (Core Communication) - -The Lightless Sync uses a **custom mediator pattern** for decoupled communication between components. - -#### Key Components - -1. **LightlessMediator**: Central message bus -2. **MessageBase**: Base class for all messages -3. **IMediatorSubscriber**: Interface for subscribers -4. **MediatorSubscriberBase**: Base class with auto-cleanup - -#### Creating Messages - -```csharp -// In Services/Mediator/Messages.cs -public record MyCustomMessage(string Data, int Value) : MessageBase; - -// For messages that need to stay on the same thread -public record SynchronousMessage : MessageBase -{ - public override bool KeepThreadContext => true; -} -``` - -#### Subscribing to Messages - -```csharp -public class MyService : DisposableMediatorSubscriberBase, IHostedService -{ - public MyService(ILogger logger, LightlessMediator mediator) - : base(logger, mediator) - { - } - - public Task StartAsync(CancellationToken cancellationToken) - { - // Subscribe to messages - Mediator.Subscribe(this, HandleMyMessage); - Mediator.Subscribe(this, HandleAnotherMessage); - return Task.CompletedTask; - } - - private void HandleMyMessage(MyCustomMessage message) - { - Logger.LogDebug("Received: {Data}, {Value}", message.Data, message.Value); - // Handle the message - } - - public Task StopAsync(CancellationToken cancellationToken) - { - // Cleanup is automatic with DisposableMediatorSubscriberBase - return Task.CompletedTask; - } -} -``` - -#### Publishing Messages - -```csharp -// Publish to all subscribers -Mediator.Publish(new MyCustomMessage("test", 42)); - -// Publish from non-mediator classes -_mediator.Publish(new NotificationMessage( - "Title", - "Message", - NotificationType.Info)); -``` - -#### Message Guidelines - -- **Use records** for messages (immutable and concise) -- **Keep messages simple**: Only carry data, no logic -- **Name clearly**: `Message` (e.g., `PairRequestReceivedMessage`) -- **Thread-safe data**: Messages are processed asynchronously unless `KeepThreadContext = true` -- **Avoid circular dependencies**: Messages should flow in one direction - ---- - -## Dependency Injection - -### Service Registration (Plugin.cs) - -```csharp -// In Plugin.cs constructor: -.ConfigureServices(collection => -{ - // Singleton services (shared state, long-lived) - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - - // Scoped services (per-resolution scope) - collection.AddScoped(); - collection.AddScoped(); - - // Hosted services (background services with lifecycle) - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - - // Lazy dependencies (avoid circular dependencies) - collection.AddSingleton(s => new Lazy(() => s.GetRequiredService())); - - // Factory pattern - collection.AddSingleton(); -}) -``` - -### Service Patterns - -#### Standard Service -```csharp -public class MyService -{ - private readonly ILogger _logger; - private readonly SomeDependency _dependency; - - public MyService( - ILogger logger, - SomeDependency dependency) - { - _logger = logger; - _dependency = dependency; - } - - public void DoWork() - { - _logger.LogInformation("Working..."); - _dependency.PerformAction(); - } -} -``` - -#### Hosted Service (Background Service) -```csharp -public class MyHostedService : IHostedService, IMediatorSubscriber -{ - private readonly ILogger _logger; - private readonly LightlessMediator _mediator; - - public LightlessMediator Mediator => _mediator; - - public MyHostedService( - ILogger logger, - LightlessMediator mediator) - { - _logger = logger; - _mediator = mediator; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Starting {Service}", nameof(MyHostedService)); - - // Subscribe to mediator messages - _mediator.Subscribe(this, HandleSomeMessage); - - // Initialize resources - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Stopping {Service}", nameof(MyHostedService)); - - // Cleanup - _mediator.UnsubscribeAll(this); - - return Task.CompletedTask; - } - - private void HandleSomeMessage(SomeMessage msg) - { - // Handle message - } -} -``` - -#### Mediator Subscriber Service -```csharp -public sealed class MyService : DisposableMediatorSubscriberBase -{ - private readonly SomeDependency _dependency; - - public MyService( - ILogger logger, - LightlessMediator mediator, - SomeDependency dependency) - : base(logger, mediator) - { - _dependency = dependency; - - // Subscribe in constructor or in separate Init method - Mediator.Subscribe(this, HandleMessage); - } - - private void HandleMessage(SomeMessage msg) - { - Logger.LogDebug("Handling message: {Msg}", msg); - // Process message - } - - // Dispose is handled by base class -} -``` - ---- - -## Service Development - -### Service Responsibilities - -Services should follow **Single Responsibility Principle**: - -- **NotificationService**: Handles all in-game notifications -- **BroadcastService**: Manages Lightfinder broadcast state -- **PairRequestService**: Manages incoming pair requests -- **DalamudUtilService**: Utility methods for Dalamud framework operations - -### Service Guidelines - -1. **Logging**: Always use `ILogger` with appropriate log levels -2. **Error Handling**: Wrap risky operations in try-catch, log exceptions -3. **Async/Await**: Use `ConfigureAwait(false)` for non-UI operations -4. **Thread Safety**: Use locks, `ConcurrentDictionary`, or `SemaphoreSlim` for shared state -5. **Disposal**: Implement `IDisposable` or inherit from `DisposableMediatorSubscriberBase` - -### Example Service Template - -```csharp -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace LightlessSync.Services; - -public sealed class TemplateService : DisposableMediatorSubscriberBase, IHostedService -{ - private readonly ILogger _logger; - private readonly SomeDependency _dependency; - private readonly SemaphoreSlim _lock = new(1); - - public TemplateService( - ILogger logger, - LightlessMediator mediator, - SomeDependency dependency) - : base(logger, mediator) - { - _logger = logger; - _dependency = dependency; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Starting {Service}", nameof(TemplateService)); - - Mediator.Subscribe(this, HandleSomeMessage); - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Stopping {Service}", nameof(TemplateService)); - - _lock.Dispose(); - - return Task.CompletedTask; - } - - private async void HandleSomeMessage(SomeMessage msg) - { - await _lock.WaitAsync().ConfigureAwait(false); - try - { - _logger.LogDebug("Processing message: {Msg}", msg); - - // Do work - await _dependency.ProcessAsync(msg).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to process message"); - } - finally - { - _lock.Release(); - } - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - _lock?.Dispose(); - } - - base.Dispose(disposing); - } -} -``` - ---- - -## UI Development - -### UI Base Classes - -All UI windows inherit from `WindowMediatorSubscriberBase`: - -```csharp -public class MyWindow : WindowMediatorSubscriberBase -{ - private readonly UiSharedService _uiShared; - - public MyWindow( - ILogger logger, - LightlessMediator mediator, - UiSharedService uiShared, - PerformanceCollectorService performanceCollector) - : base(logger, mediator, "My Window Title", performanceCollector) - { - _uiShared = uiShared; - - // Window configuration - Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse; - SizeConstraints = new WindowSizeConstraints - { - MinimumSize = new Vector2(600, 400), - MaximumSize = new Vector2(800, 600) - }; - } - - protected override void DrawInternal() - { - // ImGui drawing code here - ImGui.Text("Hello, World!"); - } -} -``` - -### UI Guidelines - -1. **Separation of Concerns**: UI should only handle rendering and user input -2. **Use UiSharedService**: Centralized UI utilities (icons, buttons, styling) -3. **Performance**: Use `PerformanceCollectorService` to track rendering performance -4. **Disposal**: Windows are scoped; don't store state that needs to persist -5. **Mediator**: Use mediator to communicate with services -6. **ImGui Best Practices**: - - Use `ImRaii` for push/pop operations - - Always pair `Begin`/`End` calls - - Use `ImGuiHelpers.ScaledDummy()` for responsive spacing - - Check `IsOpen` property for visibility - -### ImGui Patterns - -```csharp -// Using ImRaii for automatic cleanup -using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"))) -using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f)) -{ - if (ImGui.Button("Styled Button")) - { - // Handle click - } -} - -// Tab bars -using (var tabBar = ImRaii.TabBar("###my_tabs")) -{ - if (!tabBar) return; - - using (var tab1 = ImRaii.TabItem("Tab 1")) - { - if (tab1) - { - ImGui.Text("Tab 1 content"); - } - } - - using (var tab2 = ImRaii.TabItem("Tab 2")) - { - if (tab2) - { - ImGui.Text("Tab 2 content"); - } - } -} - -// Child windows for scrolling regions -using (var child = ImRaii.Child("###content", new Vector2(0, -40), false, - ImGuiWindowFlags.AlwaysVerticalScrollbar)) -{ - if (!child) return; - - // Scrollable content here -} -``` - ---- - -## Dalamud Integration - -### Dalamud Services - -Dalamud provides various services accessible through constructor injection: - -```csharp -// Common Dalamud services: -IClientState clientState // Player and world state -IFramework framework // Game framework events -IObjectTable objectTable // Game objects (players, NPCs, etc.) -IChatGui chatGui // Chat window access -ICommandManager commandManager // Command registration -IDataManager gameData // Game data sheets -IPluginLog pluginLog // Logging -INotificationManager notificationManager // Toast notifications -IGameGui gameGui // Game UI access -ITextureProvider textureProvider // Icon/texture loading -``` - -### Framework Threading - -**CRITICAL**: Dalamud operates on the **game's main thread**. - -```csharp -// Safe: Already on framework thread -private void OnFrameworkUpdate(IFramework framework) -{ - // Can safely access game state - var player = _clientState.LocalPlayer; -} - -// Unsafe: Called from background thread -private async Task BackgroundWork() -{ - // DO NOT access game state here! - - // Use DalamudUtilService to run on framework thread - await _dalamudUtil.RunOnFrameworkThread(() => - { - // Now safe to access game state - var player = _clientState.LocalPlayer; - }); -} -``` - -### Game Object Access - -```csharp -// Always check for null -var player = _clientState.LocalPlayer; -if (player == null) -{ - return; -} - -// Iterating game objects -foreach (var obj in _objectTable) -{ - if (obj is IPlayerCharacter pc) - { - // Handle player character - } -} - -// Finding specific objects -var target = _targetManager.Target; -if (target is IPlayerCharacter targetPlayer) -{ - // Handle targeted player -} -``` - -### IPC with Other Plugins - -Use the IPC pattern for communicating with other Dalamud plugins: - -```csharp -public sealed class IpcCallerExample : DisposableMediatorSubscriberBase, IIpcCaller -{ - private readonly IDalamudPluginInterface _pi; - private readonly ICallGateSubscriber _apiVersion; - - public string Name => "ExamplePlugin"; - public bool Available { get; private set; } - - public IpcCallerExample( - ILogger logger, - IDalamudPluginInterface pluginInterface, - LightlessMediator mediator) - : base(logger, mediator) - { - _pi = pluginInterface; - - try - { - _apiVersion = _pi.GetIpcSubscriber("ExamplePlugin.GetApiVersion"); - CheckAvailability(); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Failed to initialize IPC for ExamplePlugin"); - } - } - - private void CheckAvailability() - { - try - { - var version = _apiVersion.InvokeFunc(); - Available = version >= 1; - Logger.LogInformation("ExamplePlugin available, version: {Version}", version); - } - catch - { - Available = false; - } - } - - public void DoSomething() - { - if (!Available) - { - Logger.LogWarning("ExamplePlugin not available"); - return; - } - - // Call IPC methods - } -} -``` - ---- - -## Performance Considerations - -### Framework Updates - -The game runs at ~60 FPS. Avoid heavy operations in framework updates: - -```csharp -Mediator.Subscribe(this, OnTick); - -private void OnTick(PriorityFrameworkUpdateMessage _) -{ - // Throttle expensive operations - if ((DateTime.UtcNow - _lastCheck).TotalSeconds < 1) - { - return; - } - _lastCheck = DateTime.UtcNow; - - // Do lightweight work only -} -``` - -### Async Operations - -```csharp -// Good: Non-blocking async -public async Task FetchDataAsync() -{ - var result = await _httpClient.GetAsync(url).ConfigureAwait(false); - return await result.Content.ReadFromJsonAsync().ConfigureAwait(false); -} - -// Bad: Blocking async -public Data FetchDataBlocking() -{ - return FetchDataAsync().GetAwaiter().GetResult(); // Blocks thread! -} -``` - -### Collection Performance - -```csharp -// Good: Thread-safe concurrent collections -private readonly ConcurrentDictionary _cache = new(); - -// Good: Lock-protected regular collections -private readonly List _items = new(); -private readonly object _itemsLock = new(); - -public void AddItem(Item item) -{ - lock (_itemsLock) - { - _items.Add(item); - } -} - -// Good: SemaphoreSlim for async locks -private readonly SemaphoreSlim _asyncLock = new(1); - -public async Task ProcessAsync() -{ - await _asyncLock.WaitAsync().ConfigureAwait(false); - try - { - // Critical section - } - finally - { - _asyncLock.Release(); - } -} -``` - -### Memory Management - -```csharp -// Dispose pattern for unmanaged resources -public class ResourceManager : IDisposable -{ - private bool _disposed; - private readonly SemaphoreSlim _semaphore = new(1); - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (_disposed) return; - - if (disposing) - { - // Dispose managed resources - _semaphore?.Dispose(); - } - - // Free unmanaged resources - - _disposed = true; - } -} -``` - ---- - -## Testing - -### Unit Testing - -```csharp -[TestClass] -public class MyServiceTests -{ - private Mock> _mockLogger; - private Mock _mockMediator; - private MyService _service; - - [TestInitialize] - public void Setup() - { - _mockLogger = new Mock>(); - _mockMediator = new Mock(); - _service = new MyService(_mockLogger.Object, _mockMediator.Object); - } - - [TestMethod] - public void DoWork_WithValidInput_ReturnsExpectedResult() - { - // Arrange - var input = "test"; - - // Act - var result = _service.DoWork(input); - - // Assert - Assert.AreEqual("expected", result); - } -} -``` - -### Integration Testing - -Test with real Dalamud services in a controlled environment: - -```csharp -// Create test harness that mimics Dalamud environment -public class DalamudTestHarness -{ - public IClientState ClientState { get; } - public IFramework Framework { get; } - // ... other services - - public void SimulateFrameworkUpdate() - { - // Trigger framework update event - } -} -``` - ---- - -## Common Patterns - -### Notification Pattern - -```csharp -// Simple notification -Mediator.Publish(new NotificationMessage( - "Title", - "Message body", - NotificationType.Info)); - -// Rich notification with actions -var notification = new LightlessNotification -{ - Id = "unique_id", - Title = "Action Required", - Message = "Do you want to proceed?", - Type = NotificationType.Warning, - Duration = TimeSpan.FromSeconds(10), - Actions = new List - { - new() - { - Id = "confirm", - Label = "Confirm", - Icon = FontAwesomeIcon.Check, - Color = UIColors.Get("LightlessGreen"), - IsPrimary = true, - OnClick = (n) => - { - _logger.LogInformation("User confirmed"); - DoAction(); - n.IsDismissed = true; - } - }, - new() - { - Id = "cancel", - Label = "Cancel", - Icon = FontAwesomeIcon.Times, - OnClick = (n) => n.IsDismissed = true - } - } -}; - -Mediator.Publish(new LightlessNotificationMessage(notification)); -``` - -### Configuration Pattern - -```csharp -public class MyConfigService : ConfigurationServiceBase -{ - public MyConfigService(string configDirectory) - : base(Path.Combine(configDirectory, "myconfig.json")) - { - } -} - -// Usage -_configService.Current.SomeSetting = newValue; -_configService.Save(); -``` - -### Factory Pattern - -```csharp -public class GameObjectHandlerFactory -{ - private readonly DalamudUtilService _dalamudUtil; - private readonly LightlessMediator _mediator; - - public GameObjectHandlerFactory( - DalamudUtilService dalamudUtil, - LightlessMediator mediator) - { - _dalamudUtil = dalamudUtil; - _mediator = mediator; - } - - public GameObjectHandler Create(IntPtr address, string identifier) - { - return new GameObjectHandler( - _dalamudUtil, - _mediator, - () => address, - identifier); - } -} -``` - ---- - -## Error Handling - -### Logging Guidelines - -```csharp -// Debug: Verbose information for debugging -_logger.LogDebug("Processing request {RequestId}", requestId); - -// Information: General informational messages -_logger.LogInformation("User {UserId} connected", userId); - -// Warning: Recoverable issues -_logger.LogWarning("Cache miss for {Key}, will fetch from server", key); - -// Error: Errors that need attention -_logger.LogError(ex, "Failed to process message {MessageId}", messageId); - -// Critical: Application-breaking errors -_logger.LogCritical(ex, "Database connection failed"); -``` - -### Exception Handling - -```csharp -public async Task DoWorkAsync() -{ - try - { - // Risky operation - return await PerformOperationAsync().ConfigureAwait(false); - } - catch (HttpRequestException ex) - { - _logger.LogError(ex, "Network error during operation"); - // Show user-friendly notification - Mediator.Publish(new NotificationMessage( - "Network Error", - "Failed to connect to server. Please check your connection.", - NotificationType.Error)); - return Result.Failure; - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error during operation"); - throw; // Re-throw unexpected exceptions - } -} -``` - ---- - -## Best Practices Summary - -### DO ✅ - -- Use `DisposableMediatorSubscriberBase` for services that subscribe to mediator -- Use `IHostedService` for background services -- Always inject `ILogger` for logging -- Use `ConfigureAwait(false)` for async operations not requiring UI thread -- Dispose of resources properly (SemaphoreSlim, HttpClient, etc.) -- Check for null when accessing Dalamud game objects -- Use thread-safe collections for shared state -- Keep UI logic separate from business logic -- Use mediator for cross-component communication -- Write unit tests for business logic -- Document public APIs with XML comments - -### DON'T ❌ - -- Don't access game objects from background threads without `RunOnFrameworkThread` -- Don't block async methods with `.Result` or `.Wait()` -- Don't store UI state in services (services are often singletons) -- Don't use `async void` except for event handlers -- Don't catch `Exception` without re-throwing or logging -- Don't create circular dependencies between services -- Don't perform heavy operations in framework updates -- Don't forget to unsubscribe from mediator messages -- Don't hardcode file paths (use `IDalamudPluginInterface.ConfigDirectory`) -- Don't use primary constructors (prefer traditional for clarity) - ---- - -## Code Review Checklist - -Before submitting a pull request, ensure: - -- [ ] Code follows naming conventions -- [ ] All new services are registered in `Plugin.cs` -- [ ] Mediator messages are defined in `Messages.cs` -- [ ] Logging is appropriate and informative -- [ ] Thread safety is considered for shared state -- [ ] Disposable resources are properly disposed -- [ ] Async operations use `ConfigureAwait(false)` -- [ ] UI code is separated from business logic -- [ ] No direct game object access from background threads -- [ ] Error handling is comprehensive -- [ ] Performance impact is considered -- [ ] Comments explain "why", not "what" -- [ ] No compiler warnings -- [ ] Tests pass (if applicable) - ---- - -## Resources - -- [Dalamud Documentation](https://dalamud.dev) -- [ImGui.NET Documentation](https://github.com/mellinoe/ImGui.NET) -- [C# Coding Conventions](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions) -- [.NET API Guidelines](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/api-guidelines) - ---- - -**Happy Coding!** 🚀 From b4dd0ee0e1546173939191a9dcd7f6351613e9ac Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 20 Oct 2025 14:32:21 +0200 Subject: [PATCH 06/13] type cleanup --- LightlessSync/Plugin.cs | 6 +-- LightlessSync/UI/LightlessNotificationUI.cs | 46 ++++++++++----------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 54a879e..01a4de4 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -255,9 +255,9 @@ public sealed class Plugin : IDalamudPlugin collection.AddScoped((s) => new BroadcastUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped((s) => new SyncshellFinderUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped(); - collection.AddScoped((s) => - new LightlessNotificationUI( - s.GetRequiredService>(), + collection.AddScoped((s) => + new LightlessNotificationUi( + s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index 2ac26b7..3d2d748 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -15,17 +15,17 @@ using Dalamud.Bindings.ImGui; namespace LightlessSync.UI; -public class LightlessNotificationUI : WindowMediatorSubscriberBase +public class LightlessNotificationUi : WindowMediatorSubscriberBase { - private const float NotificationMinHeight = 60f; - private const float NotificationMaxHeight = 250f; - 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 const float _notificationMinHeight = 60f; + private const float _notificationMaxHeight = 250f; + 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 _notifications = new(); private readonly object _notificationLock = new(); @@ -33,7 +33,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase private readonly Dictionary _notificationYOffsets = new(); private readonly Dictionary _notificationTargetYOffsets = new(); - public LightlessNotificationUI(ILogger logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService) + public LightlessNotificationUi(ILogger logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService) : base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector) { _configService = configService; @@ -155,8 +155,8 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase var width = _configService.Current.NotificationWidth; float posX = corner == NotificationCorner.Left - ? viewport.WorkPos.X + offsetX - WindowPaddingOffset - : viewport.WorkPos.X + viewport.WorkSize.X - width - offsetX - WindowPaddingOffset; + ? viewport.WorkPos.X + offsetX - _windowPaddingOffset + : viewport.WorkPos.X + viewport.WorkSize.X - width - offsetX - _windowPaddingOffset; return new Vector2(posX, viewport.WorkPos.Y); } @@ -274,7 +274,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase else if (notification.IsAnimatingOut && notification.AnimationProgress > 0f) { notification.AnimationProgress = Math.Max(0f, - notification.AnimationProgress - deltaTime * _configService.Current.NotificationAnimationSpeed * OutAnimationSpeedMultiplier); + notification.AnimationProgress - deltaTime * _configService.Current.NotificationAnimationSpeed * _outAnimationSpeedMultiplier); } else if (!notification.IsAnimatingOut && !notification.IsDismissed) { @@ -289,7 +289,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase private Vector2 CalculateSlideOffset(float alpha) { - var distance = (1f - alpha) * SlideAnimationDistance; + var distance = (1f - alpha) * _slideAnimationDistance; var corner = _configService.Current.NotificationCorner; return corner == NotificationCorner.Left ? new Vector2(-distance, 0) : new Vector2(distance, 0); } @@ -466,7 +466,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase private void DrawNotificationText(LightlessNotification notification, float alpha) { - var contentPos = new Vector2(ContentPaddingX, ContentPaddingY); + var contentPos = new Vector2(_contentPaddingX, _contentPaddingY); var windowSize = ImGui.GetWindowSize(); var contentWidth = CalculateContentWidth(windowSize.X); @@ -483,7 +483,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase } private float CalculateContentWidth(float windowWidth) => - windowWidth - (ContentPaddingX * 2); + windowWidth - (_contentPaddingX * 2); private bool HasActions(LightlessNotification notification) => notification.Actions.Count > 0; @@ -491,9 +491,9 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase private void PositionActionsAtBottom(float windowHeight) { var actionHeight = ImGui.GetFrameHeight(); - var bottomY = windowHeight - ContentPaddingY - actionHeight; + var bottomY = windowHeight - _contentPaddingY - actionHeight; ImGui.SetCursorPosY(bottomY); - ImGui.SetCursorPosX(ContentPaddingX); + ImGui.SetCursorPosX(_contentPaddingX); } private float DrawTitle(LightlessNotification notification, float contentWidth, float alpha) @@ -530,7 +530,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase { if (string.IsNullOrEmpty(notification.Message)) return; - var messagePos = contentPos + new Vector2(0f, titleHeight + TitleMessageSpacing); + var messagePos = contentPos + new Vector2(0f, titleHeight + _titleMessageSpacing); var messageColor = new Vector4(0.9f, 0.9f, 0.9f, alpha); ImGui.SetCursorPos(messagePos); @@ -563,13 +563,13 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase private float CalculateActionButtonWidth(int actionCount, float availableWidth) { - var totalSpacing = (actionCount - 1) * ActionButtonSpacing; + var totalSpacing = (actionCount - 1) * _actionButtonSpacing; return (availableWidth - totalSpacing) / actionCount; } private void PositionActionButton(int index, float startX, float buttonWidth) { - var xPosition = startX + index * (buttonWidth + ActionButtonSpacing); + var xPosition = startX + index * (buttonWidth + _actionButtonSpacing); ImGui.SetCursorPosX(xPosition); } @@ -687,7 +687,7 @@ public class LightlessNotificationUI : WindowMediatorSubscriberBase height += 12f; } - return Math.Clamp(height, NotificationMinHeight, NotificationMaxHeight); + return Math.Clamp(height, _notificationMinHeight, _notificationMaxHeight); } private float CalculateTitleHeight(LightlessNotification notification, float contentWidth) From 4f5ef8ff4b0b6811f9c2cf1e31f4bceb7d9d3854 Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 20 Oct 2025 14:51:10 +0200 Subject: [PATCH 07/13] type cleanup --- LightlessSync/UI/UpdateNotesUi.cs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/LightlessSync/UI/UpdateNotesUi.cs b/LightlessSync/UI/UpdateNotesUi.cs index f7544e1..bc60ab5 100644 --- a/LightlessSync/UI/UpdateNotesUi.cs +++ b/LightlessSync/UI/UpdateNotesUi.cs @@ -52,12 +52,12 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase private float _particleSpawnTimer; private readonly Random _random = new(); - private const float HeaderHeight = 150f; - private const float ParticleSpawnInterval = 0.2f; - private const int MaxParticles = 50; - private const int MaxTrailLength = 50; - private const float EdgeFadeDistance = 30f; - private const float ExtendedParticleHeight = 40f; + private const float _headerHeight = 150f; + private const float _particleSpawnInterval = 0.2f; + private const int _maxParticles = 50; + private const int _maxTrailLength = 50; + private const float _edgeFadeDistance = 30f; + private const float _extendedParticleHeight = 40f; public UpdateNotesUi(ILogger logger, LightlessMediator mediator, @@ -111,16 +111,16 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase var headerWidth = (800f * ImGuiHelpers.GlobalScale) - (windowPadding.X * 2); var headerStart = windowPos + new Vector2(windowPadding.X, windowPadding.Y); - var headerEnd = headerStart + new Vector2(headerWidth, HeaderHeight); + var headerEnd = headerStart + new Vector2(headerWidth, _headerHeight); - var extendedParticleSize = new Vector2(headerWidth, HeaderHeight + ExtendedParticleHeight); + var extendedParticleSize = new Vector2(headerWidth, _headerHeight + _extendedParticleHeight); DrawGradientBackground(headerStart, headerEnd); DrawHeaderText(headerStart); DrawHeaderButtons(headerStart, headerWidth); DrawBottomGradient(headerStart, headerEnd, headerWidth); - ImGui.SetCursorPosY(windowPadding.Y + HeaderHeight + 5); + ImGui.SetCursorPosY(windowPadding.Y + _headerHeight + 5); ImGui.SetCursorPosX(20); using (ImRaii.PushFont(UiBuilder.IconFont)) { @@ -260,7 +260,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase var deltaTime = ImGui.GetIO().DeltaTime; _particleSpawnTimer += deltaTime; - if (_particleSpawnTimer > ParticleSpawnInterval && _particles.Count < MaxParticles) + if (_particleSpawnTimer > _particleSpawnInterval && _particles.Count < _maxParticles) { SpawnParticle(bannerSize); _particleSpawnTimer = 0f; @@ -282,7 +282,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase if (particle.Type == ParticleType.ShootingStar && particle.Trail != null) { particle.Trail.Insert(0, particle.Position); - if (particle.Trail.Count > MaxTrailLength) + if (particle.Trail.Count > _maxTrailLength) particle.Trail.RemoveAt(particle.Trail.Count - 1); } @@ -316,12 +316,12 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase var lifeFade = Math.Min(fadeIn, fadeOut); var edgeFadeX = Math.Min( - Math.Min(1f, (particle.Position.X + EdgeFadeDistance) / EdgeFadeDistance), - Math.Min(1f, (bannerSize.X - particle.Position.X + EdgeFadeDistance) / EdgeFadeDistance) + Math.Min(1f, (particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance), + Math.Min(1f, (bannerSize.X - particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance) ); var edgeFadeY = Math.Min( - Math.Min(1f, (particle.Position.Y + EdgeFadeDistance) / EdgeFadeDistance), - Math.Min(1f, (bannerSize.Y - particle.Position.Y + EdgeFadeDistance) / EdgeFadeDistance) + Math.Min(1f, (particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance), + Math.Min(1f, (bannerSize.Y - particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance) ); var edgeFade = Math.Min(edgeFadeX, edgeFadeY); From 147baa4c1bd5c8fac31989b71f70bd15e02774cc Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 20 Oct 2025 21:16:30 +0200 Subject: [PATCH 08/13] api cleanup, decline message on notification decline --- LightlessSync/Services/NotificationService.cs | 2 +- LightlessSync/Services/PairRequestService.cs | 6 +++++- LightlessSync/WebAPI/SignalR/ApiController.cs | 2 -- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/LightlessSync/Services/NotificationService.cs b/LightlessSync/Services/NotificationService.cs index 56da126..9e20064 100644 --- a/LightlessSync/Services/NotificationService.cs +++ b/LightlessSync/Services/NotificationService.cs @@ -568,7 +568,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ senderName, request.HashedCid, onAccept: () => _pairRequestService.AcceptPairRequest(request.HashedCid, senderName), - onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid)); + onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid, senderName)); } private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _) diff --git a/LightlessSync/Services/PairRequestService.cs b/LightlessSync/Services/PairRequestService.cs index f2ee64f..7190825 100644 --- a/LightlessSync/Services/PairRequestService.cs +++ b/LightlessSync/Services/PairRequestService.cs @@ -220,9 +220,13 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase }); } - public void DeclinePairRequest(string hashedCid) + public void DeclinePairRequest(string hashedCid, string displayName) { RemoveRequest(hashedCid); + Mediator.Publish(new NotificationMessage("Pair request declined", + "Declined " + displayName + "'s pending pair request.", + NotificationType.Info, + TimeSpan.FromSeconds(3))); Logger.LogDebug("Declined pair request from {HashedCid}", hashedCid); } diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index 56ab36e..15aef29 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -32,7 +32,6 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL private readonly ServerConfigurationManager _serverManager; private readonly TokenProvider _tokenProvider; private readonly LightlessConfigService _lightlessConfigService; - private readonly NotificationService _lightlessNotificationService; private CancellationTokenSource _connectionCancellationTokenSource; private ConnectionDto? _connectionDto; private bool _doNotNotifyOnNextInfo = false; @@ -54,7 +53,6 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL _serverManager = serverManager; _tokenProvider = tokenProvider; _lightlessConfigService = lightlessConfigService; - _lightlessNotificationService = lightlessNotificationService; _connectionCancellationTokenSource = new CancellationTokenSource(); Mediator.Subscribe(this, (_) => DalamudUtilOnLogIn()); From ee20b6fa5f9808b0112570073dccae080936a942 Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 20 Oct 2025 21:25:28 +0200 Subject: [PATCH 09/13] version 1.12.3 --- LightlessSync/LightlessSync.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 42fb61b..b4b5288 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -3,7 +3,7 @@ - 1.12.2.6 + 1.12.3 https://github.com/Light-Public-Syncshells/LightlessClient From a32ac02c6d00a4178266b6f84e234f50ca04fe98 Mon Sep 17 00:00:00 2001 From: choco Date: Tue, 21 Oct 2025 09:59:30 +0200 Subject: [PATCH 10/13] download notification stuck fix --- LightlessSync/Services/NotificationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/Services/NotificationService.cs b/LightlessSync/Services/NotificationService.cs index 9e20064..72f4a16 100644 --- a/LightlessSync/Services/NotificationService.cs +++ b/LightlessSync/Services/NotificationService.cs @@ -608,7 +608,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ Mediator.Publish(new LightlessNotificationMessage(notification)); - if (AreAllDownloadsCompleted(userDownloads)) + if (userDownloads.Count == 0 || AreAllDownloadsCompleted(userDownloads)) { Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); } From 7aadbcec10719d28fdee25a4b7ddd0dab9d9eaa4 Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 21 Oct 2025 11:22:19 -0500 Subject: [PATCH 11/13] Wording changes --- LightlessSync/Changelog/changelog.yaml | 1 + LightlessSync/Changelog/credits.yaml | 12 ++++++------ LightlessSync/UI/EditProfileUi.cs | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/LightlessSync/Changelog/changelog.yaml b/LightlessSync/Changelog/changelog.yaml index db2397d..7055a90 100644 --- a/LightlessSync/Changelog/changelog.yaml +++ b/LightlessSync/Changelog/changelog.yaml @@ -25,6 +25,7 @@ changelog: - "More customizable notification options." - "Perfomance limiter shows as notifications." - "All notifications can be configured or disabled in Settings → Notifications." + - "Cleaning up notifications implementation" - number: "Bugfixes" icon: "" items: diff --git a/LightlessSync/Changelog/credits.yaml b/LightlessSync/Changelog/credits.yaml index b04b1e6..d685978 100644 --- a/LightlessSync/Changelog/credits.yaml +++ b/LightlessSync/Changelog/credits.yaml @@ -51,12 +51,12 @@ credits: role: "Height offset integration" - name: "Honorific Team" role: "Title system integration" - - name: "Moodles Team" - role: "Status effect integration" - - name: "PetNicknames Team" - role: "Pet naming integration" - - name: "Brio Team" - role: "GPose enhancement integration" + - name: "Glyceri" + role: "Moodles - Status effect integration" + - name: "Glyceri" + role: "PetNicknames - Pet naming integration" + - name: "Minmoose" + role: "Brio - GPose enhancement integration" - category: "Special Thanks" items: diff --git a/LightlessSync/UI/EditProfileUi.cs b/LightlessSync/UI/EditProfileUi.cs index 44c314a..3dd8725 100644 --- a/LightlessSync/UI/EditProfileUi.cs +++ b/LightlessSync/UI/EditProfileUi.cs @@ -281,7 +281,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase { _uiSharedService.MediumText("Supporter Vanity Settings", UIColors.Get("LightlessPurple")); ImGui.Dummy(new Vector2(4)); - _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"), "Must be a supporter through Patreon/Ko-fi to access these settings."); + _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"), "Must be a supporter through Patreon/Ko-fi to access these settings. If you have the vanity role, you must interact with the Discord bot first."); var hasVanity = _apiController.HasVanity; From 1a89c2caeef874c00b816faea4bbd31fedfa248c Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Wed, 22 Oct 2025 03:20:13 +0900 Subject: [PATCH 12/13] some caching stuff and bug fixes --- LightlessSync/Services/CharacterAnalyzer.cs | 58 +++++- LightlessSync/UI/CompactUI.cs | 144 +++++++------- LightlessSync/UI/Components/DrawUserPair.cs | 187 ++++++++++++++---- LightlessSync/UI/Handlers/IdDisplayHandler.cs | 2 +- LightlessSync/Utils/SeStringUtils.cs | 44 ++++- 5 files changed, 316 insertions(+), 119 deletions(-) diff --git a/LightlessSync/Services/CharacterAnalyzer.cs b/LightlessSync/Services/CharacterAnalyzer.cs index c35fd01..27235f6 100644 --- a/LightlessSync/Services/CharacterAnalyzer.cs +++ b/LightlessSync/Services/CharacterAnalyzer.cs @@ -6,7 +6,11 @@ using LightlessSync.UI; using LightlessSync.Utils; using Lumina.Data.Files; using Microsoft.Extensions.Logging; - +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace LightlessSync.Services; public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable @@ -16,6 +20,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable private CancellationTokenSource? _analysisCts; private CancellationTokenSource _baseAnalysisCts = new(); private string _lastDataHash = string.Empty; + private CharacterAnalysisSummary _latestSummary = CharacterAnalysisSummary.Empty; public CharacterAnalyzer(ILogger logger, LightlessMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer) : base(logger, mediator) @@ -34,6 +39,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable public bool IsAnalysisRunning => _analysisCts != null; public int TotalFiles { get; internal set; } internal Dictionary> LastAnalysis { get; } = []; + public CharacterAnalysisSummary LatestSummary => _latestSummary; public void CancelAnalyze() { @@ -80,6 +86,8 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable } } + RecalculateSummary(); + Mediator.Publish(new CharacterDataAnalyzedMessage()); _analysisCts.CancelDispose(); @@ -137,11 +145,39 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable LastAnalysis[obj.Key] = data; } + RecalculateSummary(); + Mediator.Publish(new CharacterDataAnalyzedMessage()); _lastDataHash = charaData.DataHash.Value; } + private void RecalculateSummary() + { + var builder = ImmutableDictionary.CreateBuilder(); + + foreach (var (objectKind, entries) in LastAnalysis) + { + long totalTriangles = 0; + long texOriginalBytes = 0; + long texCompressedBytes = 0; + + foreach (var entry in entries.Values) + { + totalTriangles += entry.Triangles; + if (string.Equals(entry.FileType, "tex", StringComparison.OrdinalIgnoreCase)) + { + texOriginalBytes += entry.OriginalSize; + texCompressedBytes += entry.CompressedSize; + } + } + + builder[objectKind] = new CharacterAnalysisObjectSummary(entries.Count, totalTriangles, texOriginalBytes, texCompressedBytes); + } + + _latestSummary = new CharacterAnalysisSummary(builder.ToImmutable()); + } + private void PrintAnalysis() { if (LastAnalysis.Count == 0) return; @@ -232,4 +268,24 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable } }); } +} + +public readonly record struct CharacterAnalysisObjectSummary(int EntryCount, long TotalTriangles, long TexOriginalBytes, long TexCompressedBytes) +{ + public bool HasEntries => EntryCount > 0; +} + +public sealed class CharacterAnalysisSummary +{ + public static CharacterAnalysisSummary Empty { get; } = + new(ImmutableDictionary.Empty); + + internal CharacterAnalysisSummary(IImmutableDictionary objects) + { + Objects = objects; + } + + public IImmutableDictionary Objects { get; } + + public bool HasData => Objects.Any(kvp => kvp.Value.HasEntries); } \ No newline at end of file diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index c264681..0700de3 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -56,7 +56,6 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly BroadcastService _broadcastService; private List _drawFolders; - private Dictionary>? _cachedAnalysis; private Pair? _lastAddedUser; private string _lastAddedUserComment = string.Empty; private Vector2 _lastPosition = Vector2.One; @@ -382,15 +381,26 @@ public class CompactUi : WindowMediatorSubscriberBase _uiSharedService.IconText(FontAwesomeIcon.Upload); ImGui.SameLine(35 * ImGuiHelpers.GlobalScale); - if (currentUploads.Any()) + if (currentUploads.Count > 0) { - var totalUploads = currentUploads.Count; + int totalUploads = currentUploads.Count; + int doneUploads = 0; + long totalUploaded = 0; + long totalToUpload = 0; - var doneUploads = currentUploads.Count(c => c.IsTransferred); - var activeUploads = currentUploads.Count(c => !c.IsTransferred); + foreach (var upload in currentUploads) + { + if (upload.IsTransferred) + { + doneUploads++; + } + + totalUploaded += upload.Transferred; + totalToUpload += upload.Total; + } + + int activeUploads = totalUploads - doneUploads; var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8); - var totalUploaded = currentUploads.Sum(c => c.Transferred); - var totalToUpload = currentUploads.Sum(c => c.Total); ImGui.TextUnformatted($"{doneUploads}/{totalUploads} (slots {activeUploads}/{uploadSlotLimit})"); var uploadText = $"({UiSharedService.ByteToString(totalUploaded)}/{UiSharedService.ByteToString(totalToUpload)})"; @@ -405,17 +415,17 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.TextUnformatted("No uploads in progress"); } - var currentDownloads = BuildCurrentDownloadSnapshot(); + var downloadSummary = GetDownloadSummary(); ImGui.AlignTextToFramePadding(); _uiSharedService.IconText(FontAwesomeIcon.Download); ImGui.SameLine(35 * ImGuiHelpers.GlobalScale); - if (currentDownloads.Any()) + if (downloadSummary.HasDownloads) { - var totalDownloads = currentDownloads.Sum(c => c.TotalFiles); - var doneDownloads = currentDownloads.Sum(c => c.TransferredFiles); - var totalDownloaded = currentDownloads.Sum(c => c.TransferredBytes); - var totalToDownload = currentDownloads.Sum(c => c.TotalBytes); + var totalDownloads = downloadSummary.TotalFiles; + var doneDownloads = downloadSummary.TransferredFiles; + var totalDownloaded = downloadSummary.TransferredBytes; + var totalToDownload = downloadSummary.TotalBytes; ImGui.TextUnformatted($"{doneDownloads}/{totalDownloads}"); var downloadText = @@ -433,27 +443,35 @@ public class CompactUi : WindowMediatorSubscriberBase } - private List BuildCurrentDownloadSnapshot() + private DownloadSummary GetDownloadSummary() { - List snapshot = new(); + long totalBytes = 0; + long transferredBytes = 0; + int totalFiles = 0; + int transferredFiles = 0; foreach (var kvp in _currentDownloads.ToArray()) { - var value = kvp.Value; - if (value == null || value.Count == 0) + if (kvp.Value is not { Count: > 0 } statuses) + { continue; - - try - { - snapshot.AddRange(value.Values.ToArray()); } - catch (System.ArgumentException) + + foreach (var status in statuses.Values) { - // skibidi + totalBytes += status.TotalBytes; + transferredBytes += status.TransferredBytes; + totalFiles += status.TotalFiles; + transferredFiles += status.TransferredFiles; } } - return snapshot; + return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes); + } + + private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes) + { + public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0; } private void DrawUIDHeader() @@ -480,7 +498,7 @@ public class CompactUi : WindowMediatorSubscriberBase } //Getting information of character and triangles threshold to show overlimit status in UID bar. - _cachedAnalysis = _characterAnalyzer.LastAnalysis.DeepClone(); + var analysisSummary = _characterAnalyzer.LatestSummary; Vector2 uidTextSize, iconSize; using (_uiSharedService.UidFont.Push()) @@ -509,6 +527,7 @@ public class CompactUi : WindowMediatorSubscriberBase if (ImGui.IsItemHovered()) { ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f); ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("PairBlue")); ImGui.Text("Lightfinder"); @@ -556,6 +575,7 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.PopStyleColor(); } + ImGui.PopTextWrapPos(); ImGui.EndTooltip(); } @@ -574,7 +594,7 @@ public class CompactUi : WindowMediatorSubscriberBase var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor); var cursorPos = ImGui.GetCursorScreenPos(); var fontPtr = ImGui.GetFont(); - SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr); + SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr, "uid-header"); } else { @@ -591,56 +611,40 @@ public class CompactUi : WindowMediatorSubscriberBase UiSharedService.AttachToolTip("Click to copy"); - if (_cachedAnalysis != null && _apiController.ServerState is ServerState.Connected) + if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData) { - var firstEntry = _cachedAnalysis.FirstOrDefault(); - var valueDict = firstEntry.Value; - if (valueDict != null && valueDict.Count > 0) + var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries); + if (objectSummary.HasEntries) { - var groupedfiles = valueDict - .Select(v => v.Value) - .Where(v => v != null) - .GroupBy(f => f.FileType, StringComparer.Ordinal) - .OrderBy(k => k.Key, StringComparer.Ordinal) - .ToList(); + var actualVramUsage = objectSummary.TexOriginalBytes; + var actualTriCount = objectSummary.TotalTriangles; - var actualTriCount = valueDict - .Select(v => v.Value) - .Where(v => v != null) - .Sum(f => f.Triangles); + var isOverVRAMUsage = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < actualVramUsage; + var isOverTriHold = actualTriCount > (_playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000); - if (groupedfiles != null) + if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds) { - //Checking of VRAM threshhold - var texGroup = groupedfiles.SingleOrDefault(v => string.Equals(v.Key, "tex", StringComparison.Ordinal)); - var actualVramUsage = texGroup != null ? texGroup.Sum(f => f.OriginalSize) : 0L; - var isOverVRAMUsage = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < actualVramUsage; - var isOverTriHold = actualTriCount > (_playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000); + ImGui.SameLine(); + ImGui.SetCursorPosY(cursorY + 15f); + _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")); - if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds) + string warningMessage = ""; + if (isOverTriHold) { - ImGui.SameLine(); - ImGui.SetCursorPosY(cursorY + 15f); - _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")); + warningMessage += $"You exceed your own triangles threshold by " + + $"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles."; + warningMessage += Environment.NewLine; - string warningMessage = ""; - if (isOverTriHold) - { - warningMessage += $"You exceed your own triangles threshold by " + - $"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles."; - warningMessage += Environment.NewLine; - - } - if (isOverVRAMUsage) - { - warningMessage += $"You exceed your own VRAM threshold by " + - $"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}."; - } - UiSharedService.AttachToolTip(warningMessage); - if (ImGui.IsItemClicked()) - { - _lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi))); - } + } + if (isOverVRAMUsage) + { + warningMessage += $"You exceed your own VRAM threshold by " + + $"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}."; + } + UiSharedService.AttachToolTip(warningMessage); + if (ImGui.IsItemClicked()) + { + _lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi))); } } } @@ -663,7 +667,7 @@ public class CompactUi : WindowMediatorSubscriberBase var seString = SeStringUtils.BuildFormattedPlayerName(_apiController.UID, vanityTextColor, vanityGlowColor); var cursorPos = ImGui.GetCursorScreenPos(); var fontPtr = ImGui.GetFont(); - SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr); + SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr, "uid-footer"); } else { @@ -921,4 +925,4 @@ public class CompactUi : WindowMediatorSubscriberBase _wasOpen = IsOpen; IsOpen = false; } -} \ No newline at end of file +} diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index fa5022e..4c4c1d4 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -2,6 +2,7 @@ using Dalamud.Interface; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; @@ -13,6 +14,9 @@ using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Handlers; using LightlessSync.Utils; using LightlessSync.WebAPI; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text; namespace LightlessSync.UI.Components; @@ -32,6 +36,8 @@ public class DrawUserPair private readonly CharaDataManager _charaDataManager; private float _menuWidth = -1; private bool _wasHovered = false; + private TooltipSnapshot _tooltipSnapshot = TooltipSnapshot.Empty; + private string _cachedTooltip = string.Empty; public DrawUserPair(string id, Pair entry, List syncedGroups, GroupFullInfoDto? currentGroup, @@ -190,15 +196,12 @@ public class DrawUserPair private void DrawLeftSide() { - string userPairText = string.Empty; - ImGui.AlignTextToFramePadding(); if (_pair.IsPaused) { using var _ = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); _uiSharedService.IconText(FontAwesomeIcon.PauseCircle); - userPairText = _pair.UserData.AliasOrUID + " is paused"; } else if (!_pair.IsOnline) { @@ -207,12 +210,10 @@ public class DrawUserPair ? FontAwesomeIcon.ArrowsLeftRight : (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional ? FontAwesomeIcon.User : FontAwesomeIcon.Users)); - userPairText = _pair.UserData.AliasOrUID + " is offline"; } else if (_pair.IsVisible) { _uiSharedService.IconText(FontAwesomeIcon.Eye, UIColors.Get("LightlessBlue")); - userPairText = _pair.UserData.AliasOrUID + " is visible: " + _pair.PlayerName + Environment.NewLine + "Click to target this player"; if (ImGui.IsItemClicked()) { _mediator.Publish(new TargetPairMessage(_pair)); @@ -223,46 +224,9 @@ public class DrawUserPair using var _ = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("PairBlue")); _uiSharedService.IconText(_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional ? FontAwesomeIcon.User : FontAwesomeIcon.Users); - userPairText = _pair.UserData.AliasOrUID + " is online"; } - if (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.OneSided) - { - userPairText += UiSharedService.TooltipSeparator + "User has not added you back"; - } - else if (_pair.IndividualPairStatus == API.Data.Enum.IndividualPairStatus.Bidirectional) - { - userPairText += UiSharedService.TooltipSeparator + "You are directly Paired"; - } - - if (_pair.LastAppliedDataBytes >= 0) - { - userPairText += UiSharedService.TooltipSeparator; - userPairText += ((!_pair.IsPaired) ? "(Last) " : string.Empty) + "Mods Info" + Environment.NewLine; - userPairText += "Files Size: " + UiSharedService.ByteToString(_pair.LastAppliedDataBytes, true); - if (_pair.LastAppliedApproximateVRAMBytes >= 0) - { - userPairText += Environment.NewLine + "Approx. VRAM Usage: " + UiSharedService.ByteToString(_pair.LastAppliedApproximateVRAMBytes, true); - } - if (_pair.LastAppliedDataTris >= 0) - { - userPairText += Environment.NewLine + "Approx. Triangle Count (excl. Vanilla): " - + (_pair.LastAppliedDataTris > 1000 ? (_pair.LastAppliedDataTris / 1000d).ToString("0.0'k'") : _pair.LastAppliedDataTris); - } - } - - if (_syncedGroups.Any()) - { - userPairText += UiSharedService.TooltipSeparator + string.Join(Environment.NewLine, - _syncedGroups.Select(g => - { - var groupNote = _serverConfigurationManager.GetNoteForGid(g.GID); - var groupString = string.IsNullOrEmpty(groupNote) ? g.GroupAliasOrGID : $"{groupNote} ({g.GroupAliasOrGID})"; - return "Paired through " + groupString; - })); - } - - UiSharedService.AttachToolTip(userPairText); + UiSharedService.AttachToolTip(GetUserTooltip()); if (_performanceConfigService.Current.ShowPerformanceIndicator && !_performanceConfigService.Current.UIDsToIgnore @@ -327,6 +291,143 @@ public class DrawUserPair _displayHandler.DrawPairText(_id, _pair, leftSide, () => rightSide - leftSide); } + private string GetUserTooltip() + { + List? groupDisplays = null; + if (_syncedGroups.Count > 0) + { + groupDisplays = new List(_syncedGroups.Count); + foreach (var group in _syncedGroups) + { + var groupNote = _serverConfigurationManager.GetNoteForGid(group.GID); + groupDisplays.Add(string.IsNullOrEmpty(groupNote) ? group.GroupAliasOrGID : $"{groupNote} ({group.GroupAliasOrGID})"); + } + } + + var snapshot = new TooltipSnapshot( + _pair.IsPaused, + _pair.IsOnline, + _pair.IsVisible, + _pair.IndividualPairStatus, + _pair.UserData.AliasOrUID, + _pair.PlayerName ?? string.Empty, + _pair.LastAppliedDataBytes, + _pair.LastAppliedApproximateVRAMBytes, + _pair.LastAppliedDataTris, + _pair.IsPaired, + groupDisplays is null ? ImmutableArray.Empty : ImmutableArray.CreateRange(groupDisplays)); + + if (!_tooltipSnapshot.Equals(snapshot)) + { + _cachedTooltip = BuildTooltip(snapshot); + _tooltipSnapshot = snapshot; + } + + return _cachedTooltip; + } + + private static string BuildTooltip(in TooltipSnapshot snapshot) + { + var builder = new StringBuilder(256); + + if (snapshot.IsPaused) + { + builder.Append(snapshot.AliasOrUid); + builder.Append(" is paused"); + } + else if (!snapshot.IsOnline) + { + builder.Append(snapshot.AliasOrUid); + builder.Append(" is offline"); + } + else if (snapshot.IsVisible) + { + builder.Append(snapshot.AliasOrUid); + builder.Append(" is visible: "); + builder.Append(snapshot.PlayerName); + builder.Append(Environment.NewLine); + builder.Append("Click to target this player"); + } + else + { + builder.Append(snapshot.AliasOrUid); + builder.Append(" is online"); + } + + if (snapshot.PairStatus == IndividualPairStatus.OneSided) + { + builder.Append(UiSharedService.TooltipSeparator); + builder.Append("User has not added you back"); + } + else if (snapshot.PairStatus == IndividualPairStatus.Bidirectional) + { + builder.Append(UiSharedService.TooltipSeparator); + builder.Append("You are directly Paired"); + } + + if (snapshot.LastAppliedDataBytes >= 0) + { + builder.Append(UiSharedService.TooltipSeparator); + if (!snapshot.IsPaired) + { + builder.Append("(Last) "); + } + builder.Append("Mods Info"); + builder.Append(Environment.NewLine); + builder.Append("Files Size: "); + builder.Append(UiSharedService.ByteToString(snapshot.LastAppliedDataBytes, true)); + + if (snapshot.LastAppliedApproximateVRAMBytes >= 0) + { + builder.Append(Environment.NewLine); + builder.Append("Approx. VRAM Usage: "); + builder.Append(UiSharedService.ByteToString(snapshot.LastAppliedApproximateVRAMBytes, true)); + } + + if (snapshot.LastAppliedDataTris >= 0) + { + builder.Append(Environment.NewLine); + builder.Append("Approx. Triangle Count (excl. Vanilla): "); + builder.Append(snapshot.LastAppliedDataTris > 1000 + ? (snapshot.LastAppliedDataTris / 1000d).ToString("0.0'k'") + : snapshot.LastAppliedDataTris); + } + } + + if (!snapshot.GroupDisplays.IsEmpty) + { + builder.Append(UiSharedService.TooltipSeparator); + for (int i = 0; i < snapshot.GroupDisplays.Length; i++) + { + if (i > 0) + { + builder.Append(Environment.NewLine); + } + builder.Append("Paired through "); + builder.Append(snapshot.GroupDisplays[i]); + } + } + + return builder.ToString(); + } + + private readonly record struct TooltipSnapshot( + bool IsPaused, + bool IsOnline, + bool IsVisible, + IndividualPairStatus PairStatus, + string AliasOrUid, + string PlayerName, + long LastAppliedDataBytes, + long LastAppliedApproximateVRAMBytes, + long LastAppliedDataTris, + bool IsPaired, + ImmutableArray GroupDisplays) + { + public static TooltipSnapshot Empty { get; } = + new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, false, ImmutableArray.Empty); + } + private void DrawPairedClientMenu() { DrawIndividualMenu(); diff --git a/LightlessSync/UI/Handlers/IdDisplayHandler.cs b/LightlessSync/UI/Handlers/IdDisplayHandler.cs index 01f0df6..4d362a9 100644 --- a/LightlessSync/UI/Handlers/IdDisplayHandler.cs +++ b/LightlessSync/UI/Handlers/IdDisplayHandler.cs @@ -157,7 +157,7 @@ public class IdDisplayHandler Vector2 textSize; using (ImRaii.PushFont(font, textIsUid)) { - SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, font); + SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, font, pair.UserData.UID); itemMin = ImGui.GetItemRectMin(); itemMax = ImGui.GetItemRectMax(); //textSize = itemMax - itemMin; diff --git a/LightlessSync/Utils/SeStringUtils.cs b/LightlessSync/Utils/SeStringUtils.cs index a19a343..7507515 100644 --- a/LightlessSync/Utils/SeStringUtils.cs +++ b/LightlessSync/Utils/SeStringUtils.cs @@ -7,6 +7,7 @@ using Dalamud.Interface.Utility; using Lumina.Text; using System; using System.Numerics; +using System.Threading; using DalamudSeString = Dalamud.Game.Text.SeStringHandling.SeString; using DalamudSeStringBuilder = Dalamud.Game.Text.SeStringHandling.SeStringBuilder; using LuminaSeStringBuilder = Lumina.Text.SeStringBuilder; @@ -15,6 +16,9 @@ namespace LightlessSync.Utils; public static class SeStringUtils { + private static int _seStringHitboxCounter; + private static int _iconHitboxCounter; + public static DalamudSeString BuildFormattedPlayerName(string text, Vector4? textColor, Vector4? glowColor) { var b = new DalamudSeStringBuilder(); @@ -119,7 +123,7 @@ public static class SeStringUtils ImGui.Dummy(new Vector2(0f, textSize.Y)); } - public static Vector2 RenderSeStringWithHitbox(DalamudSeString seString, Vector2 position, ImFontPtr? font = null) + public static Vector2 RenderSeStringWithHitbox(DalamudSeString seString, Vector2 position, ImFontPtr? font = null, string? id = null) { var drawList = ImGui.GetWindowDrawList(); @@ -137,12 +141,28 @@ public static class SeStringUtils var textSize = ImGui.CalcTextSize(seString.TextValue); ImGui.SetCursorScreenPos(position); - ImGui.InvisibleButton($"##hitbox_{Guid.NewGuid()}", textSize); + if (id is not null) + { + ImGui.PushID(id); + } + else + { + ImGui.PushID(Interlocked.Increment(ref _seStringHitboxCounter)); + } + + try + { + ImGui.InvisibleButton("##hitbox", textSize); + } + finally + { + ImGui.PopID(); + } return textSize; } - public static Vector2 RenderIconWithHitbox(int iconId, Vector2 position, ImFontPtr? font = null) + public static Vector2 RenderIconWithHitbox(int iconId, Vector2 position, ImFontPtr? font = null, string? id = null) { var drawList = ImGui.GetWindowDrawList(); @@ -158,7 +178,23 @@ public static class SeStringUtils var drawResult = ImGuiHelpers.CompileSeStringWrapped(iconMacro, drawParams); ImGui.SetCursorScreenPos(position); - ImGui.InvisibleButton($"##iconHitbox_{Guid.NewGuid()}", drawResult.Size); + if (id is not null) + { + ImGui.PushID(id); + } + else + { + ImGui.PushID(Interlocked.Increment(ref _iconHitboxCounter)); + } + + try + { + ImGui.InvisibleButton("##iconHitbox", drawResult.Size); + } + finally + { + ImGui.PopID(); + } return drawResult.Size; } From 6bb00c50d81cfdf21ac9ab1c4b0a021081845064 Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Wed, 22 Oct 2025 03:33:51 +0900 Subject: [PATCH 13/13] improve logging fallback --- .../WebAPI/Files/FileDownloadManager.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index cc82d04..b8f81f2 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -215,6 +215,26 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase await Task.Delay(retryDelay, ct).ConfigureAwait(false); } + catch (TaskCanceledException ex) when (!ct.IsCancellationRequested) + { + response?.Dispose(); + retryCount++; + + Logger.LogWarning(ex, "Cancellation/timeout during download of {requestUrl}. Attempt {attempt} of {maxRetries}", requestUrl, retryCount, maxRetries); + + if (retryCount >= maxRetries) + { + Logger.LogError("Max retries reached for {requestUrl} after TaskCanceledException", requestUrl); + throw; + } + + await Task.Delay(retryDelay, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + response?.Dispose(); + throw; + } catch (HttpRequestException ex) { response?.Dispose();