mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
setting_re3
This commit is contained in:
114
LanMountainDesktop/Services/ComponentLibraryServices.cs
Normal file
114
LanMountainDesktop/Services/ComponentLibraryServices.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
845
LanMountainDesktop/Services/Settings/ComponentDomainStorage.cs
Normal file
845
LanMountainDesktop/Services/Settings/ComponentDomainStorage.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user