setting_re2

设置架构革新中
This commit is contained in:
lincube
2026-03-13 00:33:00 +08:00
parent 40a3a00cfe
commit c4df243610
92 changed files with 2048 additions and 10520 deletions

View File

@@ -0,0 +1,116 @@
using System;
namespace LanMountainDesktop.Services;
public readonly record struct DesktopGridMetrics(
int ColumnCount,
int RowCount,
double CellSize,
double GapPx,
double EdgeInsetPx,
double GridWidthPx,
double GridHeightPx)
{
public double Pitch => CellSize + GapPx;
}
public sealed class DesktopGridLayoutService
{
public const string RelaxedSpacingPreset = "Relaxed";
public const string CompactSpacingPreset = "Compact";
public string NormalizeSpacingPreset(string? value)
{
return string.Equals(value, CompactSpacingPreset, StringComparison.OrdinalIgnoreCase)
? CompactSpacingPreset
: RelaxedSpacingPreset;
}
public double ResolveGapRatio(string? preset)
{
return string.Equals(preset, CompactSpacingPreset, StringComparison.OrdinalIgnoreCase) ? 0.06 : 0.12;
}
public double CalculateEdgeInset(double hostWidth, double hostHeight, int shortSideCells, int insetPercent)
{
if (hostWidth <= 1 || hostHeight <= 1)
{
return 0;
}
var cells = Math.Max(1, shortSideCells);
var shortSidePx = Math.Max(1, Math.Min(hostWidth, hostHeight));
var baseCell = shortSidePx / cells;
var insetRatio = Math.Clamp(insetPercent, 0, 30) / 100d;
return Math.Clamp(baseCell * insetRatio, 0, 80);
}
public DesktopGridMetrics CalculateGridMetrics(
double hostWidth,
double hostHeight,
int shortSideCells,
double gapRatio,
double edgeInsetPx)
{
if (hostWidth <= 1 || hostHeight <= 1)
{
return default;
}
var shortSide = Math.Max(1, shortSideCells);
var clampedGapRatio = Math.Max(0, gapRatio);
var inset = Math.Max(0, edgeInsetPx);
var availableWidth = Math.Max(1, hostWidth - inset * 2);
var availableHeight = Math.Max(1, hostHeight - inset * 2);
if (hostWidth >= hostHeight)
{
var rowCount = shortSide;
var denominator = rowCount + Math.Max(0, rowCount - 1) * clampedGapRatio;
if (denominator <= 0)
{
return default;
}
var cellSize = availableHeight / denominator;
var gapPx = cellSize * clampedGapRatio;
var pitch = cellSize + gapPx;
if (pitch <= 0)
{
return default;
}
var columnCount = Math.Max(1, (int)Math.Floor((availableWidth + gapPx) / pitch));
var gridWidth = columnCount * cellSize + Math.Max(0, columnCount - 1) * gapPx;
var gridHeight = rowCount * cellSize + Math.Max(0, rowCount - 1) * gapPx;
return new DesktopGridMetrics(columnCount, rowCount, cellSize, gapPx, inset, gridWidth, gridHeight);
}
var columnCountPortrait = shortSide;
var denominatorPortrait = columnCountPortrait + Math.Max(0, columnCountPortrait - 1) * clampedGapRatio;
if (denominatorPortrait <= 0)
{
return default;
}
var cellSizePortrait = availableWidth / denominatorPortrait;
var gapPxPortrait = cellSizePortrait * clampedGapRatio;
var pitchPortrait = cellSizePortrait + gapPxPortrait;
if (pitchPortrait <= 0)
{
return default;
}
var rowCountPortrait = Math.Max(1, (int)Math.Floor((availableHeight + gapPxPortrait) / pitchPortrait));
var gridWidthPortrait = columnCountPortrait * cellSizePortrait + Math.Max(0, columnCountPortrait - 1) * gapPxPortrait;
var gridHeightPortrait = rowCountPortrait * cellSizePortrait + Math.Max(0, rowCountPortrait - 1) * gapPxPortrait;
return new DesktopGridMetrics(
columnCountPortrait,
rowCountPortrait,
cellSizePortrait,
gapPxPortrait,
inset,
gridWidthPortrait,
gridHeightPortrait);
}
}

View File

@@ -1,88 +0,0 @@
using System;
using Avalonia.Threading;
using LanMountainDesktop.Views;
namespace LanMountainDesktop.Services;
internal sealed class IndependentSettingsModuleService
{
private SettingsWindow? _window;
public void ShowOrActivate(string source, string? pageTag = null)
{
AppLogger.Info("IndependentSettingsModule", $"OpenRequested; Source='{source}'; PageTag='{pageTag ?? "<default>"}'.");
void ShowCore()
{
try
{
if (_window is not { } window)
{
AppLogger.Info("IndependentSettingsModule", $"WindowConstructionStarted; Source='{source}'.");
window = new SettingsWindow();
AppLogger.Info("IndependentSettingsModule", $"WindowConstructionCompleted; Source='{source}'.");
window.Closed += (_, _) =>
{
if (ReferenceEquals(_window, window))
{
_window = null;
}
AppLogger.Info("IndependentSettingsModule", "WindowClosed.");
};
_window = window;
}
window.Open(pageTag);
AppLogger.Info(
"IndependentSettingsModule",
$"WindowActivated; Source='{source}'; ReusedExisting={ReferenceEquals(_window, window)}; WasVisible={window.IsVisible}; PageTag='{pageTag ?? "<default>"}'.");
}
catch (Exception ex)
{
AppLogger.Warn("IndependentSettingsModule", $"Failed to open independent settings module window. Source='{source}'.", ex);
}
}
if (Dispatcher.UIThread.CheckAccess())
{
ShowCore();
return;
}
Dispatcher.UIThread.Post(ShowCore, DispatcherPriority.Normal);
}
public void CloseIfOpen()
{
void CloseCore()
{
if (_window is null)
{
return;
}
try
{
_window.PrepareForForceClose();
_window.Close();
}
catch (Exception ex)
{
AppLogger.Warn("IndependentSettingsModule", "Failed to close independent settings module window during shutdown.", ex);
}
finally
{
_window = null;
}
}
if (Dispatcher.UIThread.CheckAccess())
{
CloseCore();
return;
}
Dispatcher.UIThread.Post(CloseCore, DispatcherPriority.Send);
}
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services.Settings;
internal sealed class SettingsCatalogService : ISettingsCatalog
{
private readonly List<SettingsSectionDefinition> _sections = [];
private readonly object _gate = new();
public SettingsCatalogService()
{
// Built-in host sections for the next settings UI.
_sections.AddRange(
[
new SettingsSectionDefinition("general", SettingsCategories.General, SettingsScope.App, "settings.general.title", iconKey: "Settings", sortOrder: 0),
new SettingsSectionDefinition("appearance", SettingsCategories.Appearance, SettingsScope.App, "settings.appearance.title", iconKey: "DesignIdeas", sortOrder: 10),
new SettingsSectionDefinition("components", SettingsCategories.Components, SettingsScope.ComponentInstance, "settings.components.title", iconKey: "GridDots", sortOrder: 20),
new SettingsSectionDefinition("plugins", SettingsCategories.Plugins, SettingsScope.Plugin, "settings.plugins.title", iconKey: "PuzzlePiece", sortOrder: 30),
new SettingsSectionDefinition("plugin-market", SettingsCategories.PluginMarket, SettingsScope.Plugin, "settings.plugin_market.title", iconKey: "Shop", sortOrder: 40),
new SettingsSectionDefinition("update", SettingsCategories.Update, SettingsScope.App, "settings.update.title", iconKey: "ArrowSync", sortOrder: 50),
new SettingsSectionDefinition("about", SettingsCategories.About, SettingsScope.App, "settings.about.title", iconKey: "Info", sortOrder: 60),
new SettingsSectionDefinition("advanced", SettingsCategories.Advanced, SettingsScope.App, "settings.advanced.title", iconKey: "DeveloperBoard", sortOrder: 70)
]);
}
public IReadOnlyList<SettingsSectionDefinition> GetSections()
{
lock (_gate)
{
return _sections
.OrderBy(section => section.SortOrder)
.ThenBy(section => section.Id, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
public IReadOnlyList<SettingsSectionDefinition> GetSections(SettingsScope scope)
{
lock (_gate)
{
return _sections
.Where(section => section.Scope == scope)
.OrderBy(section => section.SortOrder)
.ThenBy(section => section.Id, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
public void RegisterPluginSections(string pluginId, IReadOnlyList<PluginSettingsSectionRegistration> sections)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
var normalizedPluginId = pluginId.Trim();
lock (_gate)
{
_sections.RemoveAll(section =>
section.Scope == SettingsScope.Plugin &&
string.Equals(section.SubjectId, normalizedPluginId, StringComparison.OrdinalIgnoreCase));
foreach (var registration in sections)
{
var definition = new SettingsSectionDefinition(
id: $"{normalizedPluginId}:{registration.Id}",
category: SettingsCategories.External,
scope: SettingsScope.Plugin,
titleLocalizationKey: registration.TitleLocalizationKey,
descriptionLocalizationKey: registration.DescriptionLocalizationKey,
iconKey: registration.IconKey,
sortOrder: registration.SortOrder,
subjectId: normalizedPluginId,
options: registration.Options);
_sections.Add(definition);
}
}
}
public void RemovePluginSections(string pluginId)
{
if (string.IsNullOrWhiteSpace(pluginId))
{
return;
}
lock (_gate)
{
_sections.RemoveAll(section =>
section.Scope == SettingsScope.Plugin &&
string.Equals(section.SubjectId, pluginId, StringComparison.OrdinalIgnoreCase));
}
}
}

View File

@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services.Settings;
public enum WallpaperMediaType
{
None,
Image,
Video
}
public sealed record GridSettingsState(int ShortSideCells, string SpacingPreset, int EdgeInsetPercent);
public sealed record WallpaperSettingsState(string? WallpaperPath, string Placement);
public sealed record ThemeAppearanceSettingsState(bool IsNightMode, string? ThemeColor);
public sealed record StatusBarSettingsState(
IReadOnlyList<string> TopStatusComponentIds,
IReadOnlyList<string> PinnedTaskbarActions,
bool EnableDynamicTaskbarActions,
string TaskbarLayoutMode,
string ClockDisplayFormat,
string SpacingMode,
int CustomSpacingPercent);
public sealed record WeatherSettingsState(
string LocationMode,
string LocationKey,
string LocationName,
double Latitude,
double Longitude,
bool AutoRefreshLocation,
string ExcludedAlerts,
string IconPackId,
bool NoTlsRequests,
string LocationQuery);
public sealed record RegionSettingsState(string LanguageCode, string? TimeZoneId);
public sealed record UpdateSettingsState(bool AutoCheckUpdates, bool IncludePrereleaseUpdates, string UpdateChannel);
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
public sealed record PluginMarketPluginInfo(
string Id,
string Name,
string Description,
string Author,
string Version,
string ApiVersion,
string MinHostVersion,
string DownloadUrl,
string ReleaseTag,
string ReleaseAssetName,
string IconUrl,
string ReadmeUrl,
string HomepageUrl,
string RepositoryUrl,
IReadOnlyList<string> Tags,
DateTimeOffset PublishedAt,
DateTimeOffset UpdatedAt);
public sealed record PluginMarketIndexResult(
bool Success,
IReadOnlyList<PluginMarketPluginInfo> Plugins,
string? Source,
string? SourceLocation,
string? WarningMessage,
string? ErrorMessage);
public sealed record PluginMarketInstallResult(
bool Success,
string? PluginId,
string? PluginName,
string? ErrorMessage);
public interface IGridSettingsService
{
GridSettingsState Get();
void Save(GridSettingsState state);
}
public interface IWallpaperSettingsService
{
WallpaperSettingsState Get();
void Save(WallpaperSettingsState state);
}
public interface IWallpaperMediaService
{
WallpaperMediaType DetectMediaType(string? path);
Task<string?> ImportAssetAsync(string sourcePath, CancellationToken cancellationToken = default);
}
public interface IThemeAppearanceService
{
ThemeAppearanceSettingsState Get();
void Save(ThemeAppearanceSettingsState state);
MonetPalette BuildPalette(bool nightMode, string? wallpaperPath);
}
public interface IStatusBarSettingsService
{
StatusBarSettingsState Get();
void Save(StatusBarSettingsState state);
}
public interface IWeatherProvider
{
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
string keyword,
string? locale = null,
CancellationToken cancellationToken = default);
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
WeatherQuery query,
CancellationToken cancellationToken = default);
}
public interface IWeatherSettingsService
{
WeatherSettingsState Get();
void Save(WeatherSettingsState state);
}
public interface IRegionSettingsService
{
RegionSettingsState Get();
void Save(RegionSettingsState state);
}
public interface IUpdateSettingsService
{
UpdateSettingsState Get();
void Save(UpdateSettingsState state);
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default);
}
public interface ILauncherCatalogService
{
StartMenuFolderNode LoadCatalog();
}
public interface ILauncherPolicyService
{
LauncherSettingsSnapshot Get();
void Save(LauncherSettingsSnapshot snapshot);
}
public interface IPluginManagementSettingsService
{
PluginManagementSettingsState Get();
void Save(PluginManagementSettingsState state);
IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins();
bool SetPluginEnabled(string pluginId, bool isEnabled);
bool DeleteInstalledPlugin(string pluginId);
}
public interface IPluginMarketSettingsService
{
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
public interface IApplicationInfoService
{
string GetAppVersionText();
AppRenderBackendInfo GetRenderBackendInfo();
}
public interface ISettingsFacadeService
{
ISettingsService Settings { get; }
ISettingsCatalog Catalog { get; }
IGridSettingsService Grid { get; }
IWallpaperSettingsService Wallpaper { get; }
IWallpaperMediaService WallpaperMedia { get; }
IThemeAppearanceService Theme { get; }
IStatusBarSettingsService StatusBar { get; }
IWeatherSettingsService Weather { get; }
IRegionSettingsService Region { get; }
IUpdateSettingsService Update { get; }
ILauncherCatalogService LauncherCatalog { get; }
ILauncherPolicyService LauncherPolicy { get; }
IPluginManagementSettingsService PluginManagement { get; }
IPluginMarketSettingsService PluginMarket { get; }
IApplicationInfoService ApplicationInfo { get; }
}

View File

@@ -0,0 +1,627 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.PluginMarket;
namespace LanMountainDesktop.Services.Settings;
internal sealed class GridSettingsService : IGridSettingsService
{
private readonly AppSettingsService _appSettingsService = new();
public GridSettingsState Get()
{
var snapshot = _appSettingsService.Load();
return new GridSettingsState(
snapshot.GridShortSideCells,
snapshot.GridSpacingPreset,
snapshot.DesktopEdgeInsetPercent);
}
public void Save(GridSettingsState state)
{
var snapshot = _appSettingsService.Load();
snapshot.GridShortSideCells = state.ShortSideCells;
snapshot.GridSpacingPreset = state.SpacingPreset;
snapshot.DesktopEdgeInsetPercent = state.EdgeInsetPercent;
_appSettingsService.Save(snapshot);
}
}
internal sealed class WallpaperSettingsService : IWallpaperSettingsService
{
private readonly AppSettingsService _appSettingsService = new();
public WallpaperSettingsState Get()
{
var snapshot = _appSettingsService.Load();
return new WallpaperSettingsState(snapshot.WallpaperPath, snapshot.WallpaperPlacement);
}
public void Save(WallpaperSettingsState state)
{
var snapshot = _appSettingsService.Load();
snapshot.WallpaperPath = state.WallpaperPath;
snapshot.WallpaperPlacement = string.IsNullOrWhiteSpace(state.Placement)
? "Fill"
: state.Placement.Trim();
_appSettingsService.Save(snapshot);
}
}
internal sealed class WallpaperMediaService : IWallpaperMediaService
{
private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"
};
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
private readonly string _wallpapersDirectory;
public WallpaperMediaService()
{
var appDataRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
_wallpapersDirectory = Path.Combine(appDataRoot, "Wallpapers");
}
public WallpaperMediaType DetectMediaType(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return WallpaperMediaType.None;
}
var extension = Path.GetExtension(path.Trim());
if (string.IsNullOrWhiteSpace(extension))
{
return WallpaperMediaType.None;
}
if (ImageExtensions.Contains(extension))
{
return WallpaperMediaType.Image;
}
if (VideoExtensions.Contains(extension))
{
return WallpaperMediaType.Video;
}
return WallpaperMediaType.None;
}
public async Task<string?> ImportAssetAsync(string sourcePath, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sourcePath))
{
return null;
}
var fullSourcePath = Path.GetFullPath(sourcePath);
if (!File.Exists(fullSourcePath))
{
return null;
}
if (DetectMediaType(fullSourcePath) == WallpaperMediaType.None)
{
return null;
}
Directory.CreateDirectory(_wallpapersDirectory);
var extension = Path.GetExtension(fullSourcePath);
var baseName = Path.GetFileNameWithoutExtension(fullSourcePath);
var normalizedBaseName = string.IsNullOrWhiteSpace(baseName)
? "wallpaper"
: string.Concat(baseName.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch));
var destinationPath = Path.Combine(_wallpapersDirectory, $"{normalizedBaseName}{extension}");
if (string.Equals(fullSourcePath, destinationPath, StringComparison.OrdinalIgnoreCase))
{
return destinationPath;
}
var suffix = 1;
while (File.Exists(destinationPath))
{
destinationPath = Path.Combine(_wallpapersDirectory, $"{normalizedBaseName}_{suffix}{extension}");
suffix++;
}
await using var source = File.OpenRead(fullSourcePath);
await using var destination = File.Create(destinationPath);
await source.CopyToAsync(destination, cancellationToken);
return destinationPath;
}
}
internal sealed class ThemeAppearanceService : IThemeAppearanceService
{
private readonly AppSettingsService _appSettingsService = new();
private readonly MonetColorService _monetColorService = new();
private readonly WallpaperMediaService _wallpaperMediaService = new();
public ThemeAppearanceSettingsState Get()
{
var snapshot = _appSettingsService.Load();
return new ThemeAppearanceSettingsState(
snapshot.IsNightMode ?? false,
snapshot.ThemeColor);
}
public void Save(ThemeAppearanceSettingsState state)
{
var snapshot = _appSettingsService.Load();
snapshot.IsNightMode = state.IsNightMode;
snapshot.ThemeColor = state.ThemeColor;
_appSettingsService.Save(snapshot);
}
public MonetPalette BuildPalette(bool nightMode, string? wallpaperPath)
{
Bitmap? bitmap = null;
try
{
if (_wallpaperMediaService.DetectMediaType(wallpaperPath) == WallpaperMediaType.Image &&
!string.IsNullOrWhiteSpace(wallpaperPath) &&
File.Exists(wallpaperPath))
{
bitmap = new Bitmap(wallpaperPath);
}
}
catch (Exception ex)
{
AppLogger.Warn(
"Settings.Theme",
$"Failed to load wallpaper bitmap for palette generation. Path='{wallpaperPath}'.",
ex);
}
try
{
return _monetColorService.BuildPalette(bitmap, nightMode);
}
finally
{
bitmap?.Dispose();
}
}
}
internal sealed class StatusBarSettingsService : IStatusBarSettingsService
{
private readonly AppSettingsService _appSettingsService = new();
public StatusBarSettingsState Get()
{
var snapshot = _appSettingsService.Load();
return new StatusBarSettingsState(
snapshot.TopStatusComponentIds?.ToArray() ?? [],
snapshot.PinnedTaskbarActions?.ToArray() ?? [],
snapshot.EnableDynamicTaskbarActions,
snapshot.TaskbarLayoutMode,
snapshot.ClockDisplayFormat,
snapshot.StatusBarSpacingMode,
snapshot.StatusBarCustomSpacingPercent);
}
public void Save(StatusBarSettingsState state)
{
var snapshot = _appSettingsService.Load();
snapshot.TopStatusComponentIds = state.TopStatusComponentIds?.ToList() ?? [];
snapshot.PinnedTaskbarActions = state.PinnedTaskbarActions?.ToList() ?? [];
snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions;
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
snapshot.StatusBarSpacingMode = state.SpacingMode;
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
_appSettingsService.Save(snapshot);
}
}
internal sealed class WeatherProviderAdapter : IWeatherProvider
{
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
public Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
string keyword,
string? locale = null,
CancellationToken cancellationToken = default)
{
return _weatherDataService.SearchLocationsAsync(keyword, locale, cancellationToken);
}
public Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
WeatherQuery query,
CancellationToken cancellationToken = default)
{
return _weatherDataService.GetWeatherAsync(query, cancellationToken);
}
}
internal sealed class WeatherSettingsService : IWeatherSettingsService
{
private readonly AppSettingsService _appSettingsService = new();
public WeatherSettingsState Get()
{
var snapshot = _appSettingsService.Load();
return new WeatherSettingsState(
snapshot.WeatherLocationMode,
snapshot.WeatherLocationKey,
snapshot.WeatherLocationName,
snapshot.WeatherLatitude,
snapshot.WeatherLongitude,
snapshot.WeatherAutoRefreshLocation,
snapshot.WeatherExcludedAlerts,
snapshot.WeatherIconPackId,
snapshot.WeatherNoTlsRequests,
snapshot.WeatherLocationQuery);
}
public void Save(WeatherSettingsState state)
{
var snapshot = _appSettingsService.Load();
snapshot.WeatherLocationMode = state.LocationMode;
snapshot.WeatherLocationKey = state.LocationKey;
snapshot.WeatherLocationName = state.LocationName;
snapshot.WeatherLatitude = state.Latitude;
snapshot.WeatherLongitude = state.Longitude;
snapshot.WeatherAutoRefreshLocation = state.AutoRefreshLocation;
snapshot.WeatherExcludedAlerts = state.ExcludedAlerts;
snapshot.WeatherIconPackId = state.IconPackId;
snapshot.WeatherNoTlsRequests = state.NoTlsRequests;
snapshot.WeatherLocationQuery = state.LocationQuery;
_appSettingsService.Save(snapshot);
}
}
internal sealed class RegionSettingsService : IRegionSettingsService
{
private readonly AppSettingsService _appSettingsService = new();
public RegionSettingsState Get()
{
var snapshot = _appSettingsService.Load();
return new RegionSettingsState(snapshot.LanguageCode, snapshot.TimeZoneId);
}
public void Save(RegionSettingsState state)
{
var snapshot = _appSettingsService.Load();
snapshot.LanguageCode = string.IsNullOrWhiteSpace(state.LanguageCode)
? "zh-CN"
: state.LanguageCode.Trim();
snapshot.TimeZoneId = string.IsNullOrWhiteSpace(state.TimeZoneId)
? null
: state.TimeZoneId.Trim();
_appSettingsService.Save(snapshot);
}
}
internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable
{
private readonly AppSettingsService _appSettingsService = new();
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
public UpdateSettingsState Get()
{
var snapshot = _appSettingsService.Load();
return new UpdateSettingsState(
snapshot.AutoCheckUpdates,
snapshot.IncludePrereleaseUpdates,
snapshot.UpdateChannel);
}
public void Save(UpdateSettingsState state)
{
var snapshot = _appSettingsService.Load();
snapshot.AutoCheckUpdates = state.AutoCheckUpdates;
snapshot.IncludePrereleaseUpdates = state.IncludePrereleaseUpdates;
snapshot.UpdateChannel = state.UpdateChannel;
_appSettingsService.Save(snapshot);
}
public Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
}
public Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.DownloadAssetAsync(asset, destinationFilePath, progress, cancellationToken);
}
public void Dispose()
{
_releaseUpdateService.Dispose();
}
}
internal sealed class LauncherCatalogService : ILauncherCatalogService
{
private readonly WindowsStartMenuService _startMenuService = new();
public StartMenuFolderNode LoadCatalog()
{
return _startMenuService.Load();
}
}
internal sealed class LauncherPolicyService : ILauncherPolicyService
{
private readonly LauncherSettingsService _launcherSettingsService = new();
public LauncherSettingsSnapshot Get()
{
return _launcherSettingsService.Load();
}
public void Save(LauncherSettingsSnapshot snapshot)
{
_launcherSettingsService.Save(snapshot ?? new LauncherSettingsSnapshot());
}
}
internal sealed class PluginManagementSettingsService : IPluginManagementSettingsService
{
private readonly AppSettingsService _appSettingsService = new();
private readonly PluginRuntimeService? _pluginRuntimeService;
public PluginManagementSettingsService(PluginRuntimeService? pluginRuntimeService)
{
_pluginRuntimeService = pluginRuntimeService;
}
public PluginManagementSettingsState Get()
{
var snapshot = _appSettingsService.Load();
return new PluginManagementSettingsState(snapshot.DisabledPluginIds?.ToArray() ?? []);
}
public void Save(PluginManagementSettingsState state)
{
var snapshot = _appSettingsService.Load();
snapshot.DisabledPluginIds = state.DisabledPluginIds?.ToList() ?? [];
_appSettingsService.Save(snapshot);
}
public IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins()
{
return _pluginRuntimeService?.GetInstalledPluginsSnapshot() ?? [];
}
public bool SetPluginEnabled(string pluginId, bool isEnabled)
{
return _pluginRuntimeService?.SetPluginEnabled(pluginId, isEnabled) ?? false;
}
public bool DeleteInstalledPlugin(string pluginId)
{
return _pluginRuntimeService?.DeleteInstalledPlugin(pluginId) ?? false;
}
}
internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService, IDisposable
{
private readonly PluginRuntimeService? _pluginRuntimeService;
private readonly AirAppMarketIndexService _indexService;
private readonly AirAppMarketInstallService? _installService;
private readonly Dictionary<string, AirAppMarketPluginEntry> _cachedPlugins = new(StringComparer.OrdinalIgnoreCase);
public PluginMarketSettingsService(PluginRuntimeService? pluginRuntimeService)
{
_pluginRuntimeService = pluginRuntimeService;
var dataRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"PluginMarket");
var cacheService = new AirAppMarketCacheService(dataRoot);
_indexService = new AirAppMarketIndexService(cacheService);
if (_pluginRuntimeService is not null)
{
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
}
}
public async Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default)
{
var result = await _indexService.LoadAsync(cancellationToken);
if (!result.Success || result.Document is null)
{
return new PluginMarketIndexResult(
false,
[],
result.Source?.ToString(),
result.SourceLocation,
result.WarningMessage,
result.ErrorMessage);
}
_cachedPlugins.Clear();
var plugins = result.Document.Plugins
.Select(entry =>
{
_cachedPlugins[entry.Id] = entry;
return new PluginMarketPluginInfo(
entry.Id,
entry.Name,
entry.Description,
entry.Author,
entry.Version,
entry.ApiVersion,
entry.MinHostVersion,
entry.DownloadUrl,
entry.ReleaseTag,
entry.ReleaseAssetName,
entry.IconUrl,
entry.ReadmeUrl,
entry.HomepageUrl,
entry.RepositoryUrl,
entry.Tags,
entry.PublishedAt,
entry.UpdatedAt);
})
.ToArray();
return new PluginMarketIndexResult(
true,
plugins,
result.Source?.ToString(),
result.SourceLocation,
result.WarningMessage,
null);
}
public async Task<PluginMarketInstallResult> InstallAsync(
string pluginId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(pluginId))
{
return new PluginMarketInstallResult(false, null, null, "Plugin id is required.");
}
if (_installService is null || _pluginRuntimeService is null)
{
return new PluginMarketInstallResult(
false,
pluginId,
null,
"Plugin runtime is unavailable.");
}
if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
{
var load = await LoadIndexAsync(cancellationToken);
if (!load.Success)
{
return new PluginMarketInstallResult(false, pluginId, null, load.ErrorMessage);
}
if (!_cachedPlugins.TryGetValue(pluginId, out entry))
{
return new PluginMarketInstallResult(false, pluginId, null, "Plugin was not found in market index.");
}
}
var result = await _installService.InstallAsync(entry, cancellationToken);
if (!result.Success)
{
return new PluginMarketInstallResult(false, entry.Id, entry.Name, result.ErrorMessage);
}
return new PluginMarketInstallResult(true, result.Manifest?.Id ?? entry.Id, result.Manifest?.Name ?? entry.Name, null);
}
public void Dispose()
{
_indexService.Dispose();
_installService?.Dispose();
}
}
internal sealed class ApplicationInfoService : IApplicationInfoService
{
public string GetAppVersionText()
{
var version = typeof(App).Assembly.GetName().Version;
return version is null
? "0.0.0"
: new Version(
Math.Max(0, version.Major),
Math.Max(0, version.Minor),
Math.Max(0, version.Build)).ToString(3);
}
public AppRenderBackendInfo GetRenderBackendInfo()
{
return AppRenderBackendDiagnostics.Detect();
}
}
internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposable
{
private readonly UpdateSettingsService _updateSettingsService;
private readonly PluginMarketSettingsService _pluginMarketSettingsService;
public SettingsFacadeService(PluginRuntimeService? pluginRuntimeService = null)
{
Settings = new SettingsService();
Catalog = new SettingsCatalogService();
Grid = new GridSettingsService();
Wallpaper = new WallpaperSettingsService();
WallpaperMedia = new WallpaperMediaService();
Theme = new ThemeAppearanceService();
StatusBar = new StatusBarSettingsService();
Weather = new WeatherSettingsService();
Region = new RegionSettingsService();
_updateSettingsService = new UpdateSettingsService();
Update = _updateSettingsService;
LauncherCatalog = new LauncherCatalogService();
LauncherPolicy = new LauncherPolicyService();
PluginManagement = new PluginManagementSettingsService(pluginRuntimeService);
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
PluginMarket = _pluginMarketSettingsService;
ApplicationInfo = new ApplicationInfoService();
}
public ISettingsService Settings { get; }
public ISettingsCatalog Catalog { get; }
public IGridSettingsService Grid { get; }
public IWallpaperSettingsService Wallpaper { get; }
public IWallpaperMediaService WallpaperMedia { get; }
public IThemeAppearanceService Theme { get; }
public IStatusBarSettingsService StatusBar { get; }
public IWeatherSettingsService Weather { get; }
public IRegionSettingsService Region { get; }
public IUpdateSettingsService Update { get; }
public ILauncherCatalogService LauncherCatalog { get; }
public ILauncherPolicyService LauncherPolicy { get; }
public IPluginManagementSettingsService PluginManagement { get; }
public IPluginMarketSettingsService PluginMarket { get; }
public IApplicationInfoService ApplicationInfo { get; }
public void Dispose()
{
_updateSettingsService.Dispose();
_pluginMarketSettingsService.Dispose();
}
}

View File

@@ -0,0 +1,423 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services.Settings;
internal sealed class SettingsService : ISettingsService
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private readonly AppSettingsService _appSettingsService = new();
private readonly LauncherSettingsService _launcherSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly string _pluginSettingsPath;
private readonly object _pluginSettingsGate = new();
public SettingsService()
{
var root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
_pluginSettingsPath = Path.Combine(root, "plugin-settings.json");
}
public event EventHandler<SettingsChangedEvent>? Changed;
public T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new()
{
return scope switch
{
SettingsScope.App => ConvertSnapshot<AppSettingsSnapshot, T>(_appSettingsService.Load()),
SettingsScope.Launcher => ConvertSnapshot<LauncherSettingsSnapshot, T>(_launcherSettingsService.Load()),
SettingsScope.ComponentInstance => LoadComponentSnapshot<T>(subjectId, placementId),
SettingsScope.Plugin => LoadSection<T>(scope, EnsureKey(subjectId), sectionId: "__snapshot__", placementId),
_ => new T()
};
}
public void SaveSnapshot<T>(
SettingsScope scope,
T snapshot,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? changedKeys = null)
{
switch (scope)
{
case SettingsScope.App:
_appSettingsService.Save(ConvertSnapshot<T, AppSettingsSnapshot>(snapshot));
break;
case SettingsScope.Launcher:
_launcherSettingsService.Save(ConvertSnapshot<T, LauncherSettingsSnapshot>(snapshot));
break;
case SettingsScope.ComponentInstance:
SaveComponentSnapshot(subjectId, placementId, snapshot);
break;
case SettingsScope.Plugin:
SaveSection(scope, EnsureKey(subjectId), "__snapshot__", snapshot, placementId, changedKeys);
break;
}
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
}
public T LoadSection<T>(
SettingsScope scope,
string subjectId,
string sectionId,
string? placementId = null) where T : new()
{
if (scope == SettingsScope.ComponentInstance)
{
return _componentSettingsService.LoadPluginSettings<T>(EnsureKey(subjectId), placementId);
}
if (scope != SettingsScope.Plugin)
{
return new T();
}
lock (_pluginSettingsGate)
{
var document = LoadPluginDocumentLocked();
if (!document.Sections.TryGetValue(EnsureKey(subjectId), out var pluginSections) ||
!pluginSections.TryGetValue(EnsureKey(sectionId), out var payload))
{
return new T();
}
return JsonSerializer.Deserialize<T>(payload.GetRawText(), SerializerOptions) ?? new T();
}
}
public void SaveSection<T>(
SettingsScope scope,
string subjectId,
string sectionId,
T section,
string? placementId = null,
IReadOnlyCollection<string>? changedKeys = null)
{
if (scope == SettingsScope.ComponentInstance)
{
_componentSettingsService.SavePluginSettings(EnsureKey(subjectId), placementId, section);
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
return;
}
if (scope != SettingsScope.Plugin)
{
return;
}
lock (_pluginSettingsGate)
{
var document = LoadPluginDocumentLocked();
var pluginId = EnsureKey(subjectId);
if (!document.Sections.TryGetValue(pluginId, out var pluginSections))
{
pluginSections = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
document.Sections[pluginId] = pluginSections;
}
pluginSections[EnsureKey(sectionId)] = JsonSerializer.SerializeToElement(section, SerializerOptions).Clone();
PersistPluginDocumentLocked(document);
}
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
}
public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null)
{
if (scope == SettingsScope.ComponentInstance)
{
_componentSettingsService.DeletePluginSettings(EnsureKey(subjectId), placementId);
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId));
return;
}
if (scope != SettingsScope.Plugin)
{
return;
}
lock (_pluginSettingsGate)
{
var document = LoadPluginDocumentLocked();
var pluginId = EnsureKey(subjectId);
if (document.Sections.TryGetValue(pluginId, out var sections) &&
sections.Remove(EnsureKey(sectionId)))
{
if (sections.Count == 0)
{
document.Sections.Remove(pluginId);
}
PersistPluginDocumentLocked(document);
}
}
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId));
}
public T? GetValue<T>(
SettingsScope scope,
string key,
string? subjectId = null,
string? placementId = null,
string? sectionId = null)
{
var snapshot = scope switch
{
SettingsScope.App => JsonSerializer.SerializeToElement(_appSettingsService.Load(), SerializerOptions),
SettingsScope.Launcher => JsonSerializer.SerializeToElement(_launcherSettingsService.Load(), SerializerOptions),
SettingsScope.ComponentInstance => JsonSerializer.SerializeToElement(
_componentSettingsService.LoadForComponent(EnsureKey(subjectId), placementId),
SerializerOptions),
SettingsScope.Plugin => JsonSerializer.SerializeToElement(
LoadSection<Dictionary<string, JsonElement>>(SettingsScope.Plugin, EnsureKey(subjectId), sectionId ?? "__root__", placementId),
SerializerOptions),
_ => default
};
if (snapshot.ValueKind != JsonValueKind.Object)
{
return default;
}
foreach (var property in snapshot.EnumerateObject())
{
if (!string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
return property.Value.Deserialize<T>(SerializerOptions);
}
catch
{
return default;
}
}
return default;
}
public void SetValue<T>(
SettingsScope scope,
string key,
T value,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? changedKeys = null)
{
if (scope == SettingsScope.Plugin)
{
var dict = LoadSection<Dictionary<string, JsonElement>>(
SettingsScope.Plugin,
EnsureKey(subjectId),
sectionId ?? "__root__",
placementId);
dict[key] = JsonSerializer.SerializeToElement(value, SerializerOptions).Clone();
SaveSection(SettingsScope.Plugin, EnsureKey(subjectId), sectionId ?? "__root__", dict, placementId, changedKeys ?? [key]);
return;
}
if (scope == SettingsScope.ComponentInstance)
{
var dict = _componentSettingsService.LoadPluginSettings<Dictionary<string, JsonElement>>(EnsureKey(subjectId), placementId);
dict[key] = JsonSerializer.SerializeToElement(value, SerializerOptions).Clone();
_componentSettingsService.SavePluginSettings(EnsureKey(subjectId), placementId, dict);
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys ?? [key]));
return;
}
if (scope == SettingsScope.App)
{
var snapshot = _appSettingsService.Load();
var updated = UpdateObjectKey(snapshot, key, value);
_appSettingsService.Save(updated);
OnChanged(new SettingsChangedEvent(scope, null, null, sectionId, changedKeys ?? [key]));
return;
}
if (scope == SettingsScope.Launcher)
{
var snapshot = _launcherSettingsService.Load();
var updated = UpdateObjectKey(snapshot, key, value);
_launcherSettingsService.Save(updated);
OnChanged(new SettingsChangedEvent(scope, null, null, sectionId, changedKeys ?? [key]));
}
}
public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId)
{
return new ComponentSettingsAccessor(this, componentId, placementId);
}
private T LoadComponentSnapshot<T>(string? componentId, string? placementId) where T : new()
{
var snapshot = _componentSettingsService.LoadForComponent(EnsureKey(componentId), placementId);
return ConvertSnapshot<ComponentSettingsSnapshot, T>(snapshot);
}
private void SaveComponentSnapshot<T>(string? componentId, string? placementId, T snapshot)
{
var converted = ConvertSnapshot<T, ComponentSettingsSnapshot>(snapshot);
_componentSettingsService.SaveForComponent(EnsureKey(componentId), placementId, converted);
}
private static TOut ConvertSnapshot<TIn, TOut>(TIn source) where TOut : new()
{
if (source is null)
{
return new TOut();
}
if (source is TOut direct)
{
return direct;
}
try
{
var json = JsonSerializer.Serialize(source, SerializerOptions);
return JsonSerializer.Deserialize<TOut>(json, SerializerOptions) ?? new TOut();
}
catch
{
return new TOut();
}
}
private static TSnapshot UpdateObjectKey<TSnapshot, TValue>(TSnapshot snapshot, string key, TValue value)
where TSnapshot : new()
{
var bag = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
JsonSerializer.Serialize(snapshot, SerializerOptions),
SerializerOptions) ?? new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
var actualKey = bag.Keys.FirstOrDefault(existing => string.Equals(existing, key, StringComparison.OrdinalIgnoreCase)) ?? key;
bag[actualKey] = JsonSerializer.SerializeToElement(value, SerializerOptions).Clone();
try
{
var json = JsonSerializer.Serialize(bag, SerializerOptions);
return JsonSerializer.Deserialize<TSnapshot>(json, SerializerOptions) ?? new TSnapshot();
}
catch
{
return snapshot is null ? new TSnapshot() : snapshot;
}
}
private PluginSettingsDocument LoadPluginDocumentLocked()
{
try
{
if (!File.Exists(_pluginSettingsPath))
{
return new PluginSettingsDocument();
}
var json = File.ReadAllText(_pluginSettingsPath);
return JsonSerializer.Deserialize<PluginSettingsDocument>(json, SerializerOptions) ?? new PluginSettingsDocument();
}
catch (Exception ex)
{
AppLogger.Warn("SettingsService", $"Failed to load plugin settings '{_pluginSettingsPath}'.", ex);
return new PluginSettingsDocument();
}
}
private void PersistPluginDocumentLocked(PluginSettingsDocument document)
{
try
{
var directory = Path.GetDirectoryName(_pluginSettingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_pluginSettingsPath, JsonSerializer.Serialize(document, SerializerOptions));
}
catch (Exception ex)
{
AppLogger.Warn("SettingsService", $"Failed to persist plugin settings '{_pluginSettingsPath}'.", ex);
}
}
private static string EnsureKey(string? value)
{
return string.IsNullOrWhiteSpace(value) ? "__default__" : value.Trim();
}
private void OnChanged(SettingsChangedEvent e)
{
try
{
Changed?.Invoke(this, e);
}
catch
{
// Never let a subscriber break settings persistence.
}
}
private sealed class ComponentSettingsAccessor : IComponentSettingsAccessor
{
private readonly SettingsService _settingsService;
public ComponentSettingsAccessor(SettingsService settingsService, string componentId, string? placementId)
{
_settingsService = settingsService;
ComponentId = componentId;
PlacementId = placementId;
}
public string ComponentId { get; }
public string? PlacementId { get; }
public T LoadSnapshot<T>() where T : new()
=> _settingsService.LoadSnapshot<T>(SettingsScope.ComponentInstance, ComponentId, PlacementId);
public void SaveSnapshot<T>(T snapshot, IReadOnlyCollection<string>? changedKeys = null)
=> _settingsService.SaveSnapshot(SettingsScope.ComponentInstance, snapshot, ComponentId, PlacementId, changedKeys: changedKeys);
public T LoadSection<T>(string sectionId) where T : new()
=> _settingsService.LoadSection<T>(SettingsScope.ComponentInstance, ComponentId, sectionId, PlacementId);
public void SaveSection<T>(string sectionId, T section, IReadOnlyCollection<string>? changedKeys = null)
=> _settingsService.SaveSection(SettingsScope.ComponentInstance, ComponentId, sectionId, section, PlacementId, changedKeys);
public void DeleteSection(string sectionId)
=> _settingsService.DeleteSection(SettingsScope.ComponentInstance, ComponentId, sectionId, PlacementId);
public T? GetValue<T>(string key)
=> _settingsService.GetValue<T>(SettingsScope.ComponentInstance, key, ComponentId, PlacementId);
public void SetValue<T>(string key, T value, IReadOnlyCollection<string>? changedKeys = null)
=> _settingsService.SetValue(SettingsScope.ComponentInstance, key, value, ComponentId, PlacementId, changedKeys: changedKeys);
}
private sealed class PluginSettingsDocument
{
public Dictionary<string, Dictionary<string, JsonElement>> Sections { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
}