Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Codex Rules for EasyBluetoothAudio

## Workflow Orchestration

### Planning
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
- If something goes sideways, STOP and re-plan immediately — don't keep pushing
- Use plan mode for verification steps, not just building
- Write detailed specs upfront to reduce ambiguity

### Subagent Strategy
- Use subagents liberally to keep main context window clean
- Offload research, exploration, and parallel analysis to subagents
- For complex problems, throw more compute at it via subagents
- One task per subagent for focused execution

### Self-Improvement Loop
- After ANY correction from the user: update `tasks/lessons.md` with the pattern
- Write rules that prevent the same mistake from recurring
- Ruthlessly iterate on these lessons until mistake rate drops
- Review lessons at session start for relevant context

### Verification Before Done
- Never mark a task complete without proving it works
- Diff behavior between main and your changes when relevant
- Ask: "Would a staff engineer approve this?"
- Run tests, check logs, demonstrate correctness

### Elegance (Balanced)
- For non-trivial changes: pause and ask "is there a more elegant way?"
- If a fix feels hacky: implement the elegant solution instead
- Skip for simple, obvious fixes — don't over-engineer

### Autonomous Bug Fixing
- When given a bug report: just fix it. Don't ask for hand-holding
- Point at logs, errors, failing tests — then resolve them
- Zero context switching required from the user

---

## Task Management

1. **Plan First**: Write plan to `tasks/todo.md` with checkable items
2. **Verify Plan**: Check in before starting implementation
3. **Track Progress**: Mark items complete as you go
4. **Explain Changes**: High-level summary at each step
5. **Document Results**: Add review section to `tasks/todo.md`
6. **Capture Lessons**: Update `tasks/lessons.md` after corrections

---

## Core Principles

- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs.

---

## WPF MVVM & Clean Code Design Guide

### 1. General Principles

- **Microsoft Guidelines**: Follow official C# and .NET design guidelines (naming, coding style).
- **Zero Warnings**: Code must compile with zero errors and zero warnings.
- Do not suppress warnings unless absolutely necessary (justify via pragma comment).
- Fix warnings about missing comments, unused variables, or unreachable code through refactoring.
- **Commit Rules**:
- Never commit broken or non-compiling code.
- Commit messages must be meaningful and linked to the respective DevOps item or ticket.

### 2. Refactoring & Clean Code

- **Boy Scout Rule**: Always leave the code cleaner than you found it. Refactor whenever you touch code.
- **SOLID Principles**: Classes must have a single responsibility; program against interfaces, not concrete implementations.
- **Coordination**: Changes affecting cross-domain interfaces, database structures, or global configs must be coordinated with the team before implementation.

### 3. Formatting & Comments

- **Curly Brackets — Mandatory**: Every loop or conditional (if/else) must use curly brackets, even for single lines.
- Correct: `if (a == b) { DoSomething(); }`
- Incorrect: `if (a == b) DoSomething();`
- **XML Documentation — Strict Rule**:
- Every public class, method, variable, property, and enum must have a detailed `/// <summary>`.
- For methods: document all `<param>`, `<returns>`, and `<exception>` tags.
- **Inline Comments**:
- Forbidden for obvious code. Code must be self-explanatory through excellent naming.
- Only allowed to explain highly complex, business-specific workarounds or algorithms (explain the "Why", not the "What").
- **File Structure**:
- Every class, interface, and enum must reside in its own separate file.
- Files exceeding ~300 lines usually indicate an SRP violation and should be split.

### 4. Naming Conventions

- **PascalCase**: Namespaces, Classes, Interfaces (prefix `I`), Methods, Properties, Public Members.
- **camelCase**: Parameters and local variables.
- **Private Backing Fields**: Underscore + camelCase (e.g., `_myVariable`). No Hungarian Notation (use `_isActive`, not `_bIsActive`).
- **Events**: Use `Closing`/`Closed` instead of `BeforeClose`/`AfterClose`. Event handlers follow the `On[EventName]` pattern.

### 5. Interfaces & Dependency Injection

- Interfaces are **mandatory** for: hardware integrations, external API calls, database access, all external services.
- Access to modules implementing an interface must occur **exclusively** through the interface (IoC).
- **Forbidden**: Casting an interface to its concrete class to bypass the abstraction (e.g., `(module as MyConcreteService).DoSomethingSecret()`).
- **DI**: Use `Microsoft.Extensions.DependencyInjection`; inject services via Constructor Injection.

### 6. Architecture & Layer Separation

Strict Clean Architecture layers. Circular dependencies are strictly forbidden.

| Layer | Contents | Dependencies |
|-------|----------|--------------|
| **Core / Domain** | Abstract domain models, enums, global interfaces | None |
| **Application / Services** | Business logic, abstractions, ViewModels | Core only |
| **Infrastructure / Data** | DB access, external APIs, hardware implementations | Core + Application |
| **UI / Presentation** | WPF Views and XAML | Application (via ViewModels) |

### 7. WPF & MVVM

- **Separation of Concerns**: Strict separation between GUI and business logic.
- **DataBindings**: UI communicates with application logic exclusively via DataBindings. GUI controls handle visual states only.
- **Commands**: User actions (Buttons, Menus, Toolbars) must trigger `ICommand` / `RelayCommand` in the ViewModel.
- **Code-Behind**: `.xaml.cs` files must remain completely empty except for `InitializeComponent()`. **No business logic in code-behind.**
80 changes: 6 additions & 74 deletions EasyBluetoothAudio.Tests/MainViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public MainViewModelTests()
_devicePickerServiceMock.Setup(s => s.ShowAsync()).Returns(Task.CompletedTask);
_settingsServiceMock.Setup(s => s.Load()).Returns(new AppSettings());
_dispatcherServiceMock.Setup(s => s.Invoke(It.IsAny<Action>())).Callback<Action>(a => a());
// Default the peak meter to null so the monitor's zombie branch stays inert in tests
// that do not exercise it. Tests that need zombie behavior can override this setup.
_audioServiceMock.Setup(s => s.GetActiveDevicePeakLevel()).Returns((float?)null);
}

private MainViewModel CreateViewModel()
Expand Down Expand Up @@ -253,7 +256,7 @@ public async Task Disconnect_DisconnectsAndSetsStatus()

Assert.False(vm.IsConnected);
Assert.Equal("DISCONNECTED", vm.StatusText);
_audioServiceMock.Verify(s => s.Disconnect(), Times.Once);
_audioServiceMock.Verify(s => s.Disconnect(It.IsAny<string>()), Times.Once);
}

/// <summary>
Expand Down Expand Up @@ -447,8 +450,8 @@ public async Task Monitor_ReconnectSucceeds_ResumesStreaming()
await vm.RefreshDevicesAsync();
await vm.ConnectAsync();

// Initial settle ~5s + monitor poll 10s + reconnect settle 5s + margin = ~23s
await Task.Delay(23000);
// Monitor poll 10s + reconnect attempt + margin = ~13s
await Task.Delay(13000);

Assert.True(vm.IsConnected);
Assert.Equal("STREAMING ACTIVE", vm.StatusText);
Expand Down Expand Up @@ -520,77 +523,6 @@ public async Task ConnectionLost_Event_DoesNotUpdateUi_WhenCrossCheckShowsStillC
vm.Disconnect();
}

/// <summary>
/// Verifies that ConnectAsync waits for the settling delay when called
/// immediately after Disconnect, to allow Windows to complete Bluetooth teardown.
/// </summary>
[Fact]
public async Task ConnectAsync_WaitsForSettleDelay_WhenCalledRightAfterDisconnect()
{
var device = new BluetoothDevice { Name = "iPhone", Id = "1" };
_audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device });
_audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true);
_audioServiceMock.Setup(s => s.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(true);

var vm = CreateViewModel();
await vm.RefreshDevicesAsync();
await vm.ConnectAsync();
vm.Disconnect();

var sw = System.Diagnostics.Stopwatch.StartNew();
await vm.ConnectAsync();
sw.Stop();

Assert.True(vm.IsConnected);
// Allow 200ms tolerance for scheduling overhead
Assert.True(sw.ElapsedMilliseconds >= MainViewModel.ReconnectSettleDelayMs - 200,
$"Expected settle delay of {MainViewModel.ReconnectSettleDelayMs}ms but ConnectAsync returned in {sw.ElapsedMilliseconds}ms");

vm.Disconnect();
}

/// <summary>
/// Verifies that the reconnect loop applies a settling delay before the first reconnect
/// attempt, meaning ConnectBluetoothAudioAsync is not called immediately after disconnect detection.
/// </summary>
[Fact]
public async Task Monitor_ReconnectLoop_WaitsForSettleDelayBeforeFirstAttempt()
{
var device = new BluetoothDevice { Name = "iPhone", Id = "1" };
_audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device });

DateTime? firstConnectTime = null;
DateTime? secondConnectTime = null;

_audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1"))
.ReturnsAsync(() =>
{
if (firstConnectTime == null)
{
firstConnectTime = DateTime.UtcNow;
return true;
}
secondConnectTime = DateTime.UtcNow;
return true;
});

_audioServiceMock.Setup(s => s.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(false);

var vm = CreateViewModel();
await vm.RefreshDevicesAsync();
await vm.ConnectAsync();

// Initial settle ~5s + monitor poll 10s + reconnect settle 5s + margin = ~23s
await Task.Delay(23_000);

Assert.NotNull(secondConnectTime);
var gap = (secondConnectTime!.Value - firstConnectTime!.Value).TotalMilliseconds;
Assert.True(gap >= MainViewModel.ReconnectSettleDelayMs,
$"Expected at least {MainViewModel.ReconnectSettleDelayMs}ms between initial connect and first reconnect, got {gap:F0}ms");

vm.Disconnect();
}

/// <summary>
/// Verifies that an available update is discovered and surfaced.
/// </summary>
Expand Down
36 changes: 36 additions & 0 deletions EasyBluetoothAudio/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Diagnostics;
using System.IO.Pipes;
using System.Linq;
using System.Threading;
Expand All @@ -10,6 +11,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Win32;
using System.Net.Http;
using EasyBluetoothAudio.Messages;
using EasyBluetoothAudio.Services;
using EasyBluetoothAudio.Services.Interfaces;
using EasyBluetoothAudio.ViewModels;
Expand Down Expand Up @@ -91,9 +93,27 @@ protected override void OnStartup(StartupEventArgs e)
var mainViewModel = ServiceProvider.GetRequiredService<MainViewModel>();

mainWindow.DataContext = mainViewModel;
mainWindow.OpenTrayMenuItem.Click += (_, _) =>
{
if (mainViewModel.OpenCommand.CanExecute(null))
{
mainViewModel.OpenCommand.Execute(null);
}
};
mainWindow.ExitTrayMenuItem.Click += (_, _) =>
{
if (mainViewModel.ExitCommand.CanExecute(null))
{
mainViewModel.ExitCommand.Execute(null);
}
};

mainWindow.TrayIcon.ShowBalloonTip("Easy Bluetooth Audio", "App started in system tray.", Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Info);

WeakReferenceMessenger.Default.Register<ShowBalloonRequestedMessage>(this, (_, msg) =>
Current.Dispatcher.InvokeAsync(() =>
mainWindow.TrayIcon?.ShowBalloonTip(msg.Value.Title, msg.Value.Body, msg.Value.Icon)));

mainViewModel.RequestShow += () =>
{
var mousePt = System.Windows.Forms.Cursor.Position;
Expand All @@ -110,7 +130,15 @@ protected override void OnStartup(StartupEventArgs e)

mainViewModel.RequestExit += () =>
{
Debug.WriteLine("[App] RequestExit received from tray.");
_isExiting = true;
_refreshTimer?.Stop();
if (!mainWindow.TrayIcon.IsDisposed)
{
mainWindow.TrayIcon.Dispose();
}

mainWindow.Close();
Current.Shutdown();
};

Expand Down Expand Up @@ -180,6 +208,14 @@ public void ShutdownForUpdate()
/// <inheritdoc />
protected override void OnExit(ExitEventArgs e)
{
Debug.WriteLine($"[App] OnExit code={e.ApplicationExitCode}");

// Dispose the audio service to release the AudioPlaybackConnection and its
// associated mixer endpoints (render + capture) before the process exits.
// Without this, the virtual audio endpoints linger in the Windows Volume Mixer
// and require a reboot or driver reset to disappear.
(ServiceProvider?.GetService<IAudioService>() as IDisposable)?.Dispose();

if (_ownsMutex)
{
_mutex?.ReleaseMutex();
Expand Down
1 change: 1 addition & 0 deletions EasyBluetoothAudio/EasyBluetoothAudio.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
<PackageReference Include="Hardcodet.NotifyIcon.Wpf" Version="1.1.0" />
<PackageReference Include="NAudio" Version="2.2.1" />
</ItemGroup>

<ItemGroup>
Expand Down
20 changes: 20 additions & 0 deletions EasyBluetoothAudio/Messages/ShowBalloonRequestedMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
using Hardcodet.Wpf.TaskbarNotification;

namespace EasyBluetoothAudio.Messages;

/// <summary>
/// Payload describing a tray balloon tip that the application should surface to the user.
/// </summary>
/// <param name="Title">The balloon header text.</param>
/// <param name="Body">The balloon body text shown underneath the header.</param>
/// <param name="Icon">The system icon rendered next to the message (info, warning, error).</param>
public sealed record BalloonContent(string Title, string Body, BalloonIcon Icon);

/// <summary>
/// Messenger message requesting that the tray icon display a balloon tip.
/// Used by non-UI layers (such as the ViewModel's zombie-recovery branch) to surface
/// user-facing notifications without holding a reference to the TrayIcon instance.
/// </summary>
/// <param name="Value">The balloon content to show.</param>
public sealed class ShowBalloonRequestedMessage(BalloonContent Value) : ValueChangedMessage<BalloonContent>(Value);
Loading