mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
* fix.hy3试图修复中 * Resolve dev paths and fix splash UI thread Compute a solutionRoot and expand development search paths (LanMountainDesktop and dev-test) in DeploymentLocator, add logging when scanning/finding hosts, and return distinct full paths. Ensure backward-compatible path checks. Fix cross-thread UI calls: invoke splashWindow.DismissAsync on the UI thread in LauncherFlowCoordinator, and make SplashWindow.DismissAsync ensure it runs on the UI thread before closing (simplified Close call). These changes improve development host discovery and prevent UI-thread access issues during shutdown. * 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. * Add dev/debug startup flow and launch profiles Handle design-time initialization and add a developer debug startup path: App now skips normal startup when in design mode and shows a DevDebugWindow when running in debug (unless a preview or apply-update command). CommandContext.IsDebugMode is extended to include DOTNET_ENVIRONMENT=Development via a new IsDevelopmentEnvironment helper. Program.Main and BuildAvaloniaApp are made public to aid tooling. Added multiple launchSettings profiles for debug and preview commands that set DOTNET_ENVIRONMENT=Development to simplify IDE debugging and UI previewing. * Simplify splash to fade; add themed about banners Simplify splash startup visuals by removing the multi-mode/slide behavior and always using a fade animation. Update App to create SplashWindow without a StartupVisualMode parameter and remove related fields, layout configuration, slide animation, and easing helpers from SplashWindow. Clean up unused using. Replace the single about_banner asset with theme-aware variants (about_banner_dark.png and about_banner_light.png), delete the old about_banner.png, and update AboutSettingsPage to use a DynamicResource ImageBrush (AboutBannerBrush) that selects the appropriate banner per theme. * Use AppJsonContext for startup state serialization Switch serialization to the source-generated System.Text.Json context: add JsonSerializable(typeof(StartupAttemptRecord)) to AppJsonContext and replace the previous JsonSerializerOptions-based Serialize/Deserialize calls with AppJsonContext.Default.StartupAttemptRecord. Also remove the now-unused SerializerOptions field. Additionally, update .gitignore to exclude /test-aot-publish. * Add OOBE redesign, theme & data location support Introduce a redesigned OOBE flow and data-location/theme support across the launcher. Adds a new ThemeService for applying light/dark and accent colors; integrates FluentIcons.Avalonia package for icons. Overhauls OobeWindow (UX animations, typing effect, multi-step theme and data-location pages, Monet options, and final welcome step) and its code-behind to handle step navigation, accent selection, and data-location resolution. Adds DataLocation UI and handlers (DataLocationPromptWindow changes, DataLocation resolver usage) and wires a DevDebug UI for toggling/opening the data-location page. UpdateEngineService now resolves the launcher root via DataLocationResolver. Misc: update various view models, localization entries and remove TrimmerRoots.xml. * Refactor data location paths and add background service Refactor DataLocationResolver to centralize data path resolution (ResolveLauncherDataPath, ResolveDesktopDataPath, ResolveConfigPath, ResolveLauncherLogsPath, ResolveLauncherStatePath) and replace usages of the previous ".launcher" layout with a "Launcher" folder. Update API: LoadConfig/SaveConfig reorganized and ApplyLocationChoice now accepts an optional custom path and migration flag; migration logic updated accordingly. Update dependent services and views (Logger, DeploymentLocator, UpdateEngineService, OobeStateService, StartupAttemptRegistry, LauncherDebugSettingsStore, OobeWindow) to use the new resolver APIs and paths. Add LauncherBackgroundService to load/validate/cache a custom splash background image and wire it into SplashWindow (AXAML/Axaml.cs) with UI placeholders and overlay. Misc: minor cleanup of Oobe/Splash XAML and related code adjustments and logging improvements.
844 lines
36 KiB
C#
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);
|
|
}
|
|
}
|