diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index bae3550..711856d 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -1,41 +1,3 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -# This workflow will build, test, sign and package a WPF or Windows Forms desktop application -# built on .NET Core. -# To learn how to migrate your existing application to .NET Core, -# refer to https://docs.microsoft.com/en-us/dotnet/desktop-wpf/migration/convert-project-from-net-framework -# -# To configure this workflow: -# -# 1. Configure environment variables -# GitHub sets default environment variables for every workflow run. -# Replace the variables relative to your project in the "env" section below. -# -# 2. Signing -# Generate a signing certificate in the Windows Application -# Packaging Project or add an existing signing certificate to the project. -# Next, use PowerShell to encode the .pfx file using Base64 encoding -# by running the following Powershell script to generate the output string: -# -# $pfx_cert = Get-Content '.\SigningCertificate.pfx' -Encoding Byte -# [System.Convert]::ToBase64String($pfx_cert) | Out-File 'SigningCertificate_Encoded.txt' -# -# Open the output file, SigningCertificate_Encoded.txt, and copy the -# string inside. Then, add the string to the repo as a GitHub secret -# and name it "Base64_Encoded_Pfx." -# For more information on how to configure your signing certificate for -# this workflow, refer to https://github.com/microsoft/github-actions-for-desktop-apps#signing -# -# Finally, add the signing certificate password to the repo as a secret and name it "Pfx_Key". -# See "Build the Windows Application Packaging project" below to see how the secret is used. -# -# For more information on GitHub Actions, refer to https://github.com/features/actions -# For a complete CI/CD sample to get started with GitHub Action workflows for Desktop Applications, -# refer to https://github.com/microsoft/github-actions-for-desktop-apps - name: .NET Core Desktop on: @@ -44,20 +6,19 @@ on: pull_request: branches: [ "main" ] -jobs: +env: + Solution_Name: src/StatusLightChecker.sln + Client_Project: src/StatusLightChecker/StatusLightChecker.csproj + Service_Project: src/StatusLightChecker.Service/StatusLightChecker.Service.csproj + Installer_Project: src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj + Client_Publish_Dir: publish/client + Service_Publish_Dir: publish/service +jobs: build: - - strategy: - matrix: - configuration: [Release] - - runs-on: windows-latest # For a list of available runner types, refer to - # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idruns-on - - env: - Solution_Name: src/StatusLightChecker.sln # Replace with your solution name, i.e. MyWpfApp.sln. - + runs-on: windows-latest + # windows-latest has Inno Setup 6 pre-installed at + # C:\Program Files (x86)\Inno Setup 6\ISCC.exe — no extra install step needed. steps: - name: Checkout @@ -65,34 +26,37 @@ jobs: with: fetch-depth: 0 - # Install the .NET Core workload - - name: Install .NET Core + - name: Install .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x - - # Add MSBuild to the PATH: https://github.com/microsoft/setup-msbuild - - name: Setup MSBuild.exe - uses: microsoft/setup-msbuild@v2 - - # Restore the application to populate the obj folder with RuntimeIdentifiers - - name: Restore the application - run: msbuild $env:Solution_Name /t:Restore /p:Configuration=$env:Configuration - env: - Configuration: ${{ matrix.configuration }} - - - name: Publish Self-contained - run: dotnet publish $env:Solution_Name -c Release -f net8.0-windows -r win-x64 --self-contained True -o ./StatusCheckerSelfContained - env: - Configuration: ${{ matrix.configuration }} - - # Create the app package by building and packaging the Windows Application Packaging project - - name: Zip the output zip file - run: | - Compress-Archive -Path ./StatusCheckerSelfContained/* -DestinationPath ./StatusCheckerSelfContained.zip - - - name: Upload zip as artifact - uses: actions/upload-artifact@v3 + dotnet-version: 10.0.x + + - name: Restore solution + run: dotnet restore $env:Solution_Name + + # Publish client and service into separate dirs — the installer csproj reads these + # via /p:ClientPublishDir and /p:ServicePublishDir overrides below. + - name: Publish client + run: > + dotnet publish $env:Client_Project + -c Release -f net10.0-windows -r win-x64 --self-contained true + -o $env:Client_Publish_Dir + + - name: Publish service + run: > + dotnet publish $env:Service_Project + -c Release -f net10.0-windows -r win-x64 --self-contained true + -o $env:Service_Publish_Dir + + - name: Build installer + run: > + dotnet build $env:Installer_Project + -c Release + /p:ClientPublishDir=${{ github.workspace }}\${{ env.Client_Publish_Dir }}\ + /p:ServicePublishDir=${{ github.workspace }}\${{ env.Service_Publish_Dir }}\ + + - name: Upload installer + uses: actions/upload-artifact@v4 with: - name: StatusCheckerSelfContained - path: ./StatusCheckerSelfContained.zip + name: StatusLightCheckerSetup + path: build/bin/StatusLightChecker.Installer/Release/StatusLightCheckerSetup.exe diff --git a/.gitignore b/.gitignore index 6bbcd38..9ed71ef 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ # Mono auto generated files mono_crash.* +# Centralized build output (BaseOutputPath / BaseIntermediateOutputPath) +/build/ + # Build results [Dd]ebug/ [Dd]ebugPublic/ @@ -396,3 +399,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +**/.claude diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6a56455 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,8 @@ + + + + 2.0.0 + true + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..c698fa6 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,18 @@ + + + + + + + Jublin.xyz + $(VersionPrefix) + $(VersionPrefix) + + + + $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)../')) + $(RepoRoot)build\obj\$(MSBuildProjectName)\ + $(RepoRoot)build\bin\$(MSBuildProjectName)\ + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props new file mode 100644 index 0000000..d78531f --- /dev/null +++ b/src/Directory.Packages.props @@ -0,0 +1,50 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/StatusLightChecker.Contracts/Protos/settings.proto b/src/StatusLightChecker.Contracts/Protos/settings.proto new file mode 100644 index 0000000..c44dabc --- /dev/null +++ b/src/StatusLightChecker.Contracts/Protos/settings.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; + +option csharp_namespace = "StatusLightChecker.Contracts"; + +package settings; + +service SettingsService { + // Get current color configuration + rpc GetColorConfig (ColorConfigRequest) returns (ColorConfigResponse); + + // Update color configuration + rpc UpdateColorConfig (UpdateColorConfigRequest) returns (ColorConfigResponse); + + // Subscribe to color config changes + rpc StreamColorConfigUpdates (ColorConfigRequest) returns (stream ColorConfigResponse); +} + +message ColorConfigRequest { + string clientId = 1; +} + +message UpdateColorConfigRequest { + string clientId = 1; + ColorConfiguration config = 2; +} + +message ColorConfiguration { + StatusColor available = 1; + StatusColor busy = 2; + StatusColor doNotDisturb = 3; + StatusColor away = 4; + StatusColor offline = 5; + StatusColor unknown = 6; +} + +message StatusColor { + int32 statusLevel = 1; + string name = 2; + string hexColor = 3; + bool isBlinking = 4; +} + +message ColorConfigResponse { + ColorConfiguration config = 1; + int64 lastUpdatedUnixMs = 2; + bool success = 3; + string errorMessage = 4; +} \ No newline at end of file diff --git a/src/StatusLightChecker.Contracts/Protos/status_service.proto b/src/StatusLightChecker.Contracts/Protos/status_service.proto new file mode 100644 index 0000000..4c93d2c --- /dev/null +++ b/src/StatusLightChecker.Contracts/Protos/status_service.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +option csharp_namespace = "StatusLightChecker.Contracts"; + +package statusservice; + +service StatusService { + // Stream status updates from service to client + rpc StreamStatusUpdates (StatusRequest) returns (stream StatusUpdate); + + // Get current status immediately + rpc GetCurrentStatus (StatusRequest) returns (StatusUpdate); + + // Get service health + rpc GetServiceHealth (HealthRequest) returns (HealthResponse); +} + +message StatusRequest { + string clientId = 1; +} + +message HealthRequest { + // Empty request +} + +message StatusUpdate { + string statusId = 1; + string applicationName = 2; + string statusValue = 3; + string statusLabel = 4; + int32 statusLevel = 5; // 0=Unknown, 1=Available, 2=Busy, 3=DoNotDisturb, 4=Away, 5=Offline + string colorHex = 6; + int64 timestampUnixMs = 7; + string details = 8; +} + +message HealthResponse { + ServiceState state = 1; + string version = 2; + int64 uptimeSeconds = 3; + string message = 4; +} + +enum ServiceState { + UNKNOWN = 0; + RUNNING = 1; + STOPPED = 2; + PAUSED = 3; + STARTING = 4; + STOPPING = 5; +} \ No newline at end of file diff --git a/src/StatusLightChecker.Contracts/StatusLightChecker.Contracts.csproj b/src/StatusLightChecker.Contracts/StatusLightChecker.Contracts.csproj new file mode 100644 index 0000000..0d2aff5 --- /dev/null +++ b/src/StatusLightChecker.Contracts/StatusLightChecker.Contracts.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/StatusLightChecker.Core/Colors/StatusColors.cs b/src/StatusLightChecker.Core/Colors/StatusColors.cs new file mode 100644 index 0000000..07d38d2 --- /dev/null +++ b/src/StatusLightChecker.Core/Colors/StatusColors.cs @@ -0,0 +1,37 @@ +namespace StatusLightChecker.Core.Colors; + +public class StatusColors +{ + public ColorHex Available { get; set; } = new ColorHex(0x00FF00); + public ColorHex Busy { get; set; } = new ColorHex(0xFF0000); + public ColorHex DoNotDisturb { get; set; } = new ColorHex(0x8B0000); + public ColorHex Away { get; set; } = new ColorHex(0xFFFF00); + public ColorHex Offline { get; set; } = new ColorHex(0x000000); + public ColorHex OutOfOffice { get; set; } = new ColorHex(0x800080); + public ColorHex Unknown { get; set; } = new ColorHex(0x808080); +} + +public class ColorHex +{ + public byte R { get; set; } + public byte G { get; set; } + public byte B { get; set; } + + public ColorHex() { } + + public ColorHex(uint rgb) + { + R = (byte)(rgb >> 16); + G = (byte)(rgb >> 8); + B = (byte)rgb; + } + + public ColorHex(byte r, byte g, byte b) + { + R = r; + G = g; + B = b; + } + + public override string ToString() => $"#{R:X2}{G:X2}{B:X2}"; +} diff --git a/src/StatusLightChecker.Core/Configuration/ApplicationSettings.cs b/src/StatusLightChecker.Core/Configuration/ApplicationSettings.cs new file mode 100644 index 0000000..47244cc --- /dev/null +++ b/src/StatusLightChecker.Core/Configuration/ApplicationSettings.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using StatusLightChecker.Core.Colors; +using System.IO; +using System; + +namespace StatusLightChecker.Core.Configuration; + +public class ApplicationSettings +{ + public string ComPort { get; set; } = "COM3"; + public int BaudRate { get; set; } = 115200; + public string SelectedApp { get; set; } = "MicrosoftTeams"; + public bool AutoStart { get; set; } = false; + public StatusColors StatusColors { get; set; } = new StatusColors(); + public bool ShowTrayIcon { get; set; } = true; + + private static string SettingsPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "StatusLightChecker", + "settings.json"); + + public static ApplicationSettings? Load() + { + try + { + if (!File.Exists(SettingsPath)) + return null; + + var json = File.ReadAllText(SettingsPath); + return JsonSerializer.Deserialize(json); + } + catch (Exception ex) + { + LogError(ex, "Failed to load settings"); + return null; + } + } + + public static void Save(ApplicationSettings settings) + { + try + { + var directory = Path.GetDirectoryName(SettingsPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory!); + + var options = new JsonSerializerOptions + { + WriteIndented = true + }; + var json = JsonSerializer.Serialize(settings, options); + File.WriteAllText(SettingsPath, json); + } + catch (Exception ex) + { + LogError(ex, "Failed to save settings"); + } + } + + private static void LogError(Exception ex, string message) + { + // Silent fail - logger may not be initialized yet + Console.Error.WriteLine($"{message}: {ex.Message}"); + } +} diff --git a/src/StatusLightChecker.Core/Models/StatusModels.cs b/src/StatusLightChecker.Core/Models/StatusModels.cs new file mode 100644 index 0000000..0763ca3 --- /dev/null +++ b/src/StatusLightChecker.Core/Models/StatusModels.cs @@ -0,0 +1,59 @@ +namespace StatusLightChecker.Core.Models; + +public enum StatusLevel +{ + Unknown = 0, + Available = 1, + Busy = 2, + DoNotDisturb = 3, + Away = 4, + Offline = 5 +} + +public record ApplicationStatus +{ + public string ApplicationName { get; init; } = string.Empty; + public string StatusValue { get; init; } = string.Empty; + public string StatusLabel { get; init; } = string.Empty; + public StatusLevel StatusLevel { get; init; } = StatusLevel.Unknown; + public string? Details { get; init; } + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; +} + +public record StatusColor +{ + public StatusLevel StatusLevel { get; init; } + public string Name { get; init; } = string.Empty; + public string HexColor { get; init; } = "#808080"; + public bool IsBlinking { get; init; } +} + +public record ColorConfiguration +{ + public StatusColor Available { get; init; } = new() { StatusLevel = StatusLevel.Available, Name = "Available", HexColor = "#00CC6A" }; + public StatusColor Busy { get; init; } = new() { StatusLevel = StatusLevel.Busy, Name = "Busy", HexColor = "#FF0000" }; + public StatusColor DoNotDisturb { get; init; } = new() { StatusLevel = StatusLevel.DoNotDisturb, Name = "Do Not Disturb", HexColor = "#B30000", IsBlinking = true }; + public StatusColor Away { get; init; } = new() { StatusLevel = StatusLevel.Away, Name = "Away", HexColor = "#FFCC00" }; + public StatusColor Offline { get; init; } = new() { StatusLevel = StatusLevel.Offline, Name = "Offline", HexColor = "#808080" }; + public StatusColor Unknown { get; init; } = new() { StatusLevel = StatusLevel.Unknown, Name = "Unknown", HexColor = "#CCCCCC" }; +} + +public class StatusChangedEventArgs : EventArgs +{ + public ApplicationStatus Status { get; } + + public StatusChangedEventArgs(ApplicationStatus status) + { + Status = status; + } +} + +public class ColorConfigurationChangedEventArgs : EventArgs +{ + public ColorConfiguration Configuration { get; } + + public ColorConfigurationChangedEventArgs(ColorConfiguration configuration) + { + Configuration = configuration; + } +} \ No newline at end of file diff --git a/src/StatusLightChecker.Core/Serial/SerialPortManager.cs b/src/StatusLightChecker.Core/Serial/SerialPortManager.cs new file mode 100644 index 0000000..8bb9f9b --- /dev/null +++ b/src/StatusLightChecker.Core/Serial/SerialPortManager.cs @@ -0,0 +1,103 @@ +using System.IO.Ports; +using StatusLightChecker.Core.Colors; + +namespace StatusLightChecker.Core.Serial; + +public class SerialPortManager : IDisposable +{ + private SerialPort? _serialPort; + private bool _disposed; + + public bool IsOpen => _serialPort?.IsOpen ?? false; + + public async Task Open(string portName, int baudRate, CancellationToken token) + { + try + { + if (_serialPort?.IsOpen == true) + return true; + + _serialPort?.Dispose(); + + _serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) + { + ReadTimeout = 1000, + WriteTimeout = 1000 + }; + + await Task.Run(() => _serialPort.Open(), token); + return true; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to open serial port {portName}: {ex.Message}"); + return false; + } + } + + public async Task CloseAsync() + { + if (_serialPort?.IsOpen == true) + { + await Task.Run(() => + { + try + { + _serialPort?.Close(); + } + catch { } + }); + } + } + + public void WriteColor(StatusColors colors) + { + if (_serialPort?.IsOpen != true) return; + + try + { + _serialPort.DiscardOutBuffer(); + var data = new[] { colors.Unknown.R, colors.Unknown.G, colors.Unknown.B }; + _serialPort.Write(data, 0, data.Length); + // _serialPort.DrainWriter(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to write to serial port: {ex.Message}"); + } + } + + public void WriteColorRaw(byte r, byte g, byte b) + { + if (_serialPort?.IsOpen != true) return; + + try + { + _serialPort.DiscardOutBuffer(); + var data = new[] { r, g, b }; + _serialPort.Write(data, 0, data.Length); + // _serialPort.DrainWriter(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to write to serial port: {ex.Message}"); + } + } + + public void Dispose() + { + if (_disposed) return; + + try + { + _serialPort?.Close(); + _serialPort?.Dispose(); + } + catch { } + + _disposed = true; + GC.SuppressFinalize(this); + } + + ~SerialPortManager() => Dispose(); +} diff --git a/src/StatusLightChecker.Core/Services/ColorConfigurationService.cs b/src/StatusLightChecker.Core/Services/ColorConfigurationService.cs new file mode 100644 index 0000000..a169adc --- /dev/null +++ b/src/StatusLightChecker.Core/Services/ColorConfigurationService.cs @@ -0,0 +1,128 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using StatusLightChecker.Core.Models; +using System.Text.Json; + +namespace StatusLightChecker.Core.Services; + +public class ColorConfigurationService : IColorConfigurationService +{ + private readonly ILogger _logger; + private readonly string _connectionString; + private ColorConfiguration _currentConfig; + private readonly SemaphoreSlim _semaphore = new(1, 1); + + public event EventHandler? ConfigurationChanged; + + public ColorConfigurationService(ILogger logger, string connectionString) + { + _logger = logger; + _connectionString = connectionString; + _currentConfig = new ColorConfiguration(); + + InitializeDatabaseAsync().Wait(); + LoadConfigurationAsync().Wait(); + } + + private async Task InitializeDatabaseAsync() + { + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + var createTableSql = @" + CREATE TABLE IF NOT EXISTS ColorConfiguration ( + Id INTEGER PRIMARY KEY CHECK (Id = 1), + ConfigurationJson TEXT NOT NULL, + LastUpdated INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS Settings ( + Key TEXT PRIMARY KEY, + Value TEXT NOT NULL, + LastUpdated INTEGER NOT NULL + ); + "; + + using var command = new SqliteCommand(createTableSql, connection); + await command.ExecuteNonQueryAsync(); + _logger.LogDebug("Database initialized successfully"); + } + + private async Task LoadConfigurationAsync() + { + await _semaphore.WaitAsync(); + try + { + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + using var command = new SqliteCommand( + "SELECT ConfigurationJson FROM ColorConfiguration WHERE Id = 1", connection); + + var result = await command.ExecuteScalarAsync(); + if (result is string json) + { + var config = JsonSerializer.Deserialize(json); + if (config != null) + { + _currentConfig = config; + _logger.LogInformation("Loaded color configuration from database"); + } + } + else + { + await SaveConfigurationInternalAsync(_currentConfig); + _logger.LogInformation("Saved default color configuration to database"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading color configuration"); + } + finally + { + _semaphore.Release(); + } + } + + public ColorConfiguration GetCurrentConfiguration() + { + return _currentConfig; + } + + public async Task UpdateConfigurationAsync(ColorConfiguration configuration) + { + await _semaphore.WaitAsync(); + try + { + await SaveConfigurationInternalAsync(configuration); + _currentConfig = configuration; + + ConfigurationChanged?.Invoke(this, new ColorConfigurationChangedEventArgs(configuration)); + _logger.LogInformation("Color configuration updated"); + } + finally + { + _semaphore.Release(); + } + } + + private async Task SaveConfigurationInternalAsync(ColorConfiguration configuration) + { + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + var json = JsonSerializer.Serialize(configuration); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + using var command = new SqliteCommand(@" + INSERT OR REPLACE INTO ColorConfiguration (Id, ConfigurationJson, LastUpdated) + VALUES (1, @json, @timestamp) + ", connection); + + command.Parameters.AddWithValue("@json", json); + command.Parameters.AddWithValue("@timestamp", timestamp); + + await command.ExecuteNonQueryAsync(); + } +} diff --git a/src/StatusLightChecker.Core/Services/Interfaces.cs b/src/StatusLightChecker.Core/Services/Interfaces.cs new file mode 100644 index 0000000..950c8c0 --- /dev/null +++ b/src/StatusLightChecker.Core/Services/Interfaces.cs @@ -0,0 +1,42 @@ +using StatusLightChecker.Core.Models; + +namespace StatusLightChecker.Core.Services; + +public interface IColorConfigurationService +{ + ColorConfiguration GetCurrentConfiguration(); + Task UpdateConfigurationAsync(ColorConfiguration configuration); + event EventHandler? ConfigurationChanged; +} + +public interface IStatusDetector +{ + string ApplicationName { get; } + Task DetectStatusAsync(CancellationToken cancellationToken = default); +} + +public interface IServiceHealthTracker +{ + ServiceHealth GetCurrentHealth(); + void SetState(ServiceState state); + void RecordHeartbeat(); +} + +public record ServiceHealth +{ + public ServiceState State { get; init; } = ServiceState.Stopped; + public string Version { get; init; } = "1.0.0"; + public long UptimeSeconds { get; init; } + public string Message { get; init; } = string.Empty; + public DateTimeOffset LastHeartbeat { get; init; } = DateTimeOffset.UtcNow; +} + +public enum ServiceState +{ + Unknown = 0, + Running = 1, + Stopped = 2, + Paused = 3, + Starting = 4, + Stopping = 5 +} \ No newline at end of file diff --git a/src/StatusLightChecker.Core/Services/ServiceHealthTracker.cs b/src/StatusLightChecker.Core/Services/ServiceHealthTracker.cs new file mode 100644 index 0000000..14ed56d --- /dev/null +++ b/src/StatusLightChecker.Core/Services/ServiceHealthTracker.cs @@ -0,0 +1,55 @@ +using System.Diagnostics; + +namespace StatusLightChecker.Core.Services; + +public class ServiceHealthTracker : IServiceHealthTracker +{ + private readonly Stopwatch _uptimeStopwatch = Stopwatch.StartNew(); + private ServiceState _currentState = ServiceState.Stopped; + private DateTimeOffset _lastHeartbeat = DateTimeOffset.UtcNow; + private readonly string _version; + + public ServiceHealthTracker() + { + _version = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0"; + } + + public ServiceHealth GetCurrentHealth() + { + return new ServiceHealth + { + State = _currentState, + Version = _version, + UptimeSeconds = (long)_uptimeStopwatch.Elapsed.TotalSeconds, + Message = GetStateMessage(_currentState), + LastHeartbeat = _lastHeartbeat + }; + } + + public void SetState(ServiceState state) + { + if (_currentState != state) + { + _currentState = state; + if (state == ServiceState.Running) + { + _uptimeStopwatch.Restart(); + } + } + } + + public void RecordHeartbeat() + { + _lastHeartbeat = DateTimeOffset.UtcNow; + } + + private static string GetStateMessage(ServiceState state) => state switch + { + ServiceState.Running => "Service is running normally", + ServiceState.Stopped => "Service is stopped", + ServiceState.Paused => "Service is paused", + ServiceState.Starting => "Service is starting...", + ServiceState.Stopping => "Service is stopping...", + _ => "Service state unknown" + }; +} \ No newline at end of file diff --git a/src/StatusLightChecker.Core/Services/StatusMonitoringService.cs b/src/StatusLightChecker.Core/Services/StatusMonitoringService.cs new file mode 100644 index 0000000..f7a128c --- /dev/null +++ b/src/StatusLightChecker.Core/Services/StatusMonitoringService.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StatusLightChecker.Core.Models; + +namespace StatusLightChecker.Core.Services; + +public class StatusMonitoringService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IEnumerable _detectors; + private readonly IColorConfigurationService _colorConfigService; + private ApplicationStatus? _currentStatus; + private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5); + + public event EventHandler? StatusChanged; + + public StatusMonitoringService( + ILogger logger, + IEnumerable detectors, + IColorConfigurationService colorConfigService) + { + _logger = logger; + _detectors = detectors; + _colorConfigService = colorConfigService; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Status Monitoring Service starting..."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await CheckAllDetectorsAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking status detectors"); + } + + await Task.Delay(_checkInterval, stoppingToken); + } + + _logger.LogInformation("Status Monitoring Service stopped"); + } + + private async Task CheckAllDetectorsAsync(CancellationToken cancellationToken) + { + ApplicationStatus? highestPriorityStatus = null; + + foreach (var detector in _detectors) + { + try + { + var status = await detector.DetectStatusAsync(cancellationToken); + if (status != null) + { + _logger.LogDebug("Detected status from {App}: {Status}", + detector.ApplicationName, status.StatusLabel); + + // Higher status level = higher priority + if (highestPriorityStatus == null || status.StatusLevel > highestPriorityStatus.StatusLevel) + { + highestPriorityStatus = status; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error detecting status from {App}", detector.ApplicationName); + } + } + + if (highestPriorityStatus != null && !IsSameStatus(_currentStatus, highestPriorityStatus)) + { + _currentStatus = highestPriorityStatus; + _logger.LogInformation("Status changed to: {Status} from {App}", + highestPriorityStatus.StatusLabel, highestPriorityStatus.ApplicationName); + + StatusChanged?.Invoke(this, new StatusChangedEventArgs(highestPriorityStatus)); + } + } + + public Task GetCurrentStatusAsync() + { + return Task.FromResult(_currentStatus); + } + + private static bool IsSameStatus(ApplicationStatus? a, ApplicationStatus? b) + { + if (a == null || b == null) return false; + return a.StatusLevel == b.StatusLevel && + a.ApplicationName == b.ApplicationName && + a.StatusValue == b.StatusValue; + } +} \ No newline at end of file diff --git a/src/StatusLightChecker.Core/StatusCheckers/IStatusChecker.cs b/src/StatusLightChecker.Core/StatusCheckers/IStatusChecker.cs new file mode 100644 index 0000000..8b162b0 --- /dev/null +++ b/src/StatusLightChecker.Core/StatusCheckers/IStatusChecker.cs @@ -0,0 +1,18 @@ +using StatusLightChecker.Core.Colors; + +namespace StatusLightChecker.Core.StatusCheckers; + +public delegate void StatusChangedEventHandler(); + +public interface IStatusChecker : IDisposable +{ + event StatusChangedEventHandler StatusChanged; + + string StatusButtonId { get; set; } + + Task GetCurrentStatus(); + void StartChecking(); + void StopChecking(); + + StatusColors GetColorFromStatus(StatusColors? colors); +} diff --git a/src/StatusLightChecker.Core/StatusCheckers/TeamsApplicationStatusChecker.cs b/src/StatusLightChecker.Core/StatusCheckers/TeamsApplicationStatusChecker.cs new file mode 100644 index 0000000..fba49d9 --- /dev/null +++ b/src/StatusLightChecker.Core/StatusCheckers/TeamsApplicationStatusChecker.cs @@ -0,0 +1,239 @@ +using System.Windows.Threading; +using FlaUI.Core.AutomationElements; +using FlaUI.UIA3; +using Serilog; +using StatusLightChecker.Core.Colors; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace StatusLightChecker.Core.StatusCheckers; + +public enum TeamsStatus +{ + Available, + Busy, + DoNotDisturb, + Away, + Offline, + Unknown, + OutOfOffice, + InAMeeting +} + +public class TeamsApplicationStatusChecker : IStatusChecker +{ + public event StatusChangedEventHandler? StatusChanged; + public string StatusButtonId { get; set; } = "idna-me-control-avatar-trigger"; + + private readonly ILogger _logger; + private AutomationElement? _storedWindow; + private TeamsStatus _lastStatus = TeamsStatus.Unknown; + private DispatcherTimer? _statusTimer; + private readonly int _poolingIntervalSeconds = 3; + private CancellationTokenSource? _cts; + private readonly SemaphoreSlim _statusCheckSemaphore = new(1, 1); + private readonly SemaphoreSlim _windowFinderSemaphore = new(1, 1); + + private const string WindowTitle = "Microsoft Teams"; + + public TeamsApplicationStatusChecker() + { + _logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateLogger(); + } + + public void StartChecking() + { + _cts = new CancellationTokenSource(); + InitializeTimer(); + _statusTimer?.Start(); + _logger.Information("Started checking Teams status"); + } + + public void StopChecking() + { + _statusTimer?.Stop(); + _cts?.Cancel(); + _logger.Information("Stopped checking Teams status"); + } + + private void InitializeTimer() + { + _statusTimer = new DispatcherTimer( + TimeSpan.FromSeconds(_poolingIntervalSeconds), + DispatcherPriority.Normal, + StatusTimerCallback, + Dispatcher.CurrentDispatcher); + } + + private async void StatusTimerCallback(object? sender, EventArgs e) + { + await GetCurrentStatus(); + _statusTimer?.Start(); + } + + public async Task GetCurrentStatus() + { + var canEnter = await _statusCheckSemaphore.WaitAsync(1000); + if (!canEnter) + return; + + try + { + var presenceStatus = "Unknown"; + + if (_storedWindow != null) + { + try + { + _ = _storedWindow.Name; + } + catch + { + _storedWindow = null; + } + } + + if (_storedWindow == null) + { + _storedWindow = FindWindow(); + if (_storedWindow == null) + { + _logger.Warning("Could not find Teams window"); + _lastStatus = TeamsStatus.Unknown; + return; + } + } + + var buttons = _storedWindow.FindAllDescendants( + cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button)); + + var accountButton = buttons.FirstOrDefault(b => + b.AutomationId.Equals(StatusButtonId, StringComparison.OrdinalIgnoreCase)); + + if (accountButton == null) + { + _logger.Error("Couldn't find button holding status info."); + _lastStatus = TeamsStatus.Unknown; + StatusChanged?.Invoke(); + return; + } + + var statusWords = accountButton.Name.Split(' '); + var status = statusWords.Skip(3).ToList(); + var forIndex = status.IndexOf("for"); + status = forIndex > 0 ? status.Take(forIndex).ToList() : status; + presenceStatus = string.Join(" ", status); + _logger.Information($"Status found: {presenceStatus}"); + + var newStatus = GetStatusFromElementStatus(presenceStatus); + if (newStatus != _lastStatus) + { + _lastStatus = newStatus; + _logger.Information($"Status set to {_lastStatus}"); + StatusChanged?.Invoke(); + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + _logger.Error(ex, "Error reading status"); + } + finally + { + _statusCheckSemaphore.Release(); + } + } + + private TeamsStatus GetStatusFromElementStatus(string statusString) + { + var status = statusString switch + { + "Available" => TeamsStatus.Available, + "Busy" or "In a call" or "In a meeting" => TeamsStatus.Busy, + "Presenting" or "Do not disturb" => TeamsStatus.DoNotDisturb, + "Away" or "Be right back" => TeamsStatus.Away, + _ => TeamsStatus.Unknown + }; + + if (status == TeamsStatus.Unknown) + { + _logger.Warning($"Unknown status: {statusString}"); + } + + return status; + } + + public StatusColors GetColorFromStatus(StatusColors? colors) + { + var defaultColors = new StatusColors(); + colors ??= defaultColors; + + var statusColors = _lastStatus switch + { + TeamsStatus.Available => colors.Available, + TeamsStatus.Busy => colors.Busy, + TeamsStatus.DoNotDisturb => colors.DoNotDisturb, + TeamsStatus.Away => colors.Away, + TeamsStatus.Offline => colors.Offline, + TeamsStatus.OutOfOffice => colors.OutOfOffice, + _ => colors.Unknown + }; + + return new StatusColors + { + Available = statusColors, + Busy = statusColors, + DoNotDisturb = statusColors, + Away = statusColors, + Offline = statusColors, + OutOfOffice = statusColors, + Unknown = statusColors + }; + } + + private AutomationElement? FindWindow() + { + using var automation = new UIA3Automation(); + var desktop = automation.GetDesktop(); + var windows = desktop.FindAllChildren( + cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Window)); + + var foundWindows = windows.Where(w => + w.Name.Contains(WindowTitle, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (!foundWindows.Any()) + return null; + + if (foundWindows.Count == 1) + return foundWindows[0]; + + foreach (var window in foundWindows) + { + var buttons = window.FindAllDescendants( + cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button)); + var accountButton = buttons.FirstOrDefault(b => + b.AutomationId.Equals(StatusButtonId, StringComparison.OrdinalIgnoreCase)); + if (accountButton != null) + return window; + } + + return foundWindows[0]; + } + + public void Dispose() + { + _statusTimer?.Stop(); + _statusCheckSemaphore.Dispose(); + _windowFinderSemaphore.Dispose(); + _cts?.Dispose(); + GC.SuppressFinalize(this); + } + + ~TeamsApplicationStatusChecker() => Dispose(); +} diff --git a/src/StatusLightChecker.Core/StatusLightChecker.Core.csproj b/src/StatusLightChecker.Core/StatusLightChecker.Core.csproj new file mode 100644 index 0000000..dffdd2e --- /dev/null +++ b/src/StatusLightChecker.Core/StatusLightChecker.Core.csproj @@ -0,0 +1,21 @@ + + + + net10.0-windows + true + enable + enable + + + + + + + + + + + + + + diff --git a/src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj b/src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj new file mode 100644 index 0000000..731d24c --- /dev/null +++ b/src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj @@ -0,0 +1,59 @@ + + + + + net10.0-windows + + + + + + + + + $(PkgTools_InnoSetup)\tools\ISCC.exe + C:\Program Files (x86)\Inno Setup 6\ISCC.exe + ISCC.exe + + + + $(MSBuildThisFileDirectory)..\..\build\bin\StatusLightChecker\$(Configuration)\net10.0-windows\win-x64\publish\ + $(MSBuildThisFileDirectory)..\..\build\bin\StatusLightChecker.Service\$(Configuration)\net10.0-windows\win-x64\publish\ + $(BaseOutputPath)$(Configuration)\ + + + + + + Publish + Configuration=$(Configuration);RuntimeIdentifier=win-x64;SelfContained=true;TargetFramework=net10.0-windows;PublishDir=$(ClientPublishDir) + false + true + + + Publish + Configuration=$(Configuration);RuntimeIdentifier=win-x64;SelfContained=true;TargetFramework=net10.0-windows;PublishDir=$(ServicePublishDir) + false + true + + + + + + + + + diff --git a/src/StatusLightChecker.Installer/StatusLightCheckerSetup.iss b/src/StatusLightChecker.Installer/StatusLightCheckerSetup.iss new file mode 100644 index 0000000..bf23232 --- /dev/null +++ b/src/StatusLightChecker.Installer/StatusLightCheckerSetup.iss @@ -0,0 +1,195 @@ +; StatusLightCheckerSetup.iss +; Inno Setup 6 installer script for Status Light Checker +; +; Build via MSBuild (StatusLightChecker.Installer.csproj) which passes all +; /D defines automatically. To compile manually: +; ISCC.exe /DAppVersion=2.0.0 /DClientPublishDir= /DServicePublishDir= /DOutputDir= StatusLightCheckerSetup.iss + +; --------------------------------------------------------------------------- +; External defines — all supplied by the csproj at build time via /D flags. +; If any are missing, the #error directive stops the build with a clear message. +; --------------------------------------------------------------------------- + +#ifndef AppVersion + #error AppVersion not defined. Pass /DAppVersion=x.y.z to ISCC.exe. +#endif + +#ifndef ClientPublishDir + #error ClientPublishDir not defined. Pass /DClientPublishDir= to ISCC.exe. +#endif + +#ifndef ServicePublishDir + #error ServicePublishDir not defined. Pass /DServicePublishDir= to ISCC.exe. +#endif + +#ifndef OutputDir + #define OutputDir "..\..\build\bin\StatusLightChecker.Installer\Release" +#endif + +; --------------------------------------------------------------------------- +; [Setup] +; --------------------------------------------------------------------------- + +[Setup] +AppName=Status Light Checker +AppVersion={#AppVersion} +AppVerName=Status Light Checker {#AppVersion} +AppPublisher=Jublin.xyz +AppPublisherURL=https://github.com/jublin +AppSupportURL=https://github.com/jublin/StatusLightCheckerWPF/issues +AppUpdatesURL=https://github.com/jublin/StatusLightCheckerWPF/releases + +AppId={{E220399D-9D5E-4163-AB48-CABB103A67A1}} +; AppId is the stable product GUID. Must never change between versions. + +DefaultDirName={autopf}\JublinXYZ\StatusLightChecker +; {autopf} resolves to Program Files in admin mode and +; {localappdata}\Programs in non-admin mode (Inno Setup 6.1+). + +DefaultGroupName=Status Light Checker +DisableProgramGroupPage=yes + +OutputDir={#OutputDir} +OutputBaseFilename=StatusLightCheckerSetup + +SetupIconFile=..\StatusLightChecker\icon.ico +UninstallDisplayIcon={app}\Client\StatusLightChecker.exe + +Compression=lzma2/ultra64 +SolidCompression=yes +WizardStyle=modern + +; Allow the user to choose per-user or per-machine at install time. +; Non-admin (per-user) installs will NOT register the Windows Service — see [Code]. +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog + +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +MinVersion=10.0 + +; --------------------------------------------------------------------------- +; [Languages] +; --------------------------------------------------------------------------- + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +; --------------------------------------------------------------------------- +; [Dirs] +; --------------------------------------------------------------------------- + +[Dirs] +; Writable data directory for logs and SQLite DB. +; Per-machine → %ProgramData%\Jublin\StatusLightChecker +; Per-user → %AppData%\Jublin\StatusLightChecker +; uninsneveruninstall: leave behind on uninstall so user data isn't wiped. +Name: "{code:GetDataDir}\Service"; Flags: uninsneveruninstall +Name: "{code:GetDataDir}\Service\logs"; Flags: uninsneveruninstall +Name: "{code:GetDataDir}\Client"; Flags: uninsneveruninstall +Name: "{code:GetDataDir}\Client\logs"; Flags: uninsneveruninstall + +; --------------------------------------------------------------------------- +; [Files] +; --------------------------------------------------------------------------- + +[Files] +; Client binaries — all self-contained publish output +Source: "{#ClientPublishDir}\*"; \ + DestDir: "{app}\Client"; \ + Flags: ignoreversion recursesubdirs createallsubdirs + +; Service binaries — all self-contained publish output +Source: "{#ServicePublishDir}\*"; \ + DestDir: "{app}\Service"; \ + Flags: ignoreversion recursesubdirs createallsubdirs + +; --------------------------------------------------------------------------- +; [Icons] +; --------------------------------------------------------------------------- + +[Icons] +Name: "{group}\Status Light Checker"; \ + Filename: "{app}\Client\StatusLightChecker.exe"; \ + WorkingDir: "{app}\Client" +Name: "{group}\Uninstall Status Light Checker"; \ + Filename: "{uninstallexe}" + +; --------------------------------------------------------------------------- +; [Run] — post-install actions +; --------------------------------------------------------------------------- + +[Run] +; Register the Windows Service. sc.exe requires a space after binPath=. +; Only runs in admin (all-users) install mode. +Filename: "{sys}\sc.exe"; \ + Parameters: "create ""StatusLightCheckerService"" binPath= ""{app}\Service\StatusLightChecker.Service.exe"" type= own start= auto error= normal displayname= ""Status Light Checker Service"""; \ + Flags: runhidden waituntilterminated; \ + StatusMsg: "Registering Status Light Checker Service..."; \ + Check: IsAdminInstallMode + +Filename: "{sys}\sc.exe"; \ + Parameters: "description ""StatusLightCheckerService"" ""Monitors application status for LED light control"""; \ + Flags: runhidden waituntilterminated; \ + Check: IsAdminInstallMode + +; Start= wait but don't block — service startup may legitimately take a moment. +Filename: "{sys}\sc.exe"; \ + Parameters: "start ""StatusLightCheckerService"""; \ + Flags: runhidden waituntilterminated; \ + StatusMsg: "Starting Status Light Checker Service..."; \ + Check: IsAdminInstallMode + +; Optionally launch the client after install (user can uncheck) +Filename: "{app}\Client\StatusLightChecker.exe"; \ + Description: "Launch Status Light Checker"; \ + Flags: nowait postinstall skipifsilent + +; --------------------------------------------------------------------------- +; [UninstallRun] — pre/post-uninstall actions +; --------------------------------------------------------------------------- + +[UninstallRun] +; Stop and delete the service before files are removed. +Filename: "{sys}\sc.exe"; \ + Parameters: "stop ""StatusLightCheckerService"""; \ + Flags: runhidden waituntilterminated; \ + Check: IsAdminInstallMode + +Filename: "{sys}\sc.exe"; \ + Parameters: "delete ""StatusLightCheckerService"""; \ + Flags: runhidden waituntilterminated; \ + Check: IsAdminInstallMode + +; --------------------------------------------------------------------------- +; [Code] — Pascal script +; --------------------------------------------------------------------------- + +[Code] + +{ Returns the writable data directory appropriate for the install mode. } +function GetDataDir(Param: String): String; +begin + if IsAdminInstallMode then + Result := ExpandConstant('{commonappdata}\Jublin\StatusLightChecker') + else + Result := ExpandConstant('{userappdata}\Jublin\StatusLightChecker'); +end; + +{ Warn the user if they chose non-admin install: service won't run. } +procedure CurStepChanged(CurStep: TSetupStep); +begin + if (CurStep = ssInstall) and not IsAdminInstallMode then + begin + MsgBox( + 'You are installing Status Light Checker for the current user only.' + #13#10 + #13#10 + + 'The background Windows Service will NOT be installed.' + #13#10 + + 'The application requires the service to monitor Teams status.' + #13#10 + #13#10 + + 'To install with full functionality, cancel and re-run the installer' + #13#10 + + 'by right-clicking and selecting "Run as administrator".', + mbInformation, + MB_OK + ); + end; +end; diff --git a/src/StatusLightChecker.Service/Program.cs b/src/StatusLightChecker.Service/Program.cs new file mode 100644 index 0000000..4777fb1 --- /dev/null +++ b/src/StatusLightChecker.Service/Program.cs @@ -0,0 +1,115 @@ +using Serilog; +using StatusLightChecker.Core.Services; +using StatusLightChecker.Service.Services; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Http; +using StatusLightChecker.Core; + +var builder = WebApplication.CreateBuilder(args); + +// Resolve writable data directory. When running as a Windows Service, the process +// working directory is System32 (LocalSystem account) so relative paths fail. +// Environment.UserInteractive is false when hosted by the SCM. +var isService = !Environment.UserInteractive; +var dataDir = isService + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Jublin", "StatusLightChecker", "Service") + : AppContext.BaseDirectory; + +Directory.CreateDirectory(Path.Combine(dataDir, "logs")); + +// File sink path is set programmatically so it always resolves to a writable +// location. Console sink remains configured via appsettings.json. +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .WriteTo.File( + Path.Combine(dataDir, "logs", "service-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 7, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + +builder.Logging.ClearProviders(); +builder.Logging.AddSerilog(); + +// Configure as Windows Service +builder.Host.UseWindowsService(options => +{ + options.ServiceName = builder.Configuration["ServiceConfiguration:ServiceName"] ?? "StatusLightCheckerService"; +}); + +// DB path resolved to ProgramData when running as a service; relative otherwise (dev). +var dbConnectionString = isService + ? $"Data Source={Path.Combine(dataDir, "StatusLightChecker.db")}" + : builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["Database:ConnectionString"] + ?? "Data Source=StatusLightChecker.db"; + +// Register Core Services +builder.Services.AddSingleton(sp => +{ + var logger = sp.GetRequiredService>(); + return new ColorConfigurationService(logger, dbConnectionString); +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); + +// Register gRPC services +builder.Services.AddGrpc(options => +{ + options.EnableDetailedErrors = true; + options.MaxReceiveMessageSize = 1024 * 1024; + options.MaxSendMessageSize = 1024 * 1024; +}); + +// Register Status Detectors +builder.Services.AddSingleton(); + +// Register Background Services +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +// Set initial service state +var healthTracker = app.Services.GetRequiredService(); +healthTracker.SetState(StatusLightChecker.Core.Services.ServiceState.Starting); + +// Configure gRPC endpoints +app.MapGrpcService(); +app.MapGrpcService(); + +// Health check endpoint +app.MapGet("/health", () => +{ + var health = healthTracker.GetCurrentHealth(); + return Results.Ok(new { health.State, health.Version, health.UptimeSeconds }); +}); + +// Configure shutdown +app.Lifetime.ApplicationStarted.Register(() => +{ + healthTracker.SetState(StatusLightChecker.Core.Services.ServiceState.Running); + Log.Information("Status Light Checker Service started successfully"); +}); + +app.Lifetime.ApplicationStopping.Register(() => +{ + healthTracker.SetState(StatusLightChecker.Core.Services.ServiceState.Stopping); + Log.Information("Status Light Checker Service is stopping..."); +}); + +app.Lifetime.ApplicationStopped.Register(() => +{ + healthTracker.SetState(StatusLightChecker.Core.Services.ServiceState.Stopped); + Log.Information("Status Light Checker Service stopped"); + Log.CloseAndFlush(); +}); + +await app.RunAsync(); diff --git a/src/StatusLightChecker.Service/Properties/launchSettings.json b/src/StatusLightChecker.Service/Properties/launchSettings.json new file mode 100644 index 0000000..e5e747f --- /dev/null +++ b/src/StatusLightChecker.Service/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "StatusLightChecker.Service": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59219;http://localhost:59220" + } + } +} \ No newline at end of file diff --git a/src/StatusLightChecker.Service/Services/GrpcSettingsService.cs b/src/StatusLightChecker.Service/Services/GrpcSettingsService.cs new file mode 100644 index 0000000..b5113ac --- /dev/null +++ b/src/StatusLightChecker.Service/Services/GrpcSettingsService.cs @@ -0,0 +1,159 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging; +using StatusLightChecker.Contracts; +using StatusLightChecker.Core.Services; +using StatusLightChecker.Core.Models; +using System.Collections.Concurrent; + +namespace StatusLightChecker.Service.Services; + +public class GrpcSettingsService : global::StatusLightChecker.Contracts.SettingsService.SettingsServiceBase +{ + private readonly ILogger _logger; + private readonly IColorConfigurationService _colorConfigService; + private readonly ConcurrentDictionary> _activeStreams = new(); + + public GrpcSettingsService( + ILogger logger, + IColorConfigurationService colorConfigService) + { + _logger = logger; + _colorConfigService = colorConfigService; + + _colorConfigService.ConfigurationChanged += OnConfigurationChanged; + } + + public override Task GetColorConfig( + ColorConfigRequest request, + ServerCallContext context) + { + _logger.LogDebug("GetColorConfig called by {ClientId}", request.ClientId); + + var config = _colorConfigService.GetCurrentConfiguration(); + return Task.FromResult(MapToResponse(config, true, null)); + } + + public override async Task UpdateColorConfig( + UpdateColorConfigRequest request, + ServerCallContext context) + { + _logger.LogInformation("UpdateColorConfig called by {ClientId}", request.ClientId); + + try + { + var config = MapFromProto(request.Config); + await _colorConfigService.UpdateConfigurationAsync(config); + + return MapToResponse(config, true, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update color configuration"); + return MapToResponse(_colorConfigService.GetCurrentConfiguration(), false, ex.Message); + } + } + + public override async Task StreamColorConfigUpdates( + ColorConfigRequest request, + IServerStreamWriter responseStream, + ServerCallContext context) + { + _logger.LogInformation("Client {ClientId} connected to config stream", request.ClientId); + _activeStreams[request.ClientId] = responseStream; + + // Send current config immediately + var currentConfig = _colorConfigService.GetCurrentConfiguration(); + await responseStream.WriteAsync(MapToResponse(currentConfig, true, null)); + + // Keep stream open until cancelled + try + { + await Task.Delay(Timeout.Infinite, context.CancellationToken); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Client {ClientId} disconnected from config stream", request.ClientId); + } + finally + { + _activeStreams.TryRemove(request.ClientId, out _); + } + } + + private void OnConfigurationChanged(object? sender, ColorConfigurationChangedEventArgs e) + { + var response = MapToResponse(e.Configuration, true, null); + + foreach (var (clientId, stream) in _activeStreams.ToArray()) + { + try + { + stream.WriteAsync(response).Wait(TimeSpan.FromSeconds(5)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send config update to client {ClientId}", clientId); + _activeStreams.TryRemove(clientId, out _); + } + } + } + + private static ColorConfigResponse MapToResponse(Core.Models.ColorConfiguration config, bool success, string? error) + { + return new ColorConfigResponse + { + Config = MapToProto(config), + LastUpdatedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Success = success, + ErrorMessage = error ?? string.Empty + }; + } + + private static Contracts.ColorConfiguration MapToProto(Core.Models.ColorConfiguration config) + { + return new Contracts.ColorConfiguration + { + Available = MapToProto(config.Available), + Busy = MapToProto(config.Busy), + DoNotDisturb = MapToProto(config.DoNotDisturb), + Away = MapToProto(config.Away), + Offline = MapToProto(config.Offline), + Unknown = MapToProto(config.Unknown) + }; + } + + private static Contracts.StatusColor MapToProto(Core.Models.StatusColor color) + { + return new Contracts.StatusColor + { + StatusLevel = (int)color.StatusLevel, + Name = color.Name, + HexColor = color.HexColor, + IsBlinking = color.IsBlinking + }; + } + + private static Core.Models.ColorConfiguration MapFromProto(Contracts.ColorConfiguration proto) + { + return new Core.Models.ColorConfiguration + { + Available = MapFromProto(proto.Available), + Busy = MapFromProto(proto.Busy), + DoNotDisturb = MapFromProto(proto.DoNotDisturb), + Away = MapFromProto(proto.Away), + Offline = MapFromProto(proto.Offline), + Unknown = MapFromProto(proto.Unknown) + }; + } + + private static Core.Models.StatusColor MapFromProto(Contracts.StatusColor proto) + { + return new Core.Models.StatusColor + { + StatusLevel = (Core.Models.StatusLevel)proto.StatusLevel, + Name = proto.Name, + HexColor = proto.HexColor, + IsBlinking = proto.IsBlinking + }; + } +} \ No newline at end of file diff --git a/src/StatusLightChecker.Service/Services/GrpcStatusService.cs b/src/StatusLightChecker.Service/Services/GrpcStatusService.cs new file mode 100644 index 0000000..a46b4f3 --- /dev/null +++ b/src/StatusLightChecker.Service/Services/GrpcStatusService.cs @@ -0,0 +1,131 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging; +using StatusLightChecker.Contracts; +using StatusLightChecker.Core.Services; +using StatusLightChecker.Core.Models; +using System.Collections.Concurrent; + +namespace StatusLightChecker.Service.Services; + +public class GrpcStatusService : global::StatusLightChecker.Contracts.StatusService.StatusServiceBase +{ + private readonly ILogger _logger; + private readonly StatusMonitoringService _monitoringService; + private readonly IColorConfigurationService _colorConfigService; + private readonly ServiceHealthTracker _healthTracker; + private readonly ConcurrentDictionary> _activeStreams = new(); + + public GrpcStatusService( + ILogger logger, + StatusMonitoringService monitoringService, + IColorConfigurationService colorConfigService, + ServiceHealthTracker healthTracker) + { + _logger = logger; + _monitoringService = monitoringService; + _colorConfigService = colorConfigService; + _healthTracker = healthTracker; + + _monitoringService.StatusChanged += OnStatusChanged; + } + + public override async Task StreamStatusUpdates( + StatusRequest request, + IServerStreamWriter responseStream, + ServerCallContext context) + { + _logger.LogInformation("Client {ClientId} connected to status stream", request.ClientId); + _activeStreams[request.ClientId] = responseStream; + + // Send current status immediately + var currentStatus = await _monitoringService.GetCurrentStatusAsync(); + if (currentStatus != null) + { + await responseStream.WriteAsync(MapToStatusUpdate(currentStatus)); + } + + // Keep stream open until cancelled + try + { + await Task.Delay(Timeout.Infinite, context.CancellationToken); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Client {ClientId} disconnected from status stream", request.ClientId); + } + finally + { + _activeStreams.TryRemove(request.ClientId, out _); + } + } + + public override async Task GetCurrentStatus( + StatusRequest request, + ServerCallContext context) + { + _logger.LogDebug("GetCurrentStatus called by {ClientId}", request.ClientId); + + var status = await _monitoringService.GetCurrentStatusAsync(); + return status != null + ? MapToStatusUpdate(status) + : new StatusUpdate { StatusLevel = (int)Core.Models.StatusLevel.Unknown }; + } + + public override Task GetServiceHealth( + HealthRequest request, + ServerCallContext context) + { + var health = _healthTracker.GetCurrentHealth(); + return Task.FromResult(new HealthResponse + { + State = (Contracts.ServiceState)health.State, + Version = health.Version, + UptimeSeconds = health.UptimeSeconds, + Message = health.Message + }); + } + + private void OnStatusChanged(object? sender, StatusChangedEventArgs e) + { + var update = MapToStatusUpdate(e.Status); + + foreach (var (clientId, stream) in _activeStreams) + { + try + { + stream.WriteAsync(update).Wait(TimeSpan.FromSeconds(5)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send status update to client {ClientId}", clientId); + _activeStreams.TryRemove(clientId, out _); + } + } + } + + private StatusUpdate MapToStatusUpdate(Core.Models.ApplicationStatus status) + { + var colorConfig = _colorConfigService.GetCurrentConfiguration(); + var statusColor = status.StatusLevel switch + { + Core.Models.StatusLevel.Available => colorConfig.Available, + Core.Models.StatusLevel.Busy => colorConfig.Busy, + Core.Models.StatusLevel.DoNotDisturb => colorConfig.DoNotDisturb, + Core.Models.StatusLevel.Away => colorConfig.Away, + Core.Models.StatusLevel.Offline => colorConfig.Offline, + _ => colorConfig.Unknown + }; + + return new StatusUpdate + { + StatusId = Guid.NewGuid().ToString(), + ApplicationName = status.ApplicationName, + StatusValue = status.StatusValue, + StatusLabel = status.StatusLabel, + StatusLevel = (int)status.StatusLevel, + ColorHex = statusColor.HexColor, + TimestampUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Details = status.Details + }; + } +} \ No newline at end of file diff --git a/src/StatusLightChecker.Service/Services/HealthHeartbeatService.cs b/src/StatusLightChecker.Service/Services/HealthHeartbeatService.cs new file mode 100644 index 0000000..2a9e148 --- /dev/null +++ b/src/StatusLightChecker.Service/Services/HealthHeartbeatService.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StatusLightChecker.Core.Services; + +namespace StatusLightChecker.Service.Services; + +public class HealthHeartbeatService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IServiceHealthTracker _healthTracker; + private readonly TimeSpan _heartbeatInterval = TimeSpan.FromSeconds(30); + + public HealthHeartbeatService( + ILogger logger, + IServiceHealthTracker healthTracker) + { + _logger = logger; + _healthTracker = healthTracker; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogDebug("Health heartbeat service starting..."); + + while (!stoppingToken.IsCancellationRequested) + { + _healthTracker.RecordHeartbeat(); + _logger.LogDebug("Health heartbeat recorded"); + + await Task.Delay(_heartbeatInterval, stoppingToken); + } + } +} \ No newline at end of file diff --git a/src/StatusLightChecker.Service/Services/TeamsStatusDetector.cs b/src/StatusLightChecker.Service/Services/TeamsStatusDetector.cs new file mode 100644 index 0000000..3d47e70 --- /dev/null +++ b/src/StatusLightChecker.Service/Services/TeamsStatusDetector.cs @@ -0,0 +1,135 @@ +using FlaUI.Core; +using FlaUI.Core.AutomationElements; +using FlaUI.UIA3; +using Microsoft.Extensions.Logging; +using StatusLightChecker.Core.Models; +using StatusLightChecker.Core.Services; +using System.Diagnostics; + +namespace StatusLightChecker.Service.Services; + +public class TeamsStatusDetector : IStatusDetector +{ + private readonly ILogger _logger; + private const string TeamsProcessName = "Teams"; + private const string TeamsProcessName2 = "ms-teams"; + + public string ApplicationName => "Microsoft Teams"; + + public TeamsStatusDetector(ILogger logger) + { + _logger = logger; + } + + public Task DetectStatusAsync(CancellationToken cancellationToken = default) + { + try + { + var process = FindTeamsProcess(); + if (process == null) + { + return Task.FromResult(null); + } + + var status = DetectStatusFromProcess(process); + return Task.FromResult(status); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error detecting Teams status"); + return Task.FromResult(null); + } + } + + private Process? FindTeamsProcess() + { + var process = Process.GetProcessesByName(TeamsProcessName).FirstOrDefault(); + if (process != null) return process; + + process = Process.GetProcessesByName(TeamsProcessName2).FirstOrDefault(); + if (process != null) return process; + + process = Process.GetProcessesByName(TeamsProcessName + ".exe").FirstOrDefault(); + return process; + } + + private ApplicationStatus DetectStatusFromProcess(Process process) + { + var statusLevel = StatusLevel.Away; + var statusLabel = "Away"; + var details = $"Process ID: {process.Id}"; + + try + { + using var automation = new UIA3Automation(); + var app = FlaUI.Core.Application.Attach(process); + var window = app.GetMainWindow(automation); + + if (window != null) + { + var statusIndicator = FindStatusIndicator(window); + if (statusIndicator != null) + { + (statusLevel, statusLabel) = ParseStatusFromElement(statusIndicator); + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not access Teams UI elements"); + statusLevel = StatusLevel.Available; + statusLabel = "Available"; + } + + return new ApplicationStatus + { + ApplicationName = ApplicationName, + StatusValue = statusLevel.ToString().ToLowerInvariant(), + StatusLabel = statusLabel, + StatusLevel = statusLevel, + Details = details + }; + } + + private AutomationElement? FindStatusIndicator(AutomationElement window) + { + try + { + var buttons = window.FindAllDescendants(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button)); + + foreach (var button in buttons) + { + var name = button.Name?.ToLowerInvariant() ?? ""; + if (name.Contains("status") || name.Contains("presence") || + name.Contains("available") || name.Contains("busy") || + name.Contains("away") || name.Contains("do not disturb")) + { + return button; + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error finding status indicator"); + } + return null; + } + + private (StatusLevel Level, string Label) ParseStatusFromElement(AutomationElement element) + { + var name = element.Name?.ToLowerInvariant() ?? ""; + + if (name.Contains("available") || name.Contains("green")) + return (StatusLevel.Available, "Available"); + if (name.Contains("busy") || name.Contains("red")) + return (StatusLevel.Busy, "Busy"); + if (name.Contains("do not disturb") || name.Contains("dnd") || name.Contains("presenting")) + return (StatusLevel.DoNotDisturb, "Do Not Disturb"); + if (name.Contains("away") || name.Contains("yellow")) + return (StatusLevel.Away, "Away"); + if (name.Contains("offline") || name.Contains("gray")) + return (StatusLevel.Offline, "Offline"); + + return (StatusLevel.Unknown, "Unknown"); + } +} diff --git a/src/StatusLightChecker.Service/StatusLightChecker.Service.csproj b/src/StatusLightChecker.Service/StatusLightChecker.Service.csproj new file mode 100644 index 0000000..3cd8905 --- /dev/null +++ b/src/StatusLightChecker.Service/StatusLightChecker.Service.csproj @@ -0,0 +1,42 @@ + + + + Exe + net10.0-windows + win-x64 + enable + enable + statuslightchecker-service-2025 + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/src/StatusLightChecker.Service/appsettings.Development.json b/src/StatusLightChecker.Service/appsettings.Development.json new file mode 100644 index 0000000..0b33292 --- /dev/null +++ b/src/StatusLightChecker.Service/appsettings.Development.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Grpc": "Debug" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug" + } + }, + "ServiceConfiguration": { + "GrpcEndpoint": "http://localhost:50052" + } +} \ No newline at end of file diff --git a/src/StatusLightChecker.Service/appsettings.json b/src/StatusLightChecker.Service/appsettings.json new file mode 100644 index 0000000..c9c2c21 --- /dev/null +++ b/src/StatusLightChecker.Service/appsettings.json @@ -0,0 +1,48 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Grpc": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "Serilog": { + "Using": ["Serilog.Sinks.Console"], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" + } + } + ], + "Enrich": ["FromLogContext"] + }, + "ServiceConfiguration": { + "ServiceName": "StatusLightCheckerService", + "DisplayName": "Status Light Checker Service", + "Description": "Monitors Microsoft Teams and other application statuses for LED control", + "GrpcEndpoint": "http://localhost:50051" + }, + "Database": { + "ConnectionString": "Data Source=StatusLightChecker.db" + }, + "Kestrel": { + "Endpoints": { + "Grpc": { + "Url": "http://localhost:50051", + "Protocols": "Http2" + } + } + } +} diff --git a/src/StatusLightChecker.sln b/src/StatusLightChecker.sln index 9653209..0de851d 100644 --- a/src/StatusLightChecker.sln +++ b/src/StatusLightChecker.sln @@ -1,10 +1,17 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.11.35327.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusLightChecker", "StatusLightChecker\StatusLightChecker.csproj", "{06F16C62-C908-4452-B052-CB57B5C6AC8C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusLightChecker.Core", "StatusLightChecker.Core\StatusLightChecker.Core.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusLightChecker.Service", "StatusLightChecker.Service\StatusLightChecker.Service.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F23456789012}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusLightChecker.Contracts", "StatusLightChecker.Contracts\StatusLightChecker.Contracts.csproj", "{C3D4E5F6-A7B8-9012-CDEF-345678901234}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusLightChecker.Installer", "StatusLightChecker.Installer\StatusLightChecker.Installer.csproj", "{9EC27587-AC73-405F-9A4D-BB402E13DC1E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +22,21 @@ Global {06F16C62-C908-4452-B052-CB57B5C6AC8C}.Debug|Any CPU.Build.0 = Debug|Any CPU {06F16C62-C908-4452-B052-CB57B5C6AC8C}.Release|Any CPU.ActiveCfg = Release|Any CPU {06F16C62-C908-4452-B052-CB57B5C6AC8C}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-345678901234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-345678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-345678901234}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-345678901234}.Release|Any CPU.Build.0 = Release|Any CPU + {9EC27587-AC73-405F-9A4D-BB402E13DC1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9EC27587-AC73-405F-9A4D-BB402E13DC1E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9EC27587-AC73-405F-9A4D-BB402E13DC1E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -22,4 +44,4 @@ Global GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5BAEB2C4-7651-4766-95DF-431D382E8895} EndGlobalSection -EndGlobal +EndGlobal \ No newline at end of file diff --git a/src/StatusLightChecker/App.xaml b/src/StatusLightChecker/App.xaml index c276127..cd171d3 100644 --- a/src/StatusLightChecker/App.xaml +++ b/src/StatusLightChecker/App.xaml @@ -1,16 +1,40 @@ - + xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" + xmlns:converters="clr-namespace:StatusLightChecker.Converters" + xmlns:vm="clr-namespace:StatusLightChecker.ViewModels"> + - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - + + \ No newline at end of file diff --git a/src/StatusLightChecker/App.xaml.cs b/src/StatusLightChecker/App.xaml.cs index eea7b2c..5480af1 100644 --- a/src/StatusLightChecker/App.xaml.cs +++ b/src/StatusLightChecker/App.xaml.cs @@ -1,28 +1,93 @@ -using System.Windows; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using StatusLightChecker.Services; using StatusLightChecker.ViewModels; +using System.IO; +using System.Windows; +using System.Windows.Threading; -namespace StatusLightChecker +namespace StatusLightChecker; + +public partial class App : Application { - public delegate void StatusChangedEventHandler(); - /// - /// Interaction logic for App.xaml - /// - public partial class App : Application + public static IServiceProvider ServiceProvider { get; private set; } = null!; + + protected override void OnStartup(StartupEventArgs e) { + base.OnStartup(e); + + var services = ConfigureServices(); + ServiceProvider = services.BuildServiceProvider(); - protected override void OnStartup(StartupEventArgs e) + // Log to AppData\Local so the client never tries to write into ProgramFiles. + var logDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Jublin", "StatusLightChecker", "Client", "logs"); + Directory.CreateDirectory(logDir); + + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(ServiceProvider.GetRequiredService()) + .WriteTo.File( + Path.Combine(logDir, "client-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 7) + .CreateLogger(); + + var mainWindow = ServiceProvider.GetRequiredService(); + mainWindow.Show(); + } + + protected override void OnExit(ExitEventArgs e) + { + if (ServiceProvider is IDisposable disposable) { - + disposable.Dispose(); } - - protected override async void OnExit(ExitEventArgs e) + Log.CloseAndFlush(); + base.OnExit(e); + } + + private static IServiceCollection ConfigureServices() + { + var services = new ServiceCollection(); + + // Load appsettings.json from the executable's directory, not the working + // directory. These diverge when launched via a Start Menu shortcut. + var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddUserSecrets(optional: true) + .Build(); + + services.AddSingleton(configuration); + + services.AddLogging(builder => { - await MainViewModel.Instance?.SerialPort?.WriteAsync([0,0,0], 0, 3)!; - await MainViewModel.Instance?.SerialPort?.FlushAsync()!; - await Task.Delay(1000); - MainViewModel.Instance?.SerialPort?.Close(); - base.OnExit(e); - } + builder.AddSerilog(); + builder.SetMinimumLevel(LogLevel.Information); + }); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + return services; } + private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + Log.Error(e.Exception, "Unhandled exception occurred"); + + MessageBox.Show( + $"An unhandled exception occurred: {e.Exception.Message}", + "Error", + MessageBoxButton.OK, + MessageBoxImage.Error); + + e.Handled = true; + } } diff --git a/src/StatusLightChecker/Converters/Converters.cs b/src/StatusLightChecker/Converters/Converters.cs new file mode 100644 index 0000000..53bd3c8 --- /dev/null +++ b/src/StatusLightChecker/Converters/Converters.cs @@ -0,0 +1,111 @@ +using StatusLightChecker.Core.Models; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace StatusLightChecker.Converters; + +public class StatusLevelToBrushConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is StatusLevel level) + { + var resources = App.Current.Resources; + return level switch + { + StatusLevel.Available => resources["StatusAvailableBrush"] ?? Brushes.Green, + StatusLevel.Busy => resources["StatusBusyBrush"] ?? Brushes.Red, + StatusLevel.DoNotDisturb => resources["StatusDoNotDisturbBrush"] ?? Brushes.DarkRed, + StatusLevel.Away => resources["StatusAwayBrush"] ?? Brushes.Orange, + StatusLevel.Offline => resources["StatusOfflineBrush"] ?? Brushes.Gray, + _ => resources["StatusUnknownBrush"] ?? Brushes.LightGray + }; + } + return Brushes.Gray; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class StatusLevelToIconConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is StatusLevel level) + { + return level switch + { + StatusLevel.Available => "CheckCircle", + StatusLevel.Busy => "Circle", + StatusLevel.DoNotDisturb => "MinusCircle", + StatusLevel.Away => "ClockOutline", + StatusLevel.Offline => "CloseCircle", + _ => "HelpCircle" + }; + } + return "HelpCircle"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class HexToColorConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + try + { + if (value is string hex && !string.IsNullOrWhiteSpace(hex)) + { + return (Color)ColorConverter.ConvertFromString(hex)!; + } + } + catch { } + return Colors.Gray; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class InverseBooleanConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is bool b && !b; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is bool b && !b; + } +} + +public class ServiceStatusToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string status && parameter is string targetStatus) + { + var visibility = status.Equals(targetStatus, StringComparison.OrdinalIgnoreCase) + ? System.Windows.Visibility.Visible + : System.Windows.Visibility.Collapsed; + return visibility; + } + return System.Windows.Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/StatusLightChecker/MainWindow.xaml b/src/StatusLightChecker/MainWindow.xaml index 4143e82..5523789 100644 --- a/src/StatusLightChecker/MainWindow.xaml +++ b/src/StatusLightChecker/MainWindow.xaml @@ -1,102 +1,315 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/StatusLightChecker/MainWindow.xaml.cs b/src/StatusLightChecker/MainWindow.xaml.cs index ab8c6a9..6f9d984 100644 --- a/src/StatusLightChecker/MainWindow.xaml.cs +++ b/src/StatusLightChecker/MainWindow.xaml.cs @@ -1,34 +1,24 @@ -using System.Windows; -using System.Windows.Input; +using Microsoft.Extensions.DependencyInjection; using StatusLightChecker.ViewModels; -using Wpf.Ui.Appearance; -using Wpf.Ui.Controls; +using System.Windows; +using System.ComponentModel; -namespace StatusLightChecker +namespace StatusLightChecker; + +public partial class MainWindow : Window { - /// - /// Interaction logic for MainWindow.xaml - /// - public partial class MainWindow : Window + public MainWindow() { - public MainWindow() - { - InitializeComponent(); - ApplicationThemeManager.Apply( - ApplicationTheme.Dark, // Theme type - WindowBackdropType.Acrylic, // Background type - true // Whether to change accents automatically - ); - DataContext = MainViewModel.Instance; - } + InitializeComponent(); + DataContext = App.ServiceProvider.GetRequiredService(); + } - private void LogBox_OnTextInput(object sender, TextCompositionEventArgs e) + protected override void OnClosing(CancelEventArgs e) + { + if (DataContext is MainViewModel vm) { - LogBox.ScrollToEnd(); - if (LogBox.Document.Blocks.Count > 200) - { - LogBox.Document.Blocks.Clear(); - } + vm.Dispose(); } + base.OnClosing(e); } } \ No newline at end of file diff --git a/src/StatusLightChecker/Services/GrpcClientService.cs b/src/StatusLightChecker/Services/GrpcClientService.cs new file mode 100644 index 0000000..97ea93d --- /dev/null +++ b/src/StatusLightChecker/Services/GrpcClientService.cs @@ -0,0 +1,233 @@ +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using StatusLightChecker.Contracts; +using StatusLightChecker.Core.Models; +using System.Collections.ObjectModel; + +namespace StatusLightChecker.Services; + +public class GrpcClientService : IDisposable +{ + private readonly ILogger _logger; + private readonly string _clientId; + private GrpcChannel? _channel; + private global::StatusLightChecker.Contracts.StatusService.StatusServiceClient? _statusClient; + private global::StatusLightChecker.Contracts.SettingsService.SettingsServiceClient? _settingsClient; + private CancellationTokenSource? _statusStreamCts; + private CancellationTokenSource? _configStreamCts; + private readonly ObservableCollection _statusUpdates; + + public event EventHandler? StatusReceived; + public event EventHandler? ConfigurationReceived; + public event EventHandler? ServiceStateChanged; + + public bool IsConnected => _channel?.State == ConnectivityState.Ready; + public ObservableCollection StatusUpdates => _statusUpdates; + + public GrpcClientService(ILogger logger, IConfiguration configuration) + { + _logger = logger; + _clientId = configuration["ClientConfiguration:ClientId"] ?? $"WpfClient_{Guid.NewGuid():N}"; + _statusUpdates = new ObservableCollection(); + + var endpoint = configuration["ClientConfiguration:GrpcEndpoint"] ?? "http://localhost:50051"; + InitializeChannel(endpoint); + } + + private void InitializeChannel(string endpoint) + { + try + { + _logger.LogInformation("Initializing gRPC channel to {Endpoint}", endpoint); + + _channel = GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions + { + MaxReceiveMessageSize = 1024 * 1024, + MaxSendMessageSize = 1024 * 1024 + }); + + _statusClient = new global::StatusLightChecker.Contracts.StatusService.StatusServiceClient(_channel); + _settingsClient = new global::StatusLightChecker.Contracts.SettingsService.SettingsServiceClient(_channel); + + _logger.LogInformation("gRPC channel initialized successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize gRPC channel"); + throw; + } + } + + public async Task StartStatusStreamAsync() + { + if (_statusClient == null) return; + + _statusStreamCts = new CancellationTokenSource(); + + try + { + using var call = _statusClient.StreamStatusUpdates(new StatusRequest { ClientId = _clientId }); + + await foreach (var update in call.ResponseStream.ReadAllAsync(_statusStreamCts.Token)) + { + _statusUpdates.Insert(0, update); + if (_statusUpdates.Count > 100) _statusUpdates.RemoveAt(_statusUpdates.Count - 1); + + StatusReceived?.Invoke(this, update); + ServiceStateChanged?.Invoke(this, (ServiceState)update.StatusLevel); + } + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) + { + _logger.LogInformation("Status stream cancelled"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in status stream"); + } + } + + public async Task StartConfigurationStreamAsync() + { + if (_settingsClient == null) return; + + _configStreamCts = new CancellationTokenSource(); + + try + { + using var call = _settingsClient.StreamColorConfigUpdates(new ColorConfigRequest { ClientId = _clientId }); + + await foreach (var config in call.ResponseStream.ReadAllAsync(_configStreamCts.Token)) + { + ConfigurationReceived?.Invoke(this, config); + } + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) + { + _logger.LogInformation("Config stream cancelled"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in config stream"); + } + } + + public async Task GetServiceHealthAsync() + { + if (_statusClient == null) return null; + + try + { + return await _statusClient.GetServiceHealthAsync(new HealthRequest()); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get service health"); + return null; + } + } + + public async Task GetColorConfigurationAsync() + { + if (_settingsClient == null) return null; + + try + { + var response = await _settingsClient.GetColorConfigAsync(new ColorConfigRequest { ClientId = _clientId }); + if (response.Success) + { + return MapFromProto(response.Config); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get color configuration"); + } + return null; + } + + public async Task UpdateColorConfigurationAsync(StatusLightChecker.Core.Models.ColorConfiguration config) + { + if (_settingsClient == null) return false; + + try + { + var protoConfig = MapToProto(config); + var response = await _settingsClient.UpdateColorConfigAsync(new UpdateColorConfigRequest + { + ClientId = _clientId, + Config = protoConfig + }); + return response.Success; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update color configuration"); + return false; + } + } + + public void StopStreams() + { + _statusStreamCts?.Cancel(); + _configStreamCts?.Cancel(); + } + + public void Dispose() + { + StopStreams(); + _statusStreamCts?.Dispose(); + _configStreamCts?.Dispose(); + _channel?.Dispose(); + } + + private static Contracts.ColorConfiguration MapToProto(StatusLightChecker.Core.Models.ColorConfiguration config) + { + return new Contracts.ColorConfiguration + { + Available = MapToProto(config.Available), + Busy = MapToProto(config.Busy), + DoNotDisturb = MapToProto(config.DoNotDisturb), + Away = MapToProto(config.Away), + Offline = MapToProto(config.Offline), + Unknown = MapToProto(config.Unknown) + }; + } + + private static Contracts.StatusColor MapToProto(StatusLightChecker.Core.Models.StatusColor color) + { + return new Contracts.StatusColor + { + StatusLevel = (int)color.StatusLevel, + Name = color.Name, + HexColor = color.HexColor, + IsBlinking = color.IsBlinking + }; + } + + private static StatusLightChecker.Core.Models.ColorConfiguration MapFromProto(Contracts.ColorConfiguration proto) + { + return new StatusLightChecker.Core.Models.ColorConfiguration + { + Available = MapFromProto(proto.Available), + Busy = MapFromProto(proto.Busy), + DoNotDisturb = MapFromProto(proto.DoNotDisturb), + Away = MapFromProto(proto.Away), + Offline = MapFromProto(proto.Offline), + Unknown = MapFromProto(proto.Unknown) + }; + } + + private static StatusLightChecker.Core.Models.StatusColor MapFromProto(Contracts.StatusColor proto) + { + return new StatusLightChecker.Core.Models.StatusColor + { + StatusLevel = (StatusLightChecker.Core.Models.StatusLevel)proto.StatusLevel, + Name = proto.Name, + HexColor = proto.HexColor, + IsBlinking = proto.IsBlinking + }; + } +} diff --git a/src/StatusLightChecker/Services/ServiceControllerWrapper.cs b/src/StatusLightChecker/Services/ServiceControllerWrapper.cs new file mode 100644 index 0000000..023d97b --- /dev/null +++ b/src/StatusLightChecker/Services/ServiceControllerWrapper.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.ServiceProcess; + +namespace StatusLightChecker.Services; + +public class ServiceControllerWrapper : IDisposable +{ + private readonly ILogger _logger; + private readonly string _serviceName; + private ServiceController? _serviceController; + + public event EventHandler? StatusChanged; + + public ServiceControllerWrapper(ILogger logger, IConfiguration configuration) + { + _logger = logger; + _serviceName = configuration["ClientConfiguration:ServiceName"] ?? "StatusLightCheckerService"; + RefreshServiceController(); + } + + public ServiceControllerStatus? CurrentStatus => _serviceController?.Status; + + public bool IsInstalled + { + get + { + try + { + RefreshServiceController(); + return _serviceController != null; + } + catch + { + return false; + } + } + } + + public bool CanStart => _serviceController != null; + public bool CanStop => _serviceController?.CanStop ?? false; + public bool CanPause => false; + + public async Task StartAsync() + { + if (_serviceController == null) return; + + try + { + if (_serviceController.Status == ServiceControllerStatus.Stopped) + { + _logger.LogInformation("Starting service {ServiceName}", _serviceName); + _serviceController.Start(); + await Task.Run(() => _serviceController.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(30))); + StatusChanged?.Invoke(this, _serviceController.Status); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start service"); + throw; + } + } + + public async Task StopAsync() + { + if (_serviceController == null) return; + + try + { + if (_serviceController.CanStop) + { + _logger.LogInformation("Stopping service {ServiceName}", _serviceName); + _serviceController.Stop(); + await Task.Run(() => _serviceController.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(30))); + StatusChanged?.Invoke(this, _serviceController.Status); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to stop service"); + throw; + } + } + + public async Task RestartAsync() + { + await StopAsync(); + await Task.Delay(1000); + await StartAsync(); + } + + public void Refresh() + { + RefreshServiceController(); + } + + private void RefreshServiceController() + { + try + { + _serviceController?.Dispose(); + _serviceController = new ServiceController(_serviceName); + _serviceController.Refresh(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Service {ServiceName} not found or not accessible", _serviceName); + _serviceController = null; + } + } + + public void Dispose() + { + _serviceController?.Dispose(); + } +} \ No newline at end of file diff --git a/src/StatusLightChecker/StatusCheckers/StatusCheckerBase.cs b/src/StatusLightChecker/StatusCheckers/StatusCheckerBase.cs index eb7b5ef..7c1db78 100644 --- a/src/StatusLightChecker/StatusCheckers/StatusCheckerBase.cs +++ b/src/StatusLightChecker/StatusCheckers/StatusCheckerBase.cs @@ -1,9 +1,14 @@ -using System.Windows.Media; +using System; +using System.Windows.Media; using System.Windows.Threading; +using System; +using StatusLightChecker.Core.Models; +using StatusLightChecker.Core.StatusCheckers; using FlaUI.Core.AutomationElements; using FlaUI.UIA3; using Serilog; using StatusLightChecker.Enumerations; +using StatusLightChecker.ViewModels; namespace StatusLightChecker.StatusCheckers; @@ -21,7 +26,7 @@ public abstract class StatusCheckerBase( protected AutomationElement? StoredWindow; public abstract Task GetCurrentStatus(); - private DispatcherTimer statusTimer; + private DispatcherTimer? statusTimer; public StatusChangedEventHandler StatusChanged { get; set; } = statusChangedEventHandler; @@ -45,12 +50,12 @@ public abstract class StatusCheckerBase( public async void StartChecking() { InitializeTimer(); - statusTimer.Start(); + statusTimer?.Start(); } public async void StopChecking() { - statusTimer.Stop(); + statusTimer?.Stop(); await CancellationTokenSource.CancelAsync(); } @@ -62,7 +67,7 @@ public void InitializeTimer() private async void StatusTimerCallback(object? sender, EventArgs e) { await GetCurrentStatus(); - statusTimer.Start(); + statusTimer?.Start(); } private List? FindWindows() diff --git a/src/StatusLightChecker/StatusCheckers/TeamsApplicationStatusChecker.cs b/src/StatusLightChecker/StatusCheckers/TeamsApplicationStatusChecker.cs index b05ed62..7ce343f 100644 --- a/src/StatusLightChecker/StatusCheckers/TeamsApplicationStatusChecker.cs +++ b/src/StatusLightChecker/StatusCheckers/TeamsApplicationStatusChecker.cs @@ -1,9 +1,12 @@ -using System.Drawing; +using System; +using System.Drawing; using System.Windows.Media; +using StatusLightChecker.Core.StatusCheckers; using FlaUI.Core.AutomationElements; using FlaUI.UIA3; using Serilog; using StatusLightChecker.Enumerations; +using StatusLightChecker.ViewModels; using Brushes = System.Windows.Media.Brushes; namespace StatusLightChecker.StatusCheckers; diff --git a/src/StatusLightChecker/StatusLightChecker.csproj b/src/StatusLightChecker/StatusLightChecker.csproj index 65d84e5..b5bd822 100644 --- a/src/StatusLightChecker/StatusLightChecker.csproj +++ b/src/StatusLightChecker/StatusLightChecker.csproj @@ -1,31 +1,53 @@ - + WinExe - net8.0-windows + net10.0-windows enable enable true - Jublin.xyz - 1.0.2 - 1.0.2 + false icon.ico + win-x64 + true + statuslightchecker-wpf-2025 - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + diff --git a/src/StatusLightChecker/StatusLightCheckerInstaller.cs b/src/StatusLightChecker/StatusLightCheckerInstaller.cs new file mode 100644 index 0000000..5f56a48 --- /dev/null +++ b/src/StatusLightChecker/StatusLightCheckerInstaller.cs @@ -0,0 +1,24 @@ +using System.ServiceProcess; + +namespace StatusLightChecker +{ + // TODO: Service installer needs to be updated for .NET 10 compatibility + // [System.Configuration.Install.RunInstaller(true)] + // public class StatusLightCheckerInstaller : System.Configuration.Install.Installer + // { + // public StatusLightCheckerInstaller() + // { + // var processInstaller = new System.ServiceProcess.ServiceProcessInstaller(); + // var serviceInstaller = new System.ServiceProcess.ServiceInstaller(); + + // processInstaller.Account = System.ServiceProcess.ServiceAccount.LocalSystem; + // serviceInstaller.ServiceName = "StatusLightCheckerService"; + // serviceInstaller.DisplayName = "Status Light Checker Service"; + // serviceInstaller.Description = "Monitors application status and updates lights accordingly"; + // serviceInstaller.StartType = System.ServiceProcess.ServiceStartMode.Automatic; + + // Installers.Add(processInstaller); + // Installers.Add(serviceInstaller); + // } + // } +} \ No newline at end of file diff --git a/src/StatusLightChecker/ViewModels/MainViewModel.cs b/src/StatusLightChecker/ViewModels/MainViewModel.cs index 7f7917e..5310977 100644 --- a/src/StatusLightChecker/ViewModels/MainViewModel.cs +++ b/src/StatusLightChecker/ViewModels/MainViewModel.cs @@ -1,164 +1,289 @@ -using System.Windows; -using System.Windows.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MaterialDesignThemes.Wpf; +using Microsoft.Extensions.Logging; +using StatusLightChecker.Core.Models; +using StatusLightChecker.Services; +using System.Collections.ObjectModel; +using System.ServiceProcess; using System.Windows.Media; -using System.Windows.Threading; -using CommunityToolkit.Mvvm.ComponentModel; -using Microsoft.Xaml.Behaviors.Core; -using RJCP.IO.Ports; -using Serilog; -using StatusLightChecker.Enumerations; -using StatusLightChecker.StatusCheckers; namespace StatusLightChecker.ViewModels; -internal class MainViewModel : ObservableObject +public partial class MainViewModel : ObservableObject, IDisposable { - public static MainViewModel Instance { get; } = new(); + private readonly ILogger _logger; + private readonly GrpcClientService _grpcClient; + private readonly ServiceControllerWrapper _serviceController; + private System.Timers.Timer? _refreshTimer; + private readonly System.Threading.CancellationTokenSource _cts = new(); - public SerialPortStream? SerialPort { get; private set; } + [ObservableProperty] + private string _serviceStatus = "Unknown"; - private TeamsApplicationStatusChecker? teamsApplicationStatusChecker; - - private readonly StatusChangedEventHandler statusChangedEventHandler; + [ObservableProperty] + private Brush _serviceStatusColor = Brushes.Gray; - private MainViewModel() - { - Log.Logger = new LoggerConfiguration() - .WriteTo.RichTextBox(Application.Current.MainWindow?.FindName("LogBox") as RichTextBox) - .CreateLogger(); - Log.Information("Hello, world!"); - statusChangedEventHandler = StatusChangedEvent; - } + [ObservableProperty] + private ApplicationStatus? _currentStatus; + + [ObservableProperty] + private ObservableCollection _statusHistory = new(); + + [ObservableProperty] + private ColorConfigurationViewModel _colorConfiguration = new(); + + [ObservableProperty] + private bool _isConnected; + + [ObservableProperty] + private string _connectionStatus = "Disconnected"; + + [ObservableProperty] + private bool _serviceInstalled; - private string comPort = string.Empty; - public string ComPort + [ObservableProperty] + private bool _canControlService; + + public MainViewModel( + ILogger logger, + GrpcClientService grpcClient, + ServiceControllerWrapper serviceController) { - get => comPort; - set => SetProperty(ref comPort, value); + _logger = logger; + _grpcClient = grpcClient; + _serviceController = serviceController; + + _serviceController.StatusChanged += OnServiceStatusChanged; + _grpcClient.StatusReceived += OnStatusReceived; + _grpcClient.ConfigurationReceived += OnConfigurationReceived; + + // Setup refresh timer using System.Timers.Timer instead of DispatcherTimer + _refreshTimer = new System.Timers.Timer(5000); + _refreshTimer.Elapsed += async (s, e) => await RefreshServiceStatusAsync(); + _refreshTimer.AutoReset = true; + _refreshTimer.Start(); + + _ = InitializeAsync(); } - private int baudRate = 115200; - public int BaudRate + private async Task InitializeAsync() { - get => baudRate; - set => SetProperty(ref baudRate, value); + try + { + ServiceInstalled = _serviceController.IsInstalled; + CanControlService = ServiceInstalled; + + await RefreshServiceStatusAsync(); + _ = StartGrpcStreamsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during initialization"); + } } - - private ApplicationCheck selectedApp = ApplicationCheck.MicrosoftTeams; - public ApplicationCheck SelectedApp + private async Task StartGrpcStreamsAsync() { - get => selectedApp; - set + try { - SetProperty(ref selectedApp, value); - SetupChecker(); - GetChecker()?.GetCurrentStatus(); + _ = Task.Run(async () => await _grpcClient.StartStatusStreamAsync()); + _ = Task.Run(async () => await _grpcClient.StartConfigurationStreamAsync()); + + IsConnected = true; + ConnectionStatus = "Connected"; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start gRPC streams"); + IsConnected = false; + ConnectionStatus = "Disconnected"; } } - - private IStatusChecker? GetChecker() + + private void OnStatusReceived(object? sender, StatusLightChecker.Contracts.StatusUpdate update) { - return SelectedApp switch + var viewModel = new StatusUpdateViewModel { - ApplicationCheck.MicrosoftTeams => teamsApplicationStatusChecker, - // ApplicationCheck.Slack => slackStatusChecker, - _ => null + ApplicationName = update.ApplicationName, + StatusLabel = update.StatusLabel, + StatusLevel = (StatusLevel)update.StatusLevel, + ColorHex = update.ColorHex, + Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(update.TimestampUnixMs).LocalDateTime, + Details = update.Details }; + + // Use App.Current.Dispatcher for UI thread marshaling + _ = App.Current.Dispatcher.InvokeAsync(() => + { + StatusHistory.Insert(0, viewModel); + if (StatusHistory.Count > 50) StatusHistory.RemoveAt(StatusHistory.Count - 1); + + CurrentStatus = new ApplicationStatus + { + ApplicationName = update.ApplicationName, + StatusLabel = update.StatusLabel, + StatusLevel = (StatusLevel)update.StatusLevel + }; + }); } - private void SetupChecker() + private void OnConfigurationReceived(object? sender, StatusLightChecker.Contracts.ColorConfigResponse config) { - var currChecker = GetChecker(); - currChecker?.Dispose(); - switch (SelectedApp) - { - case ApplicationCheck.MicrosoftTeams: - teamsApplicationStatusChecker = new TeamsApplicationStatusChecker(Log.Logger, statusChangedEventHandler); - break; - // case ApplicationCheck.Slack: - // slackStatusChecker = new SlackStatusChecker(Log.Logger, statusChangedEventHandler); - // break; - default: - throw new ArgumentOutOfRangeException(); - } + _ = App.Current.Dispatcher.InvokeAsync(() => + { + if (config.Config != null) + { + ColorConfiguration = new ColorConfigurationViewModel(config.Config); + } + }); } - private bool connected; + private void OnServiceStatusChanged(object? sender, ServiceControllerStatus status) + { + _ = App.Current.Dispatcher.InvokeAsync(async () => await RefreshServiceStatusAsync()); + } - private bool Connected + private async Task RefreshServiceStatusAsync() { - get => connected; - set + try + { + _serviceController.Refresh(); + + var status = _serviceController.CurrentStatus; + if (status.HasValue) + { + // Update on UI thread + await App.Current.Dispatcher.InvokeAsync(() => + { + ServiceStatus = status.Value.ToString(); + ServiceStatusColor = status.Value switch + { + ServiceControllerStatus.Running => Brushes.Green, + ServiceControllerStatus.Stopped => Brushes.Red, + ServiceControllerStatus.Paused => Brushes.Orange, + ServiceControllerStatus.StartPending or + ServiceControllerStatus.StopPending or + ServiceControllerStatus.PausePending or + ServiceControllerStatus.ContinuePending => Brushes.Yellow, + _ => Brushes.Gray + }; + }); + } + else + { + await App.Current.Dispatcher.InvokeAsync(() => + { + ServiceStatus = "Not Installed"; + ServiceStatusColor = Brushes.Gray; + }); + } + + // Also check gRPC health + var health = await _grpcClient.GetServiceHealthAsync(); + await App.Current.Dispatcher.InvokeAsync(() => + { + if (health != null) + { + IsConnected = true; + ConnectionStatus = $"Connected (v{health.Version})"; + } + else + { + IsConnected = false; + ConnectionStatus = "Disconnected"; + } + }); + } + catch (Exception ex) { - SetProperty(ref connected, value); - OnPropertyChanged(nameof(ComportButtonText)); - OnPropertyChanged(nameof(ComPortButtonCommand)); + _logger.LogDebug(ex, "Error refreshing service status"); } } - private bool showLog; - - public bool ShowLog + [RelayCommand] + private async Task StartServiceAsync() { - get => showLog; - set + try + { + await _serviceController.StartAsync(); + } + catch (Exception ex) { - SetProperty(ref showLog, value); - OnPropertyChanged(nameof(ShowLogText)); + _logger.LogError(ex, "Failed to start service"); + await ShowErrorDialogAsync($"Failed to start service: {ex.Message}"); } } - public string ShowLogText => ShowLog ? "Hide Log" : "Show Log"; - - - public ActionCommand ComPortButtonCommand => Connected ? DisconnectCommand : ConnectCommand; - - private ActionCommand ConnectCommand => new(ConnectSerialPort); - private ActionCommand DisconnectCommand => new(DisconnectSerialPort); - - private void DisconnectSerialPort() + [RelayCommand] + private async Task StopServiceAsync() { - GetChecker()?.StopChecking(); - SerialPort?.Close(); + try + { + await _serviceController.StopAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to stop service"); + await ShowErrorDialogAsync($"Failed to stop service: {ex.Message}"); + } } - private void ConnectSerialPort() + [RelayCommand] + private async Task RestartServiceAsync() { - if (GetChecker() == null) - { - SetupChecker(); - } - SerialPort = new SerialPortStream(ComPort, BaudRate); try { - SerialPort.Open(); + await _serviceController.RestartAsync(); } - catch (Exception) + catch (Exception ex) { - Connected = false; - return; + _logger.LogError(ex, "Failed to restart service"); + await ShowErrorDialogAsync($"Failed to restart service: {ex.Message}"); } - Connected = true; - GetChecker()?.StartChecking(); } - public string ComportButtonText => Connected ? "Disconnect" : "Connect"; + [RelayCommand] + private async Task RefreshStatusAsync() + { + await RefreshServiceStatusAsync(); + } - public SolidColorBrush? StatusColor => GetColorFromSelectedChecker(); + [RelayCommand] + private async Task OpenSettingsAsync() + { + var dialog = new Views.SettingsDialog + { + DataContext = new SettingsViewModel(_grpcClient, ColorConfiguration) + }; + + await DialogHost.Show(dialog, "RootDialog"); + } - private SolidColorBrush? GetColorFromSelectedChecker() + private async Task ShowErrorDialogAsync(string message) { - var checker = GetChecker(); - return checker == null ? new SolidColorBrush(Colors.Black) : checker?.GetColorFromStatus(); + var dialog = new Views.ErrorDialog { Message = message }; + await DialogHost.Show(dialog, "RootDialog"); } - - private async void StatusChangedEvent() + + public void Dispose() { - OnPropertyChanged(nameof(StatusColor)); - SerialPort?.DiscardOutBuffer(); - byte[] colorBytes = StatusColor == null ? [0, 0, 0] : [StatusColor.Color.R, StatusColor.Color.G, StatusColor.Color.B]; - await SerialPort?.WriteAsync(colorBytes, 0, colorBytes.Length)!; - await SerialPort?.FlushAsync()!; + _refreshTimer?.Stop(); + _refreshTimer?.Dispose(); + _cts?.Cancel(); + _cts?.Dispose(); + _grpcClient?.Dispose(); + _serviceController?.Dispose(); } -} \ No newline at end of file +} + +public class StatusUpdateViewModel +{ + public string ApplicationName { get; set; } = string.Empty; + public string StatusLabel { get; set; } = string.Empty; + public StatusLevel StatusLevel { get; set; } + public string ColorHex { get; set; } = "#808080"; + public DateTime Timestamp { get; set; } + public string Details { get; set; } = string.Empty; + public Brush StatusBrush => (SolidColorBrush)new BrushConverter().ConvertFromString(ColorHex)! ?? Brushes.Gray; +} diff --git a/src/StatusLightChecker/ViewModels/SettingsViewModel.cs b/src/StatusLightChecker/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..034d179 --- /dev/null +++ b/src/StatusLightChecker/ViewModels/SettingsViewModel.cs @@ -0,0 +1,179 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MaterialDesignThemes.Wpf; +using StatusLightChecker.Core.Models; +using StatusLightChecker.Services; +using System.Windows.Media; + +namespace StatusLightChecker.ViewModels; + +public partial class ColorConfigurationViewModel : ObservableObject +{ + [ObservableProperty] + private StatusColorViewModel _available = new(); + + [ObservableProperty] + private StatusColorViewModel _busy = new(); + + [ObservableProperty] + private StatusColorViewModel _doNotDisturb = new(); + + [ObservableProperty] + private StatusColorViewModel _away = new(); + + [ObservableProperty] + private StatusColorViewModel _offline = new(); + + [ObservableProperty] + private StatusColorViewModel _unknown = new(); + + public ColorConfigurationViewModel() + { + _available = new StatusColorViewModel { Name = "Available", HexColor = "#00CC6A", StatusLevel = StatusLevel.Available }; + _busy = new StatusColorViewModel { Name = "Busy", HexColor = "#FF0000", StatusLevel = StatusLevel.Busy }; + _doNotDisturb = new StatusColorViewModel { Name = "Do Not Disturb", HexColor = "#B30000", StatusLevel = StatusLevel.DoNotDisturb, IsBlinking = true }; + _away = new StatusColorViewModel { Name = "Away", HexColor = "#FFCC00", StatusLevel = StatusLevel.Away }; + _offline = new StatusColorViewModel { Name = "Offline", HexColor = "#808080", StatusLevel = StatusLevel.Offline }; + _unknown = new StatusColorViewModel { Name = "Unknown", HexColor = "#CCCCCC", StatusLevel = StatusLevel.Unknown }; + } + + public ColorConfigurationViewModel(Contracts.ColorConfiguration config) + { + _available = new StatusColorViewModel(config.Available); + _busy = new StatusColorViewModel(config.Busy); + _doNotDisturb = new StatusColorViewModel(config.DoNotDisturb); + _away = new StatusColorViewModel(config.Away); + _offline = new StatusColorViewModel(config.Offline); + _unknown = new StatusColorViewModel(config.Unknown); + } + + public ColorConfiguration ToConfiguration() + { + return new ColorConfiguration + { + Available = Available.ToStatusColor(), + Busy = Busy.ToStatusColor(), + DoNotDisturb = DoNotDisturb.ToStatusColor(), + Away = Away.ToStatusColor(), + Offline = Offline.ToStatusColor(), + Unknown = Unknown.ToStatusColor() + }; + } +} + +public partial class StatusColorViewModel : ObservableObject +{ + [ObservableProperty] + private StatusLevel _statusLevel; + + [ObservableProperty] + private string _name = string.Empty; + + [ObservableProperty] + private string _hexColor = "#808080"; + + [ObservableProperty] + private bool _isBlinking; + + public Brush ColorBrush => (SolidColorBrush)new BrushConverter().ConvertFromString(HexColor)! ?? Brushes.Gray; + + public StatusColorViewModel() { } + + public StatusColorViewModel(Contracts.StatusColor color) + { + StatusLevel = (StatusLevel)color.StatusLevel; + Name = color.Name; + HexColor = color.HexColor; + IsBlinking = color.IsBlinking; + } + + public StatusColor ToStatusColor() + { + return new StatusColor + { + StatusLevel = StatusLevel, + Name = Name, + HexColor = HexColor, + IsBlinking = IsBlinking + }; + } +} + +public partial class SettingsViewModel : ObservableObject +{ + private readonly GrpcClientService _grpcClient; + + [ObservableProperty] + private ColorConfigurationViewModel _configuration; + + [ObservableProperty] + private string _statusMessage = string.Empty; + + [ObservableProperty] + private bool _isSaving; + + public SettingsViewModel(GrpcClientService grpcClient, ColorConfigurationViewModel initialConfig) + { + _grpcClient = grpcClient; + Configuration = initialConfig; + } + + [RelayCommand] + private async Task SaveConfigurationAsync() + { + try + { + IsSaving = true; + StatusMessage = "Saving..."; + + var config = Configuration.ToConfiguration(); + var success = await _grpcClient.UpdateColorConfigurationAsync(config); + + if (success) + { + StatusMessage = "Settings saved successfully!"; + // Apply the colors dynamically + await App.Current.Dispatcher.InvokeAsync(() => ApplyColorsToTheme(config)); + } + else + { + StatusMessage = "Failed to save settings."; + } + } + catch (Exception ex) + { + StatusMessage = $"Error: {ex.Message}"; + } + finally + { + IsSaving = false; + } + } + + [RelayCommand] + private void ResetToDefaults() + { + Configuration = new ColorConfigurationViewModel(); + StatusMessage = "Reset to defaults. Click Save to apply."; + } + + [RelayCommand] + private void CloseDialog() + { + // Close dialog via command + DialogHost.CloseDialogCommand.Execute(null, null); + } + + private static void ApplyColorsToTheme(ColorConfiguration config) + { + // Apply colors to application resources for dynamic theming + var resources = App.Current.Resources; + + resources["StatusAvailableBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.Available.HexColor)!; + resources["StatusBusyBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.Busy.HexColor)!; + resources["StatusDoNotDisturbBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.DoNotDisturb.HexColor)!; + resources["StatusAwayBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.Away.HexColor)!; + resources["StatusOfflineBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.Offline.HexColor)!; + resources["StatusUnknownBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.Unknown.HexColor)!; + } +} diff --git a/src/StatusLightChecker/Views/ErrorDialog.xaml b/src/StatusLightChecker/Views/ErrorDialog.xaml new file mode 100644 index 0000000..90b22eb --- /dev/null +++ b/src/StatusLightChecker/Views/ErrorDialog.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/StatusLightChecker/Views/ErrorDialog.xaml.cs b/src/StatusLightChecker/Views/ErrorDialog.xaml.cs new file mode 100644 index 0000000..d405be7 --- /dev/null +++ b/src/StatusLightChecker/Views/ErrorDialog.xaml.cs @@ -0,0 +1,22 @@ +using System.Windows; +using System.Windows.Controls; + +namespace StatusLightChecker.Views; + +public partial class ErrorDialog : UserControl +{ + public static readonly DependencyProperty MessageProperty = + DependencyProperty.Register(nameof(Message), typeof(string), typeof(ErrorDialog), + new PropertyMetadata(string.Empty)); + + public string Message + { + get => (string)GetValue(MessageProperty); + set => SetValue(MessageProperty, value); + } + + public ErrorDialog() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/StatusLightChecker/Views/SettingsDialog.xaml b/src/StatusLightChecker/Views/SettingsDialog.xaml new file mode 100644 index 0000000..5e5fcd8 --- /dev/null +++ b/src/StatusLightChecker/Views/SettingsDialog.xaml @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 8 + 8 + + + + + + + + + + + + + + + + + + + 8 + 8 + + + + + + + + + + + + + + + + + + + 8 + 8 + + + + + + + + + + + + + + + + + + + 8 + 8 + + + + + + + + + + + + + + + + + + + 8 + 8 + + + + + + + + + + + + + + + + + + + 8 + 8 + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/StatusLightChecker/Views/SettingsDialog.xaml.cs b/src/StatusLightChecker/Views/SettingsDialog.xaml.cs new file mode 100644 index 0000000..c568050 --- /dev/null +++ b/src/StatusLightChecker/Views/SettingsDialog.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace StatusLightChecker.Views; + +public partial class SettingsDialog : UserControl +{ + public SettingsDialog() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/StatusLightChecker/appsettings.json b/src/StatusLightChecker/appsettings.json new file mode 100644 index 0000000..cd321ce --- /dev/null +++ b/src/StatusLightChecker/appsettings.json @@ -0,0 +1,30 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" + } + } + ] + }, + "ClientConfiguration": { + "ServiceName": "StatusLightCheckerService", + "GrpcEndpoint": "http://localhost:50051", + "ClientId": "WpfClient" + } +}