Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/SharpMUTerm.Core/Configuration/AppConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace SharpMUTerm.Core.Configuration;
public sealed class AppConfiguration
{
/// <summary>The current on-disk schema version. Older configs are upgraded by <see cref="ConfigurationMigrator"/>.</summary>
public const int CurrentVersion = 4;
public const int CurrentVersion = 5;

/// <summary>Schema version, for future migrations.</summary>
public int Version { get; set; } = CurrentVersion;
Expand Down
105 changes: 105 additions & 0 deletions src/SharpMUTerm.Core/Configuration/ConfigurationMigrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,114 @@ public static void Migrate(JsonObject root)
MigrateV3ToV4(root);
}

if (version < 5)
{
MigrateV4ToV5(root);
}

root["version"] = AppConfiguration.CurrentVersion;
}

/// <summary>
/// v4's spawn window ids named only their target (<c>spawn:Public</c>), so a workspace held one
/// capture pane per target however many characters were capturing into it. v5 puts the owning
/// session in the id (<see cref="Workspaces.Workspace.SpawnWindowId(string?,string)"/>), so two
/// connected characters running the same rule get a pane each. Every saved id is rewritten here —
/// in <c>lastSession.windows</c> and in every pane's <c>tabs</c> array that referenced it.
/// <para>
/// <b>It is a migration and not an adoption, and the reason is that a resumed workspace must not
/// come back holding a pane nothing writes to.</b> Left alone, a saved <c>spawn:Public</c> would
/// match no id the running client can now produce: the pane would sit there for ever, empty, while
/// its channel filled a second pane beside it. Dropping it would have been the other way to avoid
/// that and it throws away a pane the user had, plus — through the window id the restore log is
/// keyed by — the scrollback in it. Rewriting keeps both: the pane stays where it was, and its log
/// file is carried across at startup by <c>SharpMUTermApp.RestorePreviousSession</c>.
/// </para>
/// <para>
/// <b>What supplies the owner is the state itself.</b> <c>WorkspaceWindowState.SessionKey</c> has
/// been persisted all along, so an old file already records which character each spawn window
/// belonged to and the rewrite is lossless rather than a guess. A window that recorded no owner
/// keeps that: it becomes the unowned form, which is exactly what the client produces for a spawn
/// window nobody owns, so it too stays reachable.
/// </para>
/// <para>
/// <b>The decision is made by version, never by looking at the id.</b> A target is free text and may
/// hold anything — including something that reads like a v5 id — so classifying ids by shape would
/// eventually mis-file one and produce the orphan pane this step exists to prevent. Everything under
/// a document that says version 4 is v4, by construction.
/// </para>
/// </summary>
private static void MigrateV4ToV5(JsonObject root)
{
if (root["lastSession"] is not JsonObject session || session["windows"] is not JsonArray windows)
{
return;
}

var rewritten = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var window in windows.OfType<JsonObject>())
{
// Kind is written by the string enum converter, so it is the member name and not a number.
if (!string.Equals(Text(window["kind"]), nameof(Workspaces.WindowKind.Spawn),
StringComparison.OrdinalIgnoreCase))
{
continue;
}

if (Text(window["id"]) is not { } id
|| !id.StartsWith(Workspaces.Workspace.SpawnPrefix, StringComparison.Ordinal))
{
continue;
}

var target = id[Workspaces.Workspace.SpawnPrefix.Length..];
if (target.Length == 0)
{
continue;
}

var upgraded = Workspaces.Workspace.SpawnWindowId(Text(window["sessionKey"]), target);
window["id"] = upgraded;
rewritten[id] = upgraded;
}

if (rewritten.Count > 0 && session["root"] is JsonObject layout)
{
RewriteTabs(layout, rewritten);
}
}

/// <summary>Re-points every pane's tab list at the ids <see cref="MigrateV4ToV5"/> rewrote.</summary>
private static void RewriteTabs(JsonObject node, Dictionary<string, string> rewritten)
{
if (node["tabs"] is JsonArray tabs)
{
for (var i = 0; i < tabs.Count; i++)
{
if (Text(tabs[i]) is { } tab && rewritten.TryGetValue(tab, out var upgraded))
{
tabs[i] = upgraded;
}
}
}

if (node["children"] is JsonArray children)
{
foreach (var child in children.OfType<JsonObject>())
{
RewriteTabs(child, rewritten);
}
}
}

/// <summary>
/// A node's string value, or null when it is absent or is not a string. A hand-edited or
/// hand-truncated document must not stop the client starting, and <c>GetValue&lt;string&gt;</c>
/// throws on a number where this returns null.
/// </summary>
private static string? Text(JsonNode? node) =>
node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;

/// <summary>
/// v3's <c>autoLogin</c> is gone: whether a character logs itself in is now derived from whether it
/// has anything to send (<see cref="CharacterDefinition.Login"/>, <see cref="LoginPlan"/>). The
Expand Down
108 changes: 102 additions & 6 deletions src/SharpMUTerm.Core/Workspace/Workspace.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Globalization;

namespace SharpMUTerm.Core.Workspaces;

/// <summary>
Expand Down Expand Up @@ -154,14 +156,23 @@ public WorkspaceWindow OpenWindow(
}

/// <summary>
/// Routes trigger-spawned output to a spawn window named <paramref name="target"/>, creating and
/// placing the window on first use, and counts the line as unread unless the window is currently
/// visible. Returns the destination window.
/// Routes trigger-spawned output to <paramref name="sessionKey"/>'s spawn window named
/// <paramref name="target"/>, creating and placing the window on first use, and counts the line as
/// unread unless the window is currently visible. Returns the destination window.
/// <para>
/// <b>The destination is per session, not per workspace.</b> Two connected characters running the
/// same capture rule each get a window of their own; the id carries the owner, so the second
/// session to match cannot land in the first's window. It used to: the id was the target alone, so
/// whoever matched first created the window <em>with their own session key on it</em> and everybody
/// else's lines were appended to somebody else's pane. That was not merely a mixed-up channel — the
/// rail draws window rows for the active character only, so the second character's own channel was
/// filed under the first and was invisible from the character it belonged to.
/// </para>
/// </summary>
public WorkspaceWindow RouteSpawn(string target, string? sessionKey = null)
{
ArgumentException.ThrowIfNullOrEmpty(target);
var id = SpawnWindowId(target);
var id = SpawnWindowId(sessionKey, target);
if (!_windows.TryGetValue(id, out var window))
{
window = Register(new WorkspaceWindow(id, target, WindowKind.Spawn, sessionKey));
Expand All @@ -172,8 +183,93 @@ public WorkspaceWindow RouteSpawn(string target, string? sessionKey = null)
return window;
}

/// <summary>The window id a spawn <paramref name="target"/> routes to.</summary>
public static string SpawnWindowId(string target) => $"spawn:{target}";
/// <summary>Every spawn window id starts with this.</summary>
public const string SpawnPrefix = "spawn:";

/// <summary>
/// The owner field of a spawn window that belongs to nobody. A single <c>-</c>, which is not a
/// decimal length and so can never be mistaken for one — that is the whole reason it is not the
/// empty string.
/// </summary>
private const string Unowned = "-";

/// <summary>
/// The window id the spawn <paramref name="target"/> of the session <paramref name="sessionKey"/>
/// routes to. Unique per <c>(owner, target)</c> and stable for ever, so a reconnect or a restart
/// comes back to the pane it left.
/// <para>
/// <b>Why the length prefix.</b> A session key and a target are both user-controlled strings that may
/// hold any character, colons included — a world or character can be called <c>a:b</c> and a
/// trigger's <c>SpawnTarget</c> is free text. Joining them with a separator is therefore <em>not</em>
/// injective: <c>(a, b:c)</c> and <c>(a:b, c)</c> would produce one id and collapse two characters'
/// panes into one, which is this defect again in a rarer shape. Writing the owner's length in front
/// of it makes the encoding total and reversible: the digits up to the first colon give the length,
/// exactly that many characters are the owner, one more colon is consumed, and everything left is
/// the target — so distinct pairs cannot produce equal ids, whatever is in them.
/// </para>
/// <para>
/// It is legible on purpose rather than hashed. This id is a dictionary key, a value in
/// <c>config.json</c>, and the stem of a <c>RestoreLog</c> file name; a digest would be unambiguous
/// too and would make every one of those unreadable to whoever has to look at them, for no property
/// a reversible encoding does not already have. (The file name's own collision handling is
/// unchanged and unaffected: <c>RestoreLog</c> stores the full id in each file's header and refuses
/// a file whose header names a different window, so a CRC-32 clash on the stem costs one window's
/// log rather than mixing two.)
/// </para>
/// </summary>
/// <param name="sessionKey">The owning <c>world.character</c> session, or null for a window nobody owns.</param>
/// <param name="target">The capture target, which is also the window's title.</param>
public static string SpawnWindowId(string? sessionKey, string target)
{
ArgumentException.ThrowIfNullOrEmpty(target);
return sessionKey is null
? $"{SpawnPrefix}{Unowned}:{target}"
: $"{SpawnPrefix}{sessionKey.Length}:{sessionKey}:{target}";
}

/// <summary>
/// Reads a spawn window id back into the pair that made it. False when <paramref name="id"/> is not
/// one this build writes — including a spawn id from before the owner was in it, which is what
/// <c>ConfigurationMigrator</c>'s v4→v5 step upgrades.
/// </summary>
public static bool TryReadSpawnWindowId(string id, out string? sessionKey, out string target)
{
sessionKey = null;
target = string.Empty;
if (id is null || !id.StartsWith(SpawnPrefix, StringComparison.Ordinal))
{
return false;
}

var rest = id.AsSpan(SpawnPrefix.Length);
if (rest.StartsWith(Unowned + ":", StringComparison.Ordinal))
{
target = rest[(Unowned.Length + 1)..].ToString();
return target.Length > 0;
}

// NumberStyles.None: bare digits only, so a sign or surrounding space is not silently accepted
// into a field whose whole job is to say how many characters to take.
var separator = rest.IndexOf(':');
if (separator <= 0
|| !int.TryParse(rest[..separator], NumberStyles.None, CultureInfo.InvariantCulture, out var length))
{
return false;
}

// The owner's exact length, then the colon that closes it: anything shorter is not this
// encoding. Written as `<=` rather than `< length + 1` so a declared length of int.MaxValue
// cannot overflow the comparison into passing and then index off the end.
var owner = rest[(separator + 1)..];
if (owner.Length <= length || owner[length] != ':')
{
return false;
}

sessionKey = owner[..length].ToString();
target = owner[(length + 1)..].ToString();
return target.Length > 0;
}

/// <summary>
/// Records a line arriving in a window: increments its unread badge unless the window is
Expand Down
9 changes: 8 additions & 1 deletion src/SharpMUTerm.Tui/DemoScene.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ internal static class DemoScene
/// <summary>The world.character the demo resumes as focused/connected.</summary>
public const string ActiveSessionKey = "Aetherfall.Corvid";

/// <summary>
/// The demo's Chat spawn window. Spelt once, here, because a spawn window's id names its
/// <em>owner</em> as well as its target — the saved workspace, the snapshot's <c>spawn</c> view and
/// the scene's own backlog all have to mean the same window, and three hand-built ids would not.
/// </summary>
public static string ChatWindowId => Workspace.SpawnWindowId(ActiveSessionKey, "Chat");

public static AppConfiguration Build()
{
var config = new AppConfiguration();
Expand Down Expand Up @@ -243,7 +250,7 @@ private static void AddTriggerSets(AppConfiguration config)
/// </remarks>
private static WorkspaceState BuildLastSession()
{
var chatId = Workspace.SpawnWindowId("Chat");
var chatId = ChatWindowId;
return new WorkspaceState
{
Windows =
Expand Down
Loading
Loading