setting_re3

This commit is contained in:
lincube
2026-03-13 09:10:00 +08:00
parent c4df243610
commit 3b3f060f33
70 changed files with 1986 additions and 8966 deletions

View File

@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Views;
using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Services;
public sealed record ComponentLibraryCreateContext(
double CellSize,
TimeZoneService TimeZoneService,
IWeatherInfoService WeatherInfoService,
IRecommendationInfoService RecommendationInfoService,
ICalculatorDataService CalculatorDataService,
string? PlacementId = null);
public interface IComponentLibraryService
{
IReadOnlyList<DesktopComponentDefinition> GetDefinitions();
bool TryCreateControl(
string componentId,
ComponentLibraryCreateContext context,
out Control? control,
out Exception? exception);
}
public interface IComponentLibraryWindowService
{
void Open(MainWindow window);
void Close(MainWindow window);
void Toggle(MainWindow window);
}
internal sealed class ComponentLibraryService : IComponentLibraryService
{
private readonly ComponentRegistry _registry;
private readonly DesktopComponentRuntimeRegistry _runtimeRegistry;
public ComponentLibraryService(ComponentRegistry registry, DesktopComponentRuntimeRegistry runtimeRegistry)
{
_registry = registry;
_runtimeRegistry = runtimeRegistry;
}
public IReadOnlyList<DesktopComponentDefinition> GetDefinitions()
{
return _registry.GetAll().ToArray();
}
public bool TryCreateControl(
string componentId,
ComponentLibraryCreateContext context,
out Control? control,
out Exception? exception)
{
control = null;
exception = null;
if (!_runtimeRegistry.TryGetDescriptor(componentId, out var descriptor))
{
return false;
}
try
{
control = descriptor.CreateControl(
context.CellSize,
context.TimeZoneService,
context.WeatherInfoService,
context.RecommendationInfoService,
context.CalculatorDataService,
context.PlacementId);
return true;
}
catch (Exception ex)
{
exception = ex;
return false;
}
}
}
internal sealed class ComponentLibraryWindowService : IComponentLibraryWindowService
{
public void Open(MainWindow window)
{
ArgumentNullException.ThrowIfNull(window);
window.OpenComponentLibraryWindowFromService();
}
public void Close(MainWindow window)
{
ArgumentNullException.ThrowIfNull(window);
window.CloseComponentLibraryWindowFromService();
}
public void Toggle(MainWindow window)
{
ArgumentNullException.ThrowIfNull(window);
if (window.IsComponentLibraryOpenFromService)
{
window.CloseComponentLibraryWindowFromService();
return;
}
window.OpenComponentLibraryWindowFromService();
}
}

View File

