Files
LlamaCasty/Services/LayoutStore.Snapshots.cs
T
gramps 1c6fc3223d feat(37): restore the default layout when the build ID changes
A mismatched or missing current-build stamp restores the immutable default
snapshot atomically before the VM loads scenes. The new integration case edits
current on build A, opens on build B, and verifies the factory layout returns.
610 tests green, clean build with 0 warnings.

Reference: OBS scene collections keep scenes/sources as a separate saved set:
https://obsproject.com/kb/scene-collections
2026-11-10 17:48:43 -08:00

217 lines
8.5 KiB
C#

using Microsoft.Data.Sqlite;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
private static readonly string[] SnapshotTables =
{
"Scene", "Source", "Webcam", "WebcamSceneConfig", "Socials", "SocialEntry", "Music",
};
private const string DefaultsInitializedKey = "DefaultsInitialized";
private const string CurrentBuildIdKey = "CurrentBuildId";
private void EnsureLayoutSnapshotSchema()
{
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS LayoutSnapshotState (
Key TEXT PRIMARY KEY,
Value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS DefaultLayoutSetting (
Key TEXT PRIMARY KEY,
Value TEXT NOT NULL
);
""";
cmd.ExecuteNonQuery();
}
foreach (var table in SnapshotTables)
{
var snapshotTable = "Default" + table;
using (var create = _connection.CreateCommand())
{
create.CommandText = $"CREATE TABLE IF NOT EXISTS {snapshotTable} AS SELECT * FROM {table} WHERE 0;";
create.ExecuteNonQuery();
}
var snapshotColumns = ReadColumnNames(snapshotTable);
foreach (var (name, type, defaultValue) in ReadColumnDefinitions(table))
{
if (snapshotColumns.Contains(name, StringComparer.OrdinalIgnoreCase)) continue;
var definition = string.IsNullOrWhiteSpace(type) ? "BLOB" : type;
if (defaultValue != null) definition += " DEFAULT " + defaultValue;
using var add = _connection.CreateCommand();
add.CommandText = $"ALTER TABLE {snapshotTable} ADD COLUMN {QuoteIdentifier(name)} {definition};";
add.ExecuteNonQuery();
}
}
}
/// <summary>Captures the installed layout once as the immutable branded default. Existing databases
/// are snapshotted before MainViewModel loads them; a fresh database calls
/// <see cref="CaptureDefaultsIfMissing"/> after its factory scenes have been seeded.</summary>
public void PrepareLayoutSnapshots(string buildId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(buildId);
var defaultsInitialized = GetSnapshotState(DefaultsInitializedKey) == "1";
if (!defaultsInitialized && HasStoredScenes())
{
CaptureDefaults();
defaultsInitialized = true;
}
var currentBuildId = GetSnapshotState(CurrentBuildIdKey);
if (defaultsInitialized && !string.Equals(currentBuildId, buildId, StringComparison.Ordinal))
RestoreDefaults(buildId);
else if (!defaultsInitialized && string.IsNullOrWhiteSpace(currentBuildId))
SetSnapshotState(CurrentBuildIdKey, buildId);
}
/// <summary>Seeds the default snapshot after the first-run factory layout has been persisted.</summary>
public void CaptureDefaultsIfMissing()
{
if (GetSnapshotState(DefaultsInitializedKey) != "1")
CaptureDefaults();
}
private void CaptureDefaults()
{
var columnsByTable = SnapshotTables.ToDictionary(table => table, ReadColumnNames);
using var tx = _connection.BeginTransaction();
foreach (var table in SnapshotTables.Reverse())
{
using var delete = _connection.CreateCommand();
delete.Transaction = tx;
delete.CommandText = $"DELETE FROM Default{table};";
delete.ExecuteNonQuery();
}
foreach (var table in SnapshotTables)
{
var columns = string.Join(", ", columnsByTable[table].Select(QuoteIdentifier));
using var copy = _connection.CreateCommand();
copy.Transaction = tx;
copy.CommandText = $"INSERT INTO Default{table} ({columns}) SELECT {columns} FROM {table};";
copy.ExecuteNonQuery();
}
using (var clearSettings = _connection.CreateCommand())
{
clearSettings.Transaction = tx;
clearSettings.CommandText = "DELETE FROM DefaultLayoutSetting;";
clearSettings.ExecuteNonQuery();
}
using (var copySettings = _connection.CreateCommand())
{
copySettings.Transaction = tx;
copySettings.CommandText = """
INSERT INTO DefaultLayoutSetting (Key, Value)
SELECT Key, Value FROM Settings
WHERE Key LIKE 'BackgroundPath_%' OR Key LIKE 'BackgroundUseDefault_%';
""";
copySettings.ExecuteNonQuery();
}
using (var state = _connection.CreateCommand())
{
state.Transaction = tx;
state.CommandText = """
INSERT INTO LayoutSnapshotState (Key, Value) VALUES ($key, '1')
ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value;
""";
state.Parameters.AddWithValue("$key", DefaultsInitializedKey);
state.ExecuteNonQuery();
}
tx.Commit();
}
private void RestoreDefaults(string buildId)
{
var columnsByTable = SnapshotTables.ToDictionary(table => table, ReadColumnNames);
using var tx = _connection.BeginTransaction();
foreach (var table in SnapshotTables.Reverse())
{
using var delete = _connection.CreateCommand();
delete.Transaction = tx;
delete.CommandText = $"DELETE FROM {table};";
delete.ExecuteNonQuery();
}
foreach (var table in SnapshotTables)
{
var columns = string.Join(", ", columnsByTable[table].Select(QuoteIdentifier));
using var copy = _connection.CreateCommand();
copy.Transaction = tx;
copy.CommandText = $"INSERT INTO {table} ({columns}) SELECT {columns} FROM Default{table};";
copy.ExecuteNonQuery();
}
using (var clearSettings = _connection.CreateCommand())
{
clearSettings.Transaction = tx;
clearSettings.CommandText = "DELETE FROM Settings WHERE Key LIKE 'BackgroundPath_%' OR Key LIKE 'BackgroundUseDefault_%';";
clearSettings.ExecuteNonQuery();
}
using (var copySettings = _connection.CreateCommand())
{
copySettings.Transaction = tx;
copySettings.CommandText = "INSERT INTO Settings (Key, Value) SELECT Key, Value FROM DefaultLayoutSetting;";
copySettings.ExecuteNonQuery();
}
SetSnapshotState(CurrentBuildIdKey, buildId, tx);
tx.Commit();
}
private bool HasStoredScenes()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT EXISTS(SELECT 1 FROM Scene LIMIT 1);";
return Convert.ToInt32(cmd.ExecuteScalar()) != 0;
}
private string? GetSnapshotState(string key)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM LayoutSnapshotState WHERE Key = $key;";
cmd.Parameters.AddWithValue("$key", key);
return cmd.ExecuteScalar() as string;
}
private void SetSnapshotState(string key, string value, SqliteTransaction? transaction = null)
{
using var cmd = _connection.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = """
INSERT INTO LayoutSnapshotState (Key, Value) VALUES ($key, $value)
ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value;
""";
cmd.Parameters.AddWithValue("$key", key);
cmd.Parameters.AddWithValue("$value", value);
cmd.ExecuteNonQuery();
}
private List<string> ReadColumnNames(string table)
=> ReadColumnDefinitions(table).Select(column => column.Name).ToList();
private List<(string Name, string Type, string? DefaultValue)> ReadColumnDefinitions(string table)
{
var columns = new List<(string, string, string?)>();
using var cmd = _connection.CreateCommand();
cmd.CommandText = $"PRAGMA table_info({table});";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add((reader.GetString(1), reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
reader.IsDBNull(4) ? null : reader.GetString(4)));
return columns;
}
private static string QuoteIdentifier(string identifier)
=> "\"" + identifier.Replace("\"", "\"\"") + "\"";
}