File: Model\ThemeManager.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Aspire.Dashboard.Utils;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Model;
 
public sealed record ThemeSettings(string? SelectedTheme, string EffectiveTheme);
 
public interface IThemeResolver
{
    Task<ThemeSettings> GetThemeSettingsAsync(CancellationToken cancellationToken);
}
 
public sealed class BrowserThemeResolver(IJSRuntime jsRuntime) : IThemeResolver, IAsyncDisposable
{
    private readonly IJSRuntime _jsRuntime = jsRuntime;
    private IJSObjectReference? _jsModule;
 
    public async Task<ThemeSettings> GetThemeSettingsAsync(CancellationToken cancellationToken)
    {
        if (_jsModule == null)
        {
            _jsModule = await _jsRuntime.InvokeAsync<IJSObjectReference>("import", "/js/app-theme.js").ConfigureAwait(false);
        }
 
        var currentThemeTask = _jsModule.InvokeAsync<string>("getCurrentTheme", cancellationToken);
        var themeCookieValueTask = _jsModule.InvokeAsync<string?>("getThemeCookieValue", cancellationToken);
        var currentTheme = await currentThemeTask.ConfigureAwait(false);
        var themeCookieValue = await themeCookieValueTask.ConfigureAwait(false);
 
        return new ThemeSettings(themeCookieValue, currentTheme);
    }
 
    public async ValueTask DisposeAsync()
    {
        await JSInteropHelpers.SafeDisposeAsync(_jsModule).ConfigureAwait(false);
    }
}
 
public sealed class ThemeManager
{
    public const string ThemeSettingSystem = "System";
    public const string ThemeSettingDark = "Dark";
    public const string ThemeSettingLight = "Light";
 
    private readonly object _lock = new object();
    private readonly List<ModelSubscription> _subscriptions = new List<ModelSubscription>();
    private readonly IThemeResolver _themeResolver;
    private string? _effectiveTheme;
    private bool _hasInitialized;
    private string? _selectedTheme;
 
    public ThemeManager(IThemeResolver themeResolver)
    {
        _themeResolver = themeResolver;
    }
 
    /// <summary>
    /// The actual theme key (null, System, Dark, Light) set by the user.
    /// To ensure the theme is loaded from the browser, <see cref="EnsureInitializedAsync"/> must be called before accessing.
    /// </summary>
    public string? SelectedTheme
    {
        get
        {
            AssertInitialized();
            return _selectedTheme;
        }
        private set => _selectedTheme = value;
    }
 
    /// <summary>
    /// The effective theme, from app-theme.js, which is the theme that is actually applied to the browser window.
    /// To ensure the theme is loaded from the browser, <see cref="EnsureInitializedAsync"/> must be called before accessing.
    /// </summary>
    public string EffectiveTheme
    {
        get
        {
            AssertInitialized();
            return _effectiveTheme;
        }
        set => _effectiveTheme = value;
    }
 
    [MemberNotNull(nameof(_effectiveTheme))]
    private void AssertInitialized()
    {
        if (!_hasInitialized)
        {
            throw new InvalidOperationException("Theme manager not initialized.");
        }
 
        Debug.Assert(_effectiveTheme != null, "There should be an effective theme if theme manager has been initialized.");
    }
 
    public async Task EnsureInitializedAsync()
    {
        // There is some overhead is calling to the browser. Initializing can be delayed until it is needed, i.e. displaying settings dialog.
        if (!_hasInitialized)
        {
            var browserThemeSettings = await _themeResolver.GetThemeSettingsAsync(CancellationToken.None).ConfigureAwait(false);
            _effectiveTheme = browserThemeSettings.EffectiveTheme;
            SelectedTheme = !string.IsNullOrEmpty(browserThemeSettings.SelectedTheme) ? browserThemeSettings.SelectedTheme : null;
 
            _hasInitialized = true;
        }
    }
 
    public IDisposable OnThemeChanged(Func<Task> callback)
    {
        lock (_lock)
        {
            var subscription = new ModelSubscription(callback, RemoveSubscription);
            _subscriptions.Add(subscription);
            return subscription;
        }
    }
 
    private void RemoveSubscription(ModelSubscription subscription)
    {
        lock (_lock)
        {
            _subscriptions.Remove(subscription);
        }
    }
 
    public async Task RaiseThemeChangedAsync(string theme)
    {
        AssertInitialized();
 
        SelectedTheme = theme;
 
        if (_subscriptions.Count == 0)
        {
            return;
        }
 
        ModelSubscription[] subscriptions;
        lock (_lock)
        {
            subscriptions = _subscriptions.ToArray();
        }
 
        foreach (var subscription in subscriptions)
        {
            await subscription.ExecuteAsync().ConfigureAwait(false);
        }
    }
}