@@ -1,40 +1,22 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services;
public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private static readonly object CacheGate = new();
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
private static string? _cachedPath;
private static ComponentSettingsDocumentSnapshot? _cachedSnapshot;
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
private static DateTime _lastProbeUtc = DateTime.MinValue;
private readonly string _settingsPath;
private readonly string _legacyAppSettingsPath;
private const string LegacySectionId = "__legacy__";
private readonly ISettingsService? _settingsService;
private readonly IComponentStateStore? _stateStore;
private readonly IComponentMessageStore? _messageStore;
private string _scopedComponentId = string.Empty;
private string _scopedPlacementId = string.Empty;
public ComponentSettingsService()
: this(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop"))
{
_settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
}
internal ComponentSettingsService(string settingsDirectory)
@@ -44,8 +26,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
throw new ArgumentException("Settings directory cannot be null or whitespace.", nameof(settingsDirectory));
}
_settingsPath = Path.Combine(settingsDirectory, "component-settings.json");
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
var storage = new SqliteComponentDomainStorage(settingsDirectory);
_stateStore = storage;
_messageStore = storage;
}
public ComponentSettingsSnapshot Load()
@@ -55,19 +38,15 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return LoadForComponent(_scopedComponentId, _scopedPlacementId);
}
try
if (_settingsService is not null)
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
return document.DefaultSettings.Clone();
}
}
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to load component settings from '{_settingsPath}'.", ex);
return new ComponentSettingsSnapshot();
return _settingsService.LoadSnapshot<ComponentSettingsSnapshot>(
SettingsScope.ComponentInstance,
subjectId: string.Empty,
placementId: null);
}
return _stateStore?.LoadState(componentId: string.Empty, placementId: null) ?? new ComponentSettingsSnapshot();
}
public void Save(ComponentSettingsSnapshot snapshot)
@@ -78,186 +57,116 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return;
}
var snapshotToPersist = NormalizeSnapshot(snapshot);
if (_settingsService is not null)
{
_settingsService.SaveSnapshot(
SettingsScope.ComponentInstance,
snapshot ?? new ComponentSettingsSnapshot(),
subjectId: string.Empty,
placementId: null);
return;
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
document.DefaultSettings = snapshotToPersist;
PersistDocumentLocked(document);
}
}
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to save default component settings to '{_settingsPath}'.", ex);
}
_stateStore?.SaveState(componentId: string.Empty, placementId: null, snapshot ?? new ComponentSettingsSnapshot());
}
public ComponentSettingsSnapshot LoadForComponent(string componentId, string? placementId)
{
try
if (_settingsService is not null)
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
var instanceKey = BuildInstanceKey(componentId, placementId);
if (!string.IsNullOrWhiteSpace(instanceKey) &&
document.InstanceSettings.TryGetValue(instanceKey, out var snapshot))
{
return snapshot.Clone();
}
return _settingsService.LoadSnapshot<ComponentSettingsSnapshot>(
SettingsScope.ComponentInstance,
subjectId: componentId,
placementId: placementId);
}
return document.DefaultSettings.Clone();
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to load component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
return new ComponentSettingsSnapshot();
}
return _stateStore?.LoadState(componentId, placementId) ?? new ComponentSettingsSnapshot();
}
public void SaveForComponent(string componentId, string? placementId, ComponentSettingsSnapshot snapshot)
{
var normalizedSnapshot = NormalizeSnapshot(snapshot);
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
if (_settingsService is not null)
{
Save(normalizedSnapshot);
_settingsService.SaveSnapshot(
SettingsScope.ComponentInstance,
snapshot ?? new ComponentSettingsSnapshot(),
subjectId: componentId,
placementId: placementId);
return;
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
document.InstanceSettings[instanceKey] = normalizedSnapshot;
PersistDocumentLocked(document);
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to save component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
}
_stateStore?.SaveState(componentId, placementId, snapshot ?? new ComponentSettingsSnapshot());
}
public void DeleteForComponent(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
if (_settingsService is not null)
{
_settingsService.SaveSnapshot(
SettingsScope.ComponentInstance,
new ComponentSettingsSnapshot(),
subjectId: componentId,
placementId: placementId);
_settingsService.DeleteSection(SettingsScope.ComponentInstance, componentId, LegacySectionId, placementId);
return;
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
var changed = document.InstanceSettings.Remove(instanceKey);
changed |= document.PluginSettings.Remove(instanceKey);
if (changed)
{
PersistDocumentLocked(document);
}
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to delete component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
}
_stateStore?.DeleteState(componentId, placementId);
}
public T LoadPluginSettings<T>(string componentId, string? placementId) where T : new()
{
try
if (_settingsService is not null)
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey) ||
!document.PluginSettings.TryGetValue(instanceKey, out var settingsElement))
{
return new T();
}
return _settingsService.LoadSection<T>(
SettingsScope.ComponentInstance,
subjectId: componentId,
sectionId: LegacySectionId,
placementId: placementId);
}
return JsonSerializer.Deserialize<T>(settingsElement.GetRawText(), SerializerOptions) ?? new T();
}
}
catch (Exception ex)
if (_messageStore is SqliteComponentDomainStorage sqliteStorage)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to load plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
return new T();
return sqliteStorage.LoadLegacyMessage<T>(componentId, placementId);
}
return new T();
}
public void SavePluginSettings<T>(string componentId, string? placementId, T settings)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
if (_settingsService is not null)
{
_settingsService.SaveSection(
SettingsScope.ComponentInstance,
subjectId: componentId,
sectionId: LegacySectionId,
section: settings,
placementId: placementId);
return;
}
try
if (_messageStore is SqliteComponentDomainStorage sqliteStorage)
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
document.PluginSettings[instanceKey] = JsonSerializer.SerializeToElement(settings, SerializerOptions).Clone();
PersistDocumentLocked(document);
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to save plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
sqliteStorage.SaveLegacyMessage(componentId, placementId, settings);
}
}
public void DeletePluginSettings(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
if (_settingsService is not null)
{
_settingsService.DeleteSection(
SettingsScope.ComponentInstance,
subjectId: componentId,
sectionId: LegacySectionId,
placementId: placementId);
return;
}
try
if (_messageStore is SqliteComponentDomainStorage sqliteStorage)
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
if (document.PluginSettings.Remove(instanceKey))
{
PersistDocumentLocked(document);
}
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to delete plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
sqliteStorage.DeleteLegacyMessage(componentId, placementId);
}
}
@@ -309,385 +218,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsDocumentSnapshot snapshot)
internal static void ResetCacheForTests()
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
nowUtc - _lastProbeUtc < CacheProbeInterval)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsDocumentSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
writeTimeUtc == _cachedWriteTimeUtc)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private ComponentSettingsDocumentSnapshot LoadDocumentLocked()
{
var nowUtc = DateTime.UtcNow;
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
{
return cached;
}
var hasFile = File.Exists(_settingsPath);
var writeTimeUtc = hasFile
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.MinValue;
_lastProbeUtc = nowUtc;
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
{
return cached;
}
ComponentSettingsDocumentSnapshot loadedSnapshot;
var loadDetails = ComponentSettingsLoadDetails.Empty;
if (hasFile)
{
loadDetails = LoadSnapshotFromDisk();
loadedSnapshot = loadDetails.Snapshot;
}
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
{
loadedSnapshot = new ComponentSettingsDocumentSnapshot
{
DefaultSettings = NormalizeSnapshot(migratedSnapshot)
};
loadDetails = new ComponentSettingsLoadDetails(
loadedSnapshot,
ComponentSettingsDocumentFormat.LegacySnapshot,
true);
}
else
{
loadedSnapshot = new ComponentSettingsDocumentSnapshot();
}
var normalizedSnapshot = NormalizeDocument(loadedSnapshot);
if (loadDetails.ShouldRewriteToCanonical)
{
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
}
LogLoadDetails(loadDetails.Format, loadDetails.ShouldRewriteToCanonical, normalizedSnapshot);
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
return normalizedSnapshot.Clone();
}
private ComponentSettingsLoadDetails LoadSnapshotFromDisk()
{
try
{
var json = File.ReadAllText(_settingsPath);
using var document = JsonDocument.Parse(json);
if (TryGetDocumentFormat(document.RootElement, out var format))
{
var snapshot = JsonSerializer.Deserialize<ComponentSettingsDocumentSnapshot>(json, SerializerOptions);
return new ComponentSettingsLoadDetails(
snapshot ?? new ComponentSettingsDocumentSnapshot(),
format,
format == ComponentSettingsDocumentFormat.PascalCaseDocument);
}
var legacySnapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
return new ComponentSettingsLoadDetails(
new ComponentSettingsDocumentSnapshot
{
DefaultSettings = NormalizeSnapshot(legacySnapshot)
},
ComponentSettingsDocumentFormat.LegacySnapshot,
true);
}
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex);
return ComponentSettingsLoadDetails.Empty;
}
}
private bool TryLoadLegacySnapshot(out ComponentSettingsSnapshot snapshot)
{
snapshot = new ComponentSettingsSnapshot();
try
{
if (!File.Exists(_legacyAppSettingsPath))
{
return false;
}
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
var legacy = JsonSerializer.Deserialize<LegacyComponentSettingsSnapshot>(legacyJson, SerializerOptions);
if (legacy is null)
{
return false;
}
snapshot = new ComponentSettingsSnapshot
{
DailyArtworkMirrorSource = legacy.DailyArtworkMirrorSource,
ImportedClassSchedules = legacy.ImportedClassSchedules ?? [],
ActiveImportedClassScheduleId = legacy.ActiveImportedClassScheduleId ?? string.Empty,
StudyEnvironmentShowDisplayDb = legacy.StudyEnvironmentShowDisplayDb,
StudyEnvironmentShowDbfs = legacy.StudyEnvironmentShowDbfs,
DesktopClockTimeZoneId = legacy.DesktopClockTimeZoneId,
DesktopClockSecondHandMode = legacy.DesktopClockSecondHandMode,
WorldClockTimeZoneIds = legacy.WorldClockTimeZoneIds ?? [],
WorldClockSecondHandMode = legacy.WorldClockSecondHandMode,
CnrDailyNewsAutoRotateEnabled = legacy.CnrDailyNewsAutoRotateEnabled,
CnrDailyNewsAutoRotateIntervalMinutes = legacy.CnrDailyNewsAutoRotateIntervalMinutes,
IfengNewsAutoRefreshEnabled = legacy.IfengNewsAutoRefreshEnabled,
IfengNewsAutoRefreshIntervalMinutes = legacy.IfengNewsAutoRefreshIntervalMinutes,
IfengNewsChannelType = legacy.IfengNewsChannelType,
DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled,
DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes,
BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled,
BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes,
BaiduHotSearchAutoRefreshEnabled = legacy.BaiduHotSearchAutoRefreshEnabled,
BaiduHotSearchAutoRefreshIntervalMinutes = legacy.BaiduHotSearchAutoRefreshIntervalMinutes,
BaiduHotSearchSourceType = legacy.BaiduHotSearchSourceType,
WeatherAutoRefreshEnabled = legacy.WeatherAutoRefreshEnabled,
WeatherAutoRefreshIntervalMinutes = legacy.WeatherAutoRefreshIntervalMinutes,
Stcn24ForumAutoRefreshEnabled = legacy.Stcn24ForumAutoRefreshEnabled,
Stcn24ForumAutoRefreshIntervalMinutes = legacy.Stcn24ForumAutoRefreshIntervalMinutes,
Stcn24ForumSourceType = legacy.Stcn24ForumSourceType
};
return true;
}
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to migrate legacy component settings from '{_legacyAppSettingsPath}'.", ex);
return false;
}
}
private void PersistDocumentLocked(ComponentSettingsDocumentSnapshot snapshot)
{
var writeTimeUtc = PersistSnapshotToDisk(snapshot);
UpdateCache(snapshot, writeTimeUtc, DateTime.UtcNow);
}
private DateTime PersistSnapshotToDisk(ComponentSettingsDocumentSnapshot snapshot)
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_settingsPath, json);
return File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.UtcNow;
}
private static ComponentSettingsSnapshot NormalizeSnapshot(ComponentSettingsSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new ComponentSettingsSnapshot();
normalized.DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(normalized.DailyArtworkMirrorSource);
normalized.ImportedClassSchedules = NormalizeImportedSchedules(normalized.ImportedClassSchedules);
normalized.ActiveImportedClassScheduleId = NormalizeActiveScheduleId(
normalized.ActiveImportedClassScheduleId,
normalized.ImportedClassSchedules);
if (!normalized.StudyEnvironmentShowDisplayDb && !normalized.StudyEnvironmentShowDbfs)
{
normalized.StudyEnvironmentShowDisplayDb = true;
}
normalized.DesktopClockTimeZoneId = NormalizeDesktopClockTimeZoneId(normalized.DesktopClockTimeZoneId);
normalized.DesktopClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.DesktopClockSecondHandMode);
normalized.WorldClockTimeZoneIds = WorldClockTimeZoneCatalog
.NormalizeTimeZoneIds(normalized.WorldClockTimeZoneIds)
.ToList();
normalized.WorldClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.WorldClockSecondHandMode);
normalized.CnrDailyNewsAutoRotateIntervalMinutes = NormalizeCnrInterval(normalized.CnrDailyNewsAutoRotateIntervalMinutes);
normalized.IfengNewsAutoRefreshIntervalMinutes = NormalizeIfengNewsInterval(normalized.IfengNewsAutoRefreshIntervalMinutes);
normalized.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(normalized.IfengNewsChannelType);
normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes);
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval(
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes);
normalized.BaiduHotSearchAutoRefreshIntervalMinutes = NormalizeBaiduHotSearchInterval(
normalized.BaiduHotSearchAutoRefreshIntervalMinutes);
normalized.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(normalized.BaiduHotSearchSourceType);
normalized.WeatherAutoRefreshIntervalMinutes = NormalizeWeatherInterval(normalized.WeatherAutoRefreshIntervalMinutes);
normalized.Stcn24ForumAutoRefreshIntervalMinutes = NormalizeStcn24ForumInterval(normalized.Stcn24ForumAutoRefreshIntervalMinutes);
normalized.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(normalized.Stcn24ForumSourceType);
return normalized;
}
private static ComponentSettingsDocumentSnapshot NormalizeDocument(ComponentSettingsDocumentSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new ComponentSettingsDocumentSnapshot();
normalized.DefaultSettings = NormalizeSnapshot(normalized.DefaultSettings);
var instanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in normalized.InstanceSettings)
{
var key = NormalizeInstanceKey(pair.Key);
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
instanceSettings[key] = NormalizeSnapshot(pair.Value);
}
var pluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in normalized.PluginSettings)
{
var key = NormalizeInstanceKey(pair.Key);
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
pluginSettings[key] = pair.Value.Clone();
}
normalized.InstanceSettings = instanceSettings;
normalized.PluginSettings = pluginSettings;
return normalized;
}
private static List<ImportedClassScheduleSnapshot> NormalizeImportedSchedules(
IReadOnlyList<ImportedClassScheduleSnapshot>? schedules)
{
if (schedules is null || schedules.Count == 0)
{
return [];
}
var result = new List<ImportedClassScheduleSnapshot>(schedules.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var schedule in schedules)
{
if (schedule is null)
{
continue;
}
var id = schedule.Id?.Trim() ?? string.Empty;
var filePath = schedule.FilePath?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(filePath))
{
continue;
}
if (!seenIds.Add(id))
{
continue;
}
result.Add(new ImportedClassScheduleSnapshot
{
Id = id,
DisplayName = schedule.DisplayName?.Trim() ?? string.Empty,
FilePath = filePath
});
}
return result;
}
private static string NormalizeActiveScheduleId(
string? activeScheduleId,
IReadOnlyList<ImportedClassScheduleSnapshot> schedules)
{
var activeId = activeScheduleId?.Trim() ?? string.Empty;
if (schedules.Count == 0)
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(activeId))
{
return schedules[0].Id;
}
return schedules.Any(item => string.Equals(item.Id, activeId, StringComparison.OrdinalIgnoreCase))
? activeId
: schedules[0].Id;
}
private static string NormalizeDesktopClockTimeZoneId(string? timeZoneId)
{
var normalizedId = string.IsNullOrWhiteSpace(timeZoneId)
? "China Standard Time"
: timeZoneId.Trim();
return WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(normalizedId).Id;
}
private static int NormalizeCnrInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 60);
}
private static int NormalizeDailyWordInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 360);
}
private static int NormalizeIfengNewsInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 20);
}
private static int NormalizeBilibiliHotSearchInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private static int NormalizeBaiduHotSearchInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private static int NormalizeWeatherInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 12);
}
private static int NormalizeStcn24ForumInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 20);
}
private static string BuildInstanceKey(string componentId, string? placementId)
{
var normalizedComponentId = componentId?.Trim() ?? string.Empty;
var normalizedPlacementId = placementId?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(normalizedComponentId) || string.IsNullOrWhiteSpace(normalizedPlacementId))
{
return string.Empty;
}
return $"{normalizedComponentId}::{normalizedPlacementId}";
}
private static string NormalizeInstanceKey(string? key)
{
return key?.Trim() ?? string.Empty;
// no-op: SQLite storage is directly persisted without in-memory cache.
}
private bool HasScopedComponentContext()
@@ -695,192 +228,4 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return !string.IsNullOrWhiteSpace(_scopedComponentId) &&
!string.IsNullOrWhiteSpace(_scopedPlacementId);
}
private void UpdateCache(ComponentSettingsDocumentSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
_cachedWriteTimeUtc = writeTimeUtc;
_lastProbeUtc = probeTimeUtc;
}
internal static void ResetCacheForTests()
{
lock (CacheGate)
{
_cachedPath = null;
_cachedSnapshot = null;
_cachedWriteTimeUtc = DateTime.MinValue;
_lastProbeUtc = DateTime.MinValue;
}
}
private void LogLoadDetails(
ComponentSettingsDocumentFormat format,
bool rewroteToCanonical,
ComponentSettingsDocumentSnapshot snapshot)
{
AppLogger.Info(
"ComponentSettings",
$"Loaded component settings document. Format={format}; RewroteToCanonical={rewroteToCanonical}; " +
$"InstanceSettings={snapshot.InstanceSettings.Count}; PluginSettings={snapshot.PluginSettings.Count}; Path={_settingsPath}");
}
private static bool TryGetDocumentFormat(
JsonElement rootElement,
out ComponentSettingsDocumentFormat format)
{
format = ComponentSettingsDocumentFormat.EmptyDocument;
if (rootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
var hasDocumentProperties = false;
var requiresCanonicalRewrite = false;
foreach (var property in rootElement.EnumerateObject())
{
if (!IsDocumentPropertyName(property.Name))
{
continue;
}
hasDocumentProperties = true;
if (!IsCanonicalDocumentPropertyName(property.Name))
{
requiresCanonicalRewrite = true;
}
}
if (!hasDocumentProperties)
{
return false;
}
format = requiresCanonicalRewrite
? ComponentSettingsDocumentFormat.PascalCaseDocument
: ComponentSettingsDocumentFormat.CanonicalDocument;
return true;
}
private static bool IsDocumentPropertyName(string propertyName)
{
return string.Equals(propertyName, "defaultSettings", StringComparison.OrdinalIgnoreCase) ||
string.Equals(propertyName, "instanceSettings", StringComparison.OrdinalIgnoreCase) ||
string.Equals(propertyName, "pluginSettings", StringComparison.OrdinalIgnoreCase);
}
private static bool IsCanonicalDocumentPropertyName(string propertyName)
{
return string.Equals(propertyName, "defaultSettings", StringComparison.Ordinal) ||
string.Equals(propertyName, "instanceSettings", StringComparison.Ordinal) ||
string.Equals(propertyName, "pluginSettings", StringComparison.Ordinal);
}
private sealed class ComponentSettingsDocumentSnapshot
{
public ComponentSettingsSnapshot DefaultSettings { get; set; } = new();
public Dictionary<string, ComponentSettingsSnapshot> InstanceSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, JsonElement> PluginSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public ComponentSettingsDocumentSnapshot Clone()
{
var clone = new ComponentSettingsDocumentSnapshot
{
DefaultSettings = DefaultSettings?.Clone() ?? new ComponentSettingsSnapshot(),
InstanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase),
PluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase)
};
foreach (var pair in InstanceSettings)
{
clone.InstanceSettings[pair.Key] = pair.Value?.Clone() ?? new ComponentSettingsSnapshot();
}
foreach (var pair in PluginSettings)
{
clone.PluginSettings[pair.Key] = pair.Value.Clone();
}
return clone;
}
}
private sealed class LegacyComponentSettingsSnapshot
{
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
public List<ImportedClassScheduleSnapshot>? ImportedClassSchedules { get; set; }
public string? ActiveImportedClassScheduleId { get; set; }
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
public bool StudyEnvironmentShowDbfs { get; set; }
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
public string DesktopClockSecondHandMode { get; set; } = "Tick";
public List<string>? WorldClockTimeZoneIds { get; set; }
public string WorldClockSecondHandMode { get; set; } = "Tick";
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
public bool IfengNewsAutoRefreshEnabled { get; set; } = true;
public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20;
public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive;
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public bool BaiduHotSearchAutoRefreshEnabled { get; set; } = true;
public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official;
public bool WeatherAutoRefreshEnabled { get; set; } = true;
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
}
private readonly record struct ComponentSettingsLoadDetails(
ComponentSettingsDocumentSnapshot Snapshot,
ComponentSettingsDocumentFormat Format,
bool ShouldRewriteToCanonical)
{
public static ComponentSettingsLoadDetails Empty { get; } = new(
new ComponentSettingsDocumentSnapshot(),
ComponentSettingsDocumentFormat.EmptyDocument,
false);
}
private enum ComponentSettingsDocumentFormat
{
EmptyDocument,
CanonicalDocument,
PascalCaseDocument,
LegacySnapshot
}
}

