feat(37): keep a default layout snapshot beside the current layout

The matching-build persistence slice is in place: existing layouts are captured
once as defaults, first-run factory scenes are saved and captured before the
creator can edit, and same-build startup continues to load current rows. Default
asset references survive current-layout saves.

One integration test covers the current-layout roundtrip and immutable default.
609 tests green, clean build with 0 warnings.

Reference scan: OBS stores scene collections separately from output profiles;
this split keeps layout state independent in the same SQLite store:
https://obsproject.com/kb/scene-collections
This commit is contained in:
2026-11-10 17:45:39 -08:00
parent b093609ad3
commit 37791086ae
5 changed files with 248 additions and 0 deletions
+1
View File
@@ -147,6 +147,7 @@ public partial class LayoutStore : IDisposable
MigrateSocialEntryTable();
if (GetUserVersion() < 3)
MigrateToV3();
EnsureLayoutSnapshotSchema();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 10;";
+6
View File
@@ -434,10 +434,16 @@ public partial class LayoutStore : IDisposable
DELETE FROM Asset WHERE Id NOT IN (
SELECT AssetId FROM Source WHERE AssetId IS NOT NULL
UNION
SELECT AssetId FROM DefaultSource WHERE AssetId IS NOT NULL
UNION
SELECT ThumbnailAssetId FROM Scene WHERE ThumbnailAssetId IS NOT NULL
UNION
SELECT ThumbnailAssetId FROM DefaultScene WHERE ThumbnailAssetId IS NOT NULL
UNION
SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL
UNION
SELECT AlertVideoAssetId FROM DefaultSource WHERE AlertVideoAssetId IS NOT NULL
UNION
SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId'
UNION
SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId'
+168
View File
@@ -0,0 +1,168 @@
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)
{
if (GetSnapshotState(DefaultsInitializedKey) != "1" && HasStoredScenes())
CaptureDefaults();
if (string.IsNullOrWhiteSpace(GetSnapshotState(CurrentBuildIdKey)))
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 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)
{
using var cmd = _connection.CreateCommand();
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("\"", "\"\"") + "\"";
}
+9
View File
@@ -514,9 +514,11 @@ public partial class MainViewModel : ViewModelBase
private void LoadLayout()
{
_isLoading = true;
var seededFactoryLayout = false;
try
{
AppLog.Write("LoadLayout begin");
_layoutStore.PrepareLayoutSnapshots(Helpers.BuildStamp.Id);
var scenes = _layoutStore.Load();
Scenes.Clear();
foreach (var scene in scenes)
@@ -526,6 +528,7 @@ public partial class MainViewModel : ViewModelBase
{
foreach (var name in SceneCatalog.All)
AddScene(name);
seededFactoryLayout = true;
}
ReorderScenesToCanonical();
HealBackgrounds();
@@ -582,6 +585,12 @@ public partial class MainViewModel : ViewModelBase
LoadClipSettings();
BroadcastForm.Load();
if (seededFactoryLayout)
{
SaveLayoutNow();
_layoutStore.CaptureDefaultsIfMissing();
}
ScheduleSave();
AppLog.Write("LoadLayout end");
}
+64
View File
@@ -0,0 +1,64 @@
using System;
using System.IO;
using System.Linq;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
public sealed class LayoutStoreSnapshotTests
{
[Fact]
public void SameBuild_OpensCurrentLayout_WithoutChangingDefaultSnapshot()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-snapshot-{Guid.NewGuid():N}.db");
const string buildId = "build-a";
try
{
using (var store = new LayoutStore(path))
{
var factory = MakeScene("Factory title");
store.Save(new[] { factory }, null, null);
store.PrepareLayoutSnapshots(buildId);
store.Save(new[] { MakeScene("Creator edit") }, null, null);
}
using (var reopened = new LayoutStore(path))
{
reopened.PrepareLayoutSnapshots(buildId);
var current = Assert.Single(reopened.Load());
Assert.Equal("Creator edit", Assert.Single(current.Elements.OfType<Source>()).Name);
using var connection = GetConnection(path);
using var command = connection.CreateCommand();
command.CommandText = "SELECT Value FROM LayoutSnapshotState WHERE Key = 'CurrentBuildId';";
Assert.Equal(buildId, command.ExecuteScalar());
command.CommandText = "SELECT Name FROM DefaultSource;";
Assert.Equal("Factory title", command.ExecuteScalar());
}
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
private static Scene MakeScene(string sourceName)
=> new()
{
Name = SceneCatalog.Starting,
Elements = { new Source { Name = sourceName, Type = SourceType.TextOverlay } },
};
private static SqliteConnection GetConnection(string path)
{
var connection = new SqliteConnection($"Data Source={path}");
connection.Open();
return connection;
}
}