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; }