View File

@@ -10,6 +10,7 @@ using Avalonia.Media;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.ComponentSystem.Extensions;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Services;
@@ -114,6 +115,11 @@ public static class DesktopComponentRegistryFactory
{
try
{
var settingsService = contribution.Plugin.Services.GetService(typeof(ISettingsService)) as ISettingsService
?? HostSettingsFacadeProvider.GetOrCreate().Settings;
var pluginSettings = new PluginScopedSettingsService(
contribution.Plugin.Manifest.Id,
settingsService);
var pluginContext = new PluginDesktopComponentContext(
contribution.Plugin.Manifest,
contribution.Plugin.Context.PluginDirectory,
@@ -122,7 +128,8 @@ public static class DesktopComponentRegistryFactory
contribution.Plugin.Context.Properties,
contribution.Registration.ComponentId,
context.PlacementId,
context.CellSize);
context.CellSize,
pluginSettings);
return contribution.Registration.ControlFactory(contribution.Plugin.Services, pluginContext);
}

View File

@@ -1,251 +1,19 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services;
public sealed class DesktopLayoutSettingsService
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private static readonly object CacheGate = new();
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
private static string? _cachedPath;
private static DesktopLayoutSettingsSnapshot? _cachedSnapshot;
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
private static DateTime _lastProbeUtc = DateTime.MinValue;
private readonly string _settingsPath;
private readonly string _legacyAppSettingsPath;
public DesktopLayoutSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
_settingsPath = Path.Combine(settingsDirectory, "desktop-layout-settings.json");
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
}
private readonly IComponentLayoutStore _layoutStore = ComponentDomainStorageProvider.Instance;
public DesktopLayoutSettingsSnapshot Load()
{
try
{
lock (CacheGate)
{
var nowUtc = DateTime.UtcNow;
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
{
return cached;
}
var hasFile = File.Exists(_settingsPath);
var writeTimeUtc = hasFile
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.MinValue;
_lastProbeUtc = nowUtc;
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
{
return cached;
}
DesktopLayoutSettingsSnapshot loadedSnapshot;
var loadedFromLegacy = false;
if (hasFile)
{
loadedSnapshot = LoadSnapshotFromDisk();
}
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
{
loadedSnapshot = migratedSnapshot;
loadedFromLegacy = true;
}
else
{
loadedSnapshot = new DesktopLayoutSettingsSnapshot();
}
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
if (loadedFromLegacy)
{
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
}
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
return normalizedSnapshot.Clone();
}
}
catch (Exception ex)
{
AppLogger.Warn("DesktopLayout", $"Failed to load desktop layout settings from '{_settingsPath}'.", ex);
return new DesktopLayoutSettingsSnapshot();
}
return _layoutStore.LoadLayout();
}
public void Save(DesktopLayoutSettingsSnapshot snapshot)
{
var snapshotToPersist = NormalizeSnapshot(snapshot);
try
{
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
lock (CacheGate)
{
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
}
}
catch (Exception ex)
{
AppLogger.Warn("DesktopLayout", $"Failed to save desktop layout settings to '{_settingsPath}'.", ex);
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out DesktopLayoutSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
nowUtc - _lastProbeUtc < CacheProbeInterval)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out DesktopLayoutSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
writeTimeUtc == _cachedWriteTimeUtc)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private DesktopLayoutSettingsSnapshot LoadSnapshotFromDisk()
{
try
{
var json = File.ReadAllText(_settingsPath);
var snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions);
return NormalizeSnapshot(snapshot);
}
catch (Exception ex)
{
AppLogger.Warn("DesktopLayout", $"Failed to deserialize desktop layout settings from '{_settingsPath}'.", ex);
return new DesktopLayoutSettingsSnapshot();
}
}
private bool TryLoadLegacySnapshot(out DesktopLayoutSettingsSnapshot snapshot)
{
snapshot = new DesktopLayoutSettingsSnapshot();
try
{
if (!File.Exists(_legacyAppSettingsPath))
{
return false;
}
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
var legacy = JsonSerializer.Deserialize<LegacyDesktopLayoutSettingsSnapshot>(legacyJson, SerializerOptions);
if (legacy is null)
{
return false;
}
snapshot = new DesktopLayoutSettingsSnapshot
{
DesktopPageCount = legacy.DesktopPageCount,
CurrentDesktopSurfaceIndex = legacy.CurrentDesktopSurfaceIndex,
DesktopComponentPlacements = legacy.DesktopComponentPlacements ?? []
};
return true;
}
catch (Exception ex)
{
AppLogger.Warn("DesktopLayout", $"Failed to migrate legacy desktop layout settings from '{_legacyAppSettingsPath}'.", ex);
return false;
}
}
private DateTime PersistSnapshotToDisk(DesktopLayoutSettingsSnapshot snapshot)
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_settingsPath, json);
return File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.UtcNow;
}
private static DesktopLayoutSettingsSnapshot NormalizeSnapshot(DesktopLayoutSettingsSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new DesktopLayoutSettingsSnapshot();
normalized.DesktopPageCount = Math.Max(1, normalized.DesktopPageCount);
normalized.CurrentDesktopSurfaceIndex = Math.Max(0, normalized.CurrentDesktopSurfaceIndex);
var placements = new List<DesktopComponentPlacementSnapshot>(normalized.DesktopComponentPlacements?.Count ?? 0);
if (normalized.DesktopComponentPlacements is not null)
{
foreach (var placement in normalized.DesktopComponentPlacements)
{
if (placement is null)
{
continue;
}
placements.Add(new DesktopComponentPlacementSnapshot
{
PlacementId = placement.PlacementId?.Trim() ?? string.Empty,
PageIndex = Math.Max(0, placement.PageIndex),
ComponentId = placement.ComponentId?.Trim() ?? string.Empty,
Row = Math.Max(0, placement.Row),
Column = Math.Max(0, placement.Column),
WidthCells = Math.Max(1, placement.WidthCells),
HeightCells = Math.Max(1, placement.HeightCells)
});
}
}
normalized.DesktopComponentPlacements = placements;
return normalized;
}
private void UpdateCache(DesktopLayoutSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
_cachedWriteTimeUtc = writeTimeUtc;
_lastProbeUtc = probeTimeUtc;
}
private sealed class LegacyDesktopLayoutSettingsSnapshot
{
public int DesktopPageCount { get; set; } = 1;
public int CurrentDesktopSurfaceIndex { get; set; }
public List<DesktopComponentPlacementSnapshot>? DesktopComponentPlacements { get; set; }
_layoutStore.SaveLayout(snapshot ?? new DesktopLayoutSettingsSnapshot());
}
}

View File

@@ -0,0 +1,845 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text.Json;
using LanMountainDesktop.Models;
using Microsoft.Data.Sqlite;
namespace LanMountainDesktop.Services.Settings;
public interface IComponentLayoutStore
{
DesktopLayoutSettingsSnapshot LoadLayout();
void SaveLayout(DesktopLayoutSettingsSnapshot snapshot);
}
public interface IComponentStateStore
{
ComponentSettingsSnapshot LoadState(string componentId, string? placementId);
void SaveState(string componentId, string? placementId, ComponentSettingsSnapshot snapshot);
void DeleteState(string componentId, string? placementId);
}
public interface IComponentMessageStore
{
T LoadSection<T>(string componentId, string? placementId, string sectionId) where T : new();
void SaveSection<T>(string componentId, string? placementId, string sectionId, T section);
void DeleteSection(string componentId, string? placementId, string sectionId);
}
internal static class ComponentDomainStorageProvider
{
private static readonly object Gate = new();
private static SqliteComponentDomainStorage? _instance;
public static SqliteComponentDomainStorage Instance
{
get
{
lock (Gate)
{
_instance ??= new SqliteComponentDomainStorage();
return _instance;
}
}
}
}
internal sealed class SqliteComponentDomainStorage :
IComponentLayoutStore,
IComponentStateStore,
IComponentMessageStore
{
private const string MigrationMarkerKey = "component_domain_v1";
private const string DefaultInstanceKey = "__default__";
private const string LegacySectionId = "__legacy__";
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private readonly object _gate = new();
private readonly string _settingsRoot;
private readonly string _dbPath;
private readonly string _layoutJsonPath;
private readonly string _componentJsonPath;
public SqliteComponentDomainStorage(string? settingsRoot = null)
{
_settingsRoot = string.IsNullOrWhiteSpace(settingsRoot)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop")
: settingsRoot.Trim();
_dbPath = Path.Combine(_settingsRoot, "component-state.db");
_layoutJsonPath = Path.Combine(_settingsRoot, "desktop-layout-settings.json");
_componentJsonPath = Path.Combine(_settingsRoot, "component-settings.json");
Directory.CreateDirectory(_settingsRoot);
InitializeDatabase();
}
public DesktopLayoutSettingsSnapshot LoadLayout()
{
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT desktop_page_count, current_desktop_surface_index
FROM component_layout
WHERE id = 1;
""";
using var reader = command.ExecuteReader();
if (!reader.Read())
{
return new DesktopLayoutSettingsSnapshot();
}
return new DesktopLayoutSettingsSnapshot
{
DesktopPageCount = Math.Max(1, reader.GetInt32(0)),
CurrentDesktopSurfaceIndex = Math.Max(0, reader.GetInt32(1)),
DesktopComponentPlacements = LoadPlacements(connection)
};
}
}
public void SaveLayout(DesktopLayoutSettingsSnapshot snapshot)
{
var normalized = snapshot?.Clone() ?? new DesktopLayoutSettingsSnapshot();
normalized.DesktopPageCount = Math.Max(1, normalized.DesktopPageCount);
normalized.CurrentDesktopSurfaceIndex = Math.Max(0, normalized.CurrentDesktopSurfaceIndex);
lock (_gate)
{
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = """
INSERT INTO component_layout(id, desktop_page_count, current_desktop_surface_index, updated_utc)
VALUES(1, $count, $index, $updated)
ON CONFLICT(id) DO UPDATE SET
desktop_page_count = excluded.desktop_page_count,
current_desktop_surface_index = excluded.current_desktop_surface_index,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$count", normalized.DesktopPageCount);
command.Parameters.AddWithValue("$index", normalized.CurrentDesktopSurfaceIndex);
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
using (var deleteCommand = connection.CreateCommand())
{
deleteCommand.Transaction = transaction;
deleteCommand.CommandText = "DELETE FROM component_placement;";
deleteCommand.ExecuteNonQuery();
}
if (normalized.DesktopComponentPlacements is { Count: > 0 })
{
foreach (var placement in normalized.DesktopComponentPlacements)
{
if (placement is null || string.IsNullOrWhiteSpace(placement.PlacementId))
{
continue;
}
using var insertCommand = connection.CreateCommand();
insertCommand.Transaction = transaction;
insertCommand.CommandText = """
INSERT INTO component_placement(
placement_id, page_index, component_id, row_index, column_index, width_cells, height_cells, updated_utc)
VALUES($placementId, $page, $componentId, $row, $column, $width, $height, $updated);
""";
insertCommand.Parameters.AddWithValue("$placementId", placement.PlacementId.Trim());
insertCommand.Parameters.AddWithValue("$page", Math.Max(0, placement.PageIndex));
insertCommand.Parameters.AddWithValue("$componentId", placement.ComponentId?.Trim() ?? string.Empty);
insertCommand.Parameters.AddWithValue("$row", Math.Max(0, placement.Row));
insertCommand.Parameters.AddWithValue("$column", Math.Max(0, placement.Column));
insertCommand.Parameters.AddWithValue("$width", Math.Max(1, placement.WidthCells));
insertCommand.Parameters.AddWithValue("$height", Math.Max(1, placement.HeightCells));
insertCommand.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
insertCommand.ExecuteNonQuery();
}
}
transaction.Commit();
}
}
public ComponentSettingsSnapshot LoadState(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT state_json
FROM component_state
WHERE instance_key = $instanceKey
LIMIT 1;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
var json = command.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(json))
{
if (string.Equals(instanceKey, DefaultInstanceKey, StringComparison.OrdinalIgnoreCase))
{
return new ComponentSettingsSnapshot();
}
return LoadDefaultState(connection);
}
return DeserializeState(json);
}
}
public void SaveState(string componentId, string? placementId, ComponentSettingsSnapshot snapshot)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
var normalizedComponentId = NormalizeKey(componentId);
var normalizedPlacementId = NormalizePlacement(placementId);
var json = JsonSerializer.Serialize(snapshot ?? new ComponentSettingsSnapshot(), SerializerOptions);
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO component_state(instance_key, component_id, placement_id, state_json, updated_utc)
VALUES($instanceKey, $componentId, $placementId, $stateJson, $updated)
ON CONFLICT(instance_key) DO UPDATE SET
component_id = excluded.component_id,
placement_id = excluded.placement_id,
state_json = excluded.state_json,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
command.Parameters.AddWithValue("$stateJson", json);
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
}
public void DeleteState(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.Equals(instanceKey, DefaultInstanceKey, StringComparison.OrdinalIgnoreCase))
{
return;
}
lock (_gate)
{
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
using (var stateDelete = connection.CreateCommand())
{
stateDelete.Transaction = transaction;
stateDelete.CommandText = "DELETE FROM component_state WHERE instance_key = $instanceKey;";
stateDelete.Parameters.AddWithValue("$instanceKey", instanceKey);
stateDelete.ExecuteNonQuery();
}
using (var messageDelete = connection.CreateCommand())
{
messageDelete.Transaction = transaction;
messageDelete.CommandText = "DELETE FROM component_message WHERE instance_key = $instanceKey;";
messageDelete.Parameters.AddWithValue("$instanceKey", instanceKey);
messageDelete.ExecuteNonQuery();
}
transaction.Commit();
}
}
public T LoadSection<T>(string componentId, string? placementId, string sectionId) where T : new()
{
var instanceKey = BuildInstanceKey(componentId, placementId);
var normalizedSectionId = NormalizeSection(sectionId);
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT message_json
FROM component_message
WHERE instance_key = $instanceKey
AND section_id = $sectionId
LIMIT 1;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
command.Parameters.AddWithValue("$sectionId", normalizedSectionId);
var json = command.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(json))
{
return new T();
}
try
{
return JsonSerializer.Deserialize<T>(json, SerializerOptions) ?? new T();
}
catch
{
return new T();
}
}
}
public void SaveSection<T>(string componentId, string? placementId, string sectionId, T section)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
var normalizedComponentId = NormalizeKey(componentId);
var normalizedPlacementId = NormalizePlacement(placementId);
var normalizedSectionId = NormalizeSection(sectionId);
var json = JsonSerializer.Serialize(section, SerializerOptions);
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO component_message(instance_key, component_id, placement_id, section_id, message_json, updated_utc)
VALUES($instanceKey, $componentId, $placementId, $sectionId, $messageJson, $updated)
ON CONFLICT(instance_key, section_id) DO UPDATE SET
component_id = excluded.component_id,
placement_id = excluded.placement_id,
message_json = excluded.message_json,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
command.Parameters.AddWithValue("$sectionId", normalizedSectionId);
command.Parameters.AddWithValue("$messageJson", json);
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
}
public void DeleteSection(string componentId, string? placementId, string sectionId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
var normalizedSectionId = NormalizeSection(sectionId);
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM component_message
WHERE instance_key = $instanceKey
AND section_id = $sectionId;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
command.Parameters.AddWithValue("$sectionId", normalizedSectionId);
command.ExecuteNonQuery();
}
}
public T LoadLegacyMessage<T>(string componentId, string? placementId) where T : new()
{
return LoadSection<T>(componentId, placementId, LegacySectionId);
}
public void SaveLegacyMessage<T>(string componentId, string? placementId, T section)
{
SaveSection(componentId, placementId, LegacySectionId, section);
}
public void DeleteLegacyMessage(string componentId, string? placementId)
{
DeleteSection(componentId, placementId, LegacySectionId);
}
private void InitializeDatabase()
{
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
CREATE TABLE IF NOT EXISTS settings_meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS component_layout(
id INTEGER PRIMARY KEY CHECK(id = 1),
desktop_page_count INTEGER NOT NULL,
current_desktop_surface_index INTEGER NOT NULL,
updated_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS component_placement(
placement_id TEXT PRIMARY KEY,
page_index INTEGER NOT NULL,
component_id TEXT NOT NULL,
row_index INTEGER NOT NULL,
column_index INTEGER NOT NULL,
width_cells INTEGER NOT NULL,
height_cells INTEGER NOT NULL,
updated_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS component_state(
instance_key TEXT PRIMARY KEY,
component_id TEXT NOT NULL,
placement_id TEXT NOT NULL,
state_json TEXT NOT NULL,
updated_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS component_message(
instance_key TEXT NOT NULL,
component_id TEXT NOT NULL,
placement_id TEXT NOT NULL,
section_id TEXT NOT NULL,
message_json TEXT NOT NULL,
updated_utc TEXT NOT NULL,
PRIMARY KEY(instance_key, section_id)
);
""";
command.ExecuteNonQuery();
if (!IsMigrationApplied(connection))
{
ApplyInitialMigration(connection);
}
}
}
private bool IsMigrationApplied(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText = """
SELECT value
FROM settings_meta
WHERE key = $key
LIMIT 1;
""";
command.Parameters.AddWithValue("$key", MigrationMarkerKey);
var raw = command.ExecuteScalar() as string;
return string.Equals(raw, "applied", StringComparison.OrdinalIgnoreCase);
}
private void ApplyInitialMigration(SqliteConnection connection)
{
AppLogger.Info("ComponentDomainStorage", "Starting one-shot migration from legacy JSON files to SQLite.");
using var transaction = connection.BeginTransaction();
try
{
if (TryLoadLegacyLayout(out var layout))
{
PersistLayout(connection, transaction, layout);
}
if (TryLoadLegacyComponentDocument(out var document))
{
PersistComponentDocument(connection, transaction, document);
}
using var markerCommand = connection.CreateCommand();
markerCommand.Transaction = transaction;
markerCommand.CommandText = """
INSERT INTO settings_meta(key, value)
VALUES($key, 'applied')
ON CONFLICT(key) DO UPDATE SET value = 'applied';
""";
markerCommand.Parameters.AddWithValue("$key", MigrationMarkerKey);
markerCommand.ExecuteNonQuery();
transaction.Commit();
BackupLegacyFile(_layoutJsonPath);
BackupLegacyFile(_componentJsonPath);
AppLogger.Info("ComponentDomainStorage", "Legacy JSON migration completed.");
}
catch (Exception ex)
{
transaction.Rollback();
AppLogger.Error("ComponentDomainStorage", "Legacy JSON migration failed. SQLite writes are blocked for this session.", ex);
throw;
}
}
private void PersistLayout(
SqliteConnection connection,
SqliteTransaction transaction,
DesktopLayoutSettingsSnapshot snapshot)
{
using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = """
INSERT INTO component_layout(id, desktop_page_count, current_desktop_surface_index, updated_utc)
VALUES(1, $count, $index, $updated)
ON CONFLICT(id) DO UPDATE SET
desktop_page_count = excluded.desktop_page_count,
current_desktop_surface_index = excluded.current_desktop_surface_index,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$count", Math.Max(1, snapshot.DesktopPageCount));
command.Parameters.AddWithValue("$index", Math.Max(0, snapshot.CurrentDesktopSurfaceIndex));
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
if (snapshot.DesktopComponentPlacements is not { Count: > 0 })
{
return;
}
foreach (var placement in snapshot.DesktopComponentPlacements)
{
if (placement is null || string.IsNullOrWhiteSpace(placement.PlacementId))
{
continue;
}
using var placementCommand = connection.CreateCommand();
placementCommand.Transaction = transaction;
placementCommand.CommandText = """
INSERT INTO component_placement(
placement_id, page_index, component_id, row_index, column_index, width_cells, height_cells, updated_utc)
VALUES($placementId, $page, $componentId, $row, $column, $width, $height, $updated)
ON CONFLICT(placement_id) DO UPDATE SET
page_index = excluded.page_index,
component_id = excluded.component_id,
row_index = excluded.row_index,
column_index = excluded.column_index,
width_cells = excluded.width_cells,
height_cells = excluded.height_cells,
updated_utc = excluded.updated_utc;
""";
placementCommand.Parameters.AddWithValue("$placementId", placement.PlacementId.Trim());
placementCommand.Parameters.AddWithValue("$page", Math.Max(0, placement.PageIndex));
placementCommand.Parameters.AddWithValue("$componentId", placement.ComponentId?.Trim() ?? string.Empty);
placementCommand.Parameters.AddWithValue("$row", Math.Max(0, placement.Row));
placementCommand.Parameters.AddWithValue("$column", Math.Max(0, placement.Column));
placementCommand.Parameters.AddWithValue("$width", Math.Max(1, placement.WidthCells));
placementCommand.Parameters.AddWithValue("$height", Math.Max(1, placement.HeightCells));
placementCommand.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
placementCommand.ExecuteNonQuery();
}
}
private void PersistComponentDocument(
SqliteConnection connection,
SqliteTransaction transaction,
LegacyComponentDocument document)
{
PersistComponentState(connection, transaction, DefaultInstanceKey, "__default__", string.Empty, document.DefaultSettings ?? new ComponentSettingsSnapshot());
if (document.InstanceSettings is not null)
{
foreach (var pair in document.InstanceSettings)
{
if (!TrySplitInstanceKey(pair.Key, out var componentId, out var placementId))
{
continue;
}
PersistComponentState(connection, transaction, pair.Key.Trim(), componentId, placementId, pair.Value ?? new ComponentSettingsSnapshot());
}
}
if (document.PluginSettings is null)
{
return;
}
foreach (var pair in document.PluginSettings)
{
if (!TrySplitInstanceKey(pair.Key, out var componentId, out var placementId))
{
continue;
}
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO component_message(instance_key, component_id, placement_id, section_id, message_json, updated_utc)
VALUES($instanceKey, $componentId, $placementId, $sectionId, $json, $updated)
ON CONFLICT(instance_key, section_id) DO UPDATE SET
component_id = excluded.component_id,
placement_id = excluded.placement_id,
message_json = excluded.message_json,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$instanceKey", pair.Key.Trim());
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
command.Parameters.AddWithValue("$sectionId", LegacySectionId);
command.Parameters.AddWithValue("$json", pair.Value.GetRawText());
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
}
private static void PersistComponentState(
SqliteConnection connection,
SqliteTransaction transaction,
string instanceKey,
string componentId,
string placementId,
ComponentSettingsSnapshot snapshot)
{
var json = JsonSerializer.Serialize(snapshot ?? new ComponentSettingsSnapshot(), SerializerOptions);
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO component_state(instance_key, component_id, placement_id, state_json, updated_utc)
VALUES($instanceKey, $componentId, $placementId, $stateJson, $updated)
ON CONFLICT(instance_key) DO UPDATE SET
component_id = excluded.component_id,
placement_id = excluded.placement_id,
state_json = excluded.state_json,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
command.Parameters.AddWithValue("$stateJson", json);
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
private bool TryLoadLegacyLayout(out DesktopLayoutSettingsSnapshot snapshot)
{
snapshot = new DesktopLayoutSettingsSnapshot();
if (!File.Exists(_layoutJsonPath))
{
return false;
}
try
{
var json = File.ReadAllText(_layoutJsonPath);
snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions) ?? new DesktopLayoutSettingsSnapshot();
return true;
}
catch (Exception ex)
{
AppLogger.Warn("ComponentDomainStorage", $"Failed to read legacy layout file '{_layoutJsonPath}'.", ex);
return false;
}
}
private bool TryLoadLegacyComponentDocument(out LegacyComponentDocument document)
{
document = new LegacyComponentDocument();
if (!File.Exists(_componentJsonPath))
{
return false;
}
try
{
var json = File.ReadAllText(_componentJsonPath);
using var parsed = JsonDocument.Parse(json);
if (parsed.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
var hasDocumentShape = false;
foreach (var property in parsed.RootElement.EnumerateObject())
{
if (string.Equals(property.Name, "defaultSettings", StringComparison.OrdinalIgnoreCase) ||
string.Equals(property.Name, "instanceSettings", StringComparison.OrdinalIgnoreCase) ||
string.Equals(property.Name, "pluginSettings", StringComparison.OrdinalIgnoreCase))
{
hasDocumentShape = true;
break;
}
}
if (hasDocumentShape)
{
document = JsonSerializer.Deserialize<LegacyComponentDocument>(json, SerializerOptions) ?? new LegacyComponentDocument();
document.DefaultSettings ??= new ComponentSettingsSnapshot();
document.InstanceSettings ??= new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase);
document.PluginSettings ??= new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
return true;
}
var legacySingle = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions) ?? new ComponentSettingsSnapshot();
document = new LegacyComponentDocument
{
DefaultSettings = legacySingle
};
return true;
}
catch (Exception ex)
{
AppLogger.Warn("ComponentDomainStorage", $"Failed to read legacy component settings file '{_componentJsonPath}'.", ex);
return false;
}
}
private static void BackupLegacyFile(string path)
{
if (!File.Exists(path))
{
return;
}
try
{
var backupPath = $"{path}.migrated.bak";
if (File.Exists(backupPath))
{
File.Delete(backupPath);
}
File.Move(path, backupPath);
}
catch (Exception ex)
{
AppLogger.Warn("ComponentDomainStorage", $"Failed to backup migrated legacy file '{path}'.", ex);
}
}
private static bool TrySplitInstanceKey(string key, out string componentId, out string placementId)
{
componentId = string.Empty;
placementId = string.Empty;
if (string.IsNullOrWhiteSpace(key))
{
return false;
}
var normalized = key.Trim();
var parts = normalized.Split("::", 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2 ||
string.IsNullOrWhiteSpace(parts[0]) ||
string.IsNullOrWhiteSpace(parts[1]))
{
return false;
}
componentId = parts[0];
placementId = parts[1];
return true;
}
private static string BuildInstanceKey(string componentId, string? placementId)
{
var normalizedComponentId = NormalizeKey(componentId);
var normalizedPlacementId = NormalizePlacement(placementId);
if (string.IsNullOrWhiteSpace(normalizedComponentId) ||
string.IsNullOrWhiteSpace(normalizedPlacementId))
{
return DefaultInstanceKey;
}
return $"{normalizedComponentId}::{normalizedPlacementId}";
}
private static string NormalizeKey(string? key)
{
return key?.Trim() ?? string.Empty;
}
private static string NormalizePlacement(string? placementId)
{
return placementId?.Trim() ?? string.Empty;
}
private static string NormalizeSection(string? sectionId)
{
return string.IsNullOrWhiteSpace(sectionId) ? LegacySectionId : sectionId.Trim();
}
private static ComponentSettingsSnapshot DeserializeState(string json)
{
try
{
return JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions) ?? new ComponentSettingsSnapshot();
}
catch
{
return new ComponentSettingsSnapshot();
}
}
private static ComponentSettingsSnapshot LoadDefaultState(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText = """
SELECT state_json
FROM component_state
WHERE instance_key = $instanceKey
LIMIT 1;
""";
command.Parameters.AddWithValue("$instanceKey", DefaultInstanceKey);
var json = command.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(json) ? new ComponentSettingsSnapshot() : DeserializeState(json);
}
private static List<DesktopComponentPlacementSnapshot> LoadPlacements(SqliteConnection connection)
{
var placements = new List<DesktopComponentPlacementSnapshot>();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT placement_id, page_index, component_id, row_index, column_index, width_cells, height_cells
FROM component_placement
ORDER BY page_index, row_index, column_index;
""";
using var reader = command.ExecuteReader();
while (reader.Read())
{
placements.Add(new DesktopComponentPlacementSnapshot
{
PlacementId = reader.IsDBNull(0) ? string.Empty : reader.GetString(0),
PageIndex = reader.IsDBNull(1) ? 0 : Math.Max(0, reader.GetInt32(1)),
ComponentId = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
Row = reader.IsDBNull(3) ? 0 : Math.Max(0, reader.GetInt32(3)),
Column = reader.IsDBNull(4) ? 0 : Math.Max(0, reader.GetInt32(4)),
WidthCells = reader.IsDBNull(5) ? 1 : Math.Max(1, reader.GetInt32(5)),
HeightCells = reader.IsDBNull(6) ? 1 : Math.Max(1, reader.GetInt32(6))
});
}
return placements;
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadWriteCreate;Cache=Shared");
connection.Open();
return connection;
}
private sealed class LegacyComponentDocument
{
public ComponentSettingsSnapshot? DefaultSettings { get; set; } = new();
public Dictionary<string, ComponentSettingsSnapshot>? InstanceSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, JsonElement>? PluginSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,28 @@
using System;
namespace LanMountainDesktop.Services.Settings;
internal static class HostSettingsFacadeProvider
{
private static readonly object Gate = new();
private static SettingsFacadeService? _instance;
public static ISettingsFacadeService GetOrCreate()
{
lock (Gate)
{
_instance ??= new SettingsFacadeService();
return _instance;
}
}
public static void BindPluginRuntime(PluginRuntimeService pluginRuntimeService)
{
ArgumentNullException.ThrowIfNull(pluginRuntimeService);
lock (Gate)
{
_instance ??= new SettingsFacadeService(pluginRuntimeService);
_instance.BindPluginRuntime(pluginRuntimeService);
}
}
}

View File

@@ -74,6 +74,15 @@ public interface IGridSettingsService
{
GridSettingsState Get();
void Save(GridSettingsState state);
string NormalizeSpacingPreset(string? value);
double ResolveGapRatio(string? preset);
double CalculateEdgeInset(double hostWidth, double hostHeight, int shortSideCells, int insetPercent);
DesktopGridMetrics CalculateGridMetrics(
double hostWidth,
double hostHeight,
int shortSideCells,
double gapRatio,
double edgeInsetPx);
}
public interface IWallpaperSettingsService
@@ -117,12 +126,14 @@ public interface IWeatherSettingsService
{
WeatherSettingsState Get();
void Save(WeatherSettingsState state);
IWeatherInfoService GetWeatherInfoService();
}
public interface IRegionSettingsService
{
RegionSettingsState Get();
void Save(RegionSettingsState state);
TimeZoneService GetTimeZoneService();
}
public interface IUpdateSettingsService

View File

@@ -14,6 +14,7 @@ namespace LanMountainDesktop.Services.Settings;
internal sealed class GridSettingsService : IGridSettingsService
{
private readonly AppSettingsService _appSettingsService = new();
private readonly DesktopGridLayoutService _gridLayoutService = new();
public GridSettingsState Get()
{
@@ -32,6 +33,36 @@ internal sealed class GridSettingsService : IGridSettingsService
snapshot.DesktopEdgeInsetPercent = state.EdgeInsetPercent;
_appSettingsService.Save(snapshot);
}
public string NormalizeSpacingPreset(string? value)
{
return _gridLayoutService.NormalizeSpacingPreset(value);
}
public double ResolveGapRatio(string? preset)
{
return _gridLayoutService.ResolveGapRatio(preset);
}
public double CalculateEdgeInset(double hostWidth, double hostHeight, int shortSideCells, int insetPercent)
{
return _gridLayoutService.CalculateEdgeInset(hostWidth, hostHeight, shortSideCells, insetPercent);
}
public DesktopGridMetrics CalculateGridMetrics(
double hostWidth,
double hostHeight,
int shortSideCells,
double gapRatio,
double edgeInsetPx)
{
return _gridLayoutService.CalculateGridMetrics(
hostWidth,
hostHeight,
shortSideCells,
gapRatio,
edgeInsetPx);
}
}
internal sealed class WallpaperSettingsService : IWallpaperSettingsService
@@ -234,7 +265,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
}
}
internal sealed class WeatherProviderAdapter : IWeatherProvider
internal sealed class WeatherProviderAdapter : IWeatherProvider, IWeatherInfoService, IDisposable
{
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
@@ -252,11 +283,20 @@ internal sealed class WeatherProviderAdapter : IWeatherProvider
{
return _weatherDataService.GetWeatherAsync(query, cancellationToken);
}
public void Dispose()
{
if (_weatherDataService is IDisposable disposable)
{
disposable.Dispose();
}
}
}
internal sealed class WeatherSettingsService : IWeatherSettingsService
internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposable
{
private readonly AppSettingsService _appSettingsService = new();
private readonly WeatherProviderAdapter _weatherProvider = new();
public WeatherSettingsState Get()
{
@@ -289,11 +329,22 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService
snapshot.WeatherLocationQuery = state.LocationQuery;
_appSettingsService.Save(snapshot);
}
public IWeatherInfoService GetWeatherInfoService()
{
return _weatherProvider;
}
public void Dispose()
{
_weatherProvider.Dispose();
}
}
internal sealed class RegionSettingsService : IRegionSettingsService
{
private readonly AppSettingsService _appSettingsService = new();
private readonly TimeZoneService _timeZoneService = new();
public RegionSettingsState Get()
{
@@ -312,6 +363,11 @@ internal sealed class RegionSettingsService : IRegionSettingsService
: state.TimeZoneId.Trim();
_appSettingsService.Save(snapshot);
}
public TimeZoneService GetTimeZoneService()
{
return _timeZoneService;
}
}
internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable
@@ -388,13 +444,18 @@ internal sealed class LauncherPolicyService : ILauncherPolicyService
internal sealed class PluginManagementSettingsService : IPluginManagementSettingsService
{
private readonly AppSettingsService _appSettingsService = new();
private readonly PluginRuntimeService? _pluginRuntimeService;
private PluginRuntimeService? _pluginRuntimeService;
public PluginManagementSettingsService(PluginRuntimeService? pluginRuntimeService)
{
_pluginRuntimeService = pluginRuntimeService;
}
public void SetPluginRuntime(PluginRuntimeService? pluginRuntimeService)
{
_pluginRuntimeService = pluginRuntimeService;
}
public PluginManagementSettingsState Get()
{
var snapshot = _appSettingsService.Load();
@@ -426,9 +487,9 @@ internal sealed class PluginManagementSettingsService : IPluginManagementSetting
internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService, IDisposable
{
private readonly PluginRuntimeService? _pluginRuntimeService;
private readonly AirAppMarketIndexService _indexService;
private readonly AirAppMarketInstallService? _installService;
private PluginRuntimeService? _pluginRuntimeService;
private AirAppMarketIndexService _indexService;
private AirAppMarketInstallService? _installService;
private readonly Dictionary<string, AirAppMarketPluginEntry> _cachedPlugins = new(StringComparer.OrdinalIgnoreCase);
public PluginMarketSettingsService(PluginRuntimeService? pluginRuntimeService)
@@ -447,6 +508,24 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
}
}
public void SetPluginRuntime(PluginRuntimeService? pluginRuntimeService)
{
_pluginRuntimeService = pluginRuntimeService;
_installService?.Dispose();
_installService = null;
if (_pluginRuntimeService is null)
{
return;
}
var dataRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"PluginMarket");
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
}
public async Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default)
{
var result = await _indexService.LoadAsync(cancellationToken);
@@ -567,6 +646,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
{
private readonly UpdateSettingsService _updateSettingsService;
private readonly PluginMarketSettingsService _pluginMarketSettingsService;
private readonly PluginManagementSettingsService _pluginManagementSettingsService;
private readonly WeatherSettingsService _weatherSettingsService;
public SettingsFacadeService(PluginRuntimeService? pluginRuntimeService = null)
{
@@ -577,13 +658,15 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
WallpaperMedia = new WallpaperMediaService();
Theme = new ThemeAppearanceService();
StatusBar = new StatusBarSettingsService();
Weather = new WeatherSettingsService();
_weatherSettingsService = new WeatherSettingsService();
Weather = _weatherSettingsService;
Region = new RegionSettingsService();
_updateSettingsService = new UpdateSettingsService();
Update = _updateSettingsService;
LauncherCatalog = new LauncherCatalogService();
LauncherPolicy = new LauncherPolicyService();
PluginManagement = new PluginManagementSettingsService(pluginRuntimeService);
_pluginManagementSettingsService = new PluginManagementSettingsService(pluginRuntimeService);
PluginManagement = _pluginManagementSettingsService;
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
PluginMarket = _pluginMarketSettingsService;
ApplicationInfo = new ApplicationInfoService();
@@ -619,8 +702,15 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
public IApplicationInfoService ApplicationInfo { get; }
public void BindPluginRuntime(PluginRuntimeService? pluginRuntimeService)
{
_pluginManagementSettingsService.SetPluginRuntime(pluginRuntimeService);
_pluginMarketSettingsService.SetPluginRuntime(pluginRuntimeService);
}
public void Dispose()
{
_weatherSettingsService.Dispose();
_updateSettingsService.Dispose();
_pluginMarketSettingsService.Dispose();
}

View File

@@ -19,7 +19,8 @@ internal sealed class SettingsService : ISettingsService
private readonly AppSettingsService _appSettingsService = new();
private readonly LauncherSettingsService _launcherSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly IComponentStateStore _componentStateStore = ComponentDomainStorageProvider.Instance;
private readonly IComponentMessageStore _componentMessageStore = ComponentDomainStorageProvider.Instance;
private readonly string _pluginSettingsPath;
private readonly object _pluginSettingsGate = new();
@@ -80,7 +81,7 @@ internal sealed class SettingsService : ISettingsService
{
if (scope == SettingsScope.ComponentInstance)
{
return _componentSettingsService.LoadPluginSettings<T>(EnsureKey(subjectId), placementId);
return _componentMessageStore.LoadSection<T>(EnsureKey(subjectId), placementId, EnsureKey(sectionId));
}
if (scope != SettingsScope.Plugin)
@@ -111,7 +112,7 @@ internal sealed class SettingsService : ISettingsService
{
if (scope == SettingsScope.ComponentInstance)
{
_componentSettingsService.SavePluginSettings(EnsureKey(subjectId), placementId, section);
_componentMessageStore.SaveSection(EnsureKey(subjectId), placementId, EnsureKey(sectionId), section);
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
return;
}
@@ -142,7 +143,7 @@ internal sealed class SettingsService : ISettingsService
{
if (scope == SettingsScope.ComponentInstance)
{
_componentSettingsService.DeletePluginSettings(EnsureKey(subjectId), placementId);
_componentMessageStore.DeleteSection(EnsureKey(subjectId), placementId, EnsureKey(sectionId));
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId));
return;
}
@@ -183,7 +184,11 @@ internal sealed class SettingsService : ISettingsService
SettingsScope.App => JsonSerializer.SerializeToElement(_appSettingsService.Load(), SerializerOptions),
SettingsScope.Launcher => JsonSerializer.SerializeToElement(_launcherSettingsService.Load(), SerializerOptions),
SettingsScope.ComponentInstance => JsonSerializer.SerializeToElement(
_componentSettingsService.LoadForComponent(EnsureKey(subjectId), placementId),
LoadSection<Dictionary<string, JsonElement>>(
SettingsScope.ComponentInstance,
EnsureKey(subjectId),
sectionId ?? "__root__",
placementId),
SerializerOptions),
SettingsScope.Plugin => JsonSerializer.SerializeToElement(
LoadSection<Dictionary<string, JsonElement>>(SettingsScope.Plugin, EnsureKey(subjectId), sectionId ?? "__root__", placementId),
@@ -239,9 +244,10 @@ internal sealed class SettingsService : ISettingsService
if (scope == SettingsScope.ComponentInstance)
{
var dict = _componentSettingsService.LoadPluginSettings<Dictionary<string, JsonElement>>(EnsureKey(subjectId), placementId);
var effectiveSection = sectionId ?? "__root__";
var dict = _componentMessageStore.LoadSection<Dictionary<string, JsonElement>>(EnsureKey(subjectId), placementId, effectiveSection);
dict[key] = JsonSerializer.SerializeToElement(value, SerializerOptions).Clone();
_componentSettingsService.SavePluginSettings(EnsureKey(subjectId), placementId, dict);
_componentMessageStore.SaveSection(EnsureKey(subjectId), placementId, effectiveSection, dict);
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys ?? [key]));
return;
}
@@ -271,14 +277,14 @@ internal sealed class SettingsService : ISettingsService
private T LoadComponentSnapshot<T>(string? componentId, string? placementId) where T : new()
{
var snapshot = _componentSettingsService.LoadForComponent(EnsureKey(componentId), placementId);
var snapshot = _componentStateStore.LoadState(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);
_componentStateStore.SaveState(EnsureKey(componentId), placementId, converted);
}
private static TOut ConvertSnapshot<TIn, TOut>(TIn source) where TOut : new()