Files
LanMountainDesktop/LanMountainDesktop/Services/Settings/ComponentDomainStorage.cs
lincube ad3648a0b8 Add configurable data location (portable/system)
Introduce support for choosing and resolving the application's data root (system user dir vs. portable app folder). Adds DataLocationConfig model, DataLocationResolver (load/save/resolve/migrate), a UI prompt (DataLocationPromptWindow) and an OOBE step (DataLocationOobeStep) to let users pick and optionally migrate existing data. Wire the chosen data root into the launcher flow and host launch plan (forwarded via --data-root and LMD_DATA_ROOT), and add AppDataPathProvider to let runtime services read the effective data root (initialized in Program.Main). Update various services (logging, settings, DB, plugin/market, startup registry, etc.) to use the new provider/resolver and register the config type in the JSON context. This enables portable installs, safe migration, and runtime overrides via CLI or environment variable.
2026-04-24 12:30:05 +08:00

844 lines
36 KiB
C#

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)
? AppDataPathProvider.GetDataRoot()
: 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);
}
}