using System.Text.Json; using System.Text.Json.Serialization; using AcDream.Launcher.Core.Profiles; using AcDream.Platform; namespace AcDream.Launcher.Core.Launching; /// The composed session-config document plus the per-launch /// paths derived from the session id, per Campaign LA spec §6. /// is launcher-internal (fix #406 sibling /// gap) — it never appears in the written session.json, only in /// the the launcher spawns the /// child with. public sealed record ComposedSessionConfig( string SessionId, string ConfigFilePath, string StatusFilePath, string StderrLogPath, SessionConfigDocument Document); /// /// Injectable composition/write seam used by the canonical launcher /// orchestrator. Production delegates to ; /// tests can capture the exact request without writing a file or starting a /// client process. /// public interface ILauncherSessionConfigService { ComposedSessionConfig ComposeAndWrite( ServerProfile server, AccountProfile account, CharacterProfile character, LauncherInstallRecord install, ApplicationPathSet paths, string sessionId, int? loginCommandDelayMs = null); ComposedSessionConfig ComposeProbeAndWrite( ServerProfile server, AccountProfile account, LauncherInstallRecord install, ApplicationPathSet paths, string sessionId); } public sealed class LauncherSessionConfigService : ILauncherSessionConfigService { public ComposedSessionConfig ComposeAndWrite( ServerProfile server, AccountProfile account, CharacterProfile character, LauncherInstallRecord install, ApplicationPathSet paths, string sessionId, int? loginCommandDelayMs = null) => SessionConfigComposer.ComposeAndWrite( server, account, character, install, paths, sessionId, loginCommandDelayMs); public ComposedSessionConfig ComposeProbeAndWrite( ServerProfile server, AccountProfile account, LauncherInstallRecord install, ApplicationPathSet paths, string sessionId) => SessionConfigComposer.ComposeProbeAndWrite( server, account, install, paths, sessionId); } /// /// Builds the per-launch from a /// profile character + install record (Campaign LA spec §6). Passwords /// NEVER appear in the composed document — the credential is always the /// standardInput provider; the launcher feeds the password to the /// child process's stdin separately (). /// public static class SessionConfigComposer { internal static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = true, }; /// /// Builds the document and the paths it would be written to under /// <CacheDirectory>/launcher/sessions/<sessionId>/, /// without touching disk. is caller- /// supplied so composition stays a pure function of its inputs /// (golden-file tests pass a fixed id). /// public static ComposedSessionConfig Compose( ServerProfile server, AccountProfile account, CharacterProfile character, LauncherInstallRecord install, ApplicationPathSet paths, string sessionId, int? loginCommandDelayMs = null) { ArgumentNullException.ThrowIfNull(server); ArgumentNullException.ThrowIfNull(account); ArgumentNullException.ThrowIfNull(character); ArgumentNullException.ThrowIfNull(install); ArgumentNullException.ThrowIfNull(paths); ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); (string configFilePath, string statusFilePath, string stderrLogPath) = BuildSessionPaths(paths, sessionId); SessionCharacterSelector? selector = character.LaunchMode == LaunchMode.GuiSelect ? null : BuildSelector(character); SessionPolicyDescriptor? policy = character.LaunchMode == LaunchMode.Headless ? new SessionPolicyDescriptor() : null; var descriptor = new SessionDescriptor { Id = sessionId, Endpoint = new SessionEndpointDescriptor { Host = server.Host, Port = server.Port, }, Account = account.Account, Character = selector, Policy = policy, Credential = new SessionCredentialDescriptor(), Plugins = ComposePluginAllowList(character.Plugins), LoginCommands = character.LoginCommands.Count > 0 ? [.. character.LoginCommands] : null, LoginCommandDelayMs = loginCommandDelayMs, StatusFile = statusFilePath, }; var document = new SessionConfigDocument { Process = new SessionProcessSettings { Content = new SessionContentDescriptor { DatDirectory = install.DatDirectory, PreparedAssetPath = install.PreparedAssetPath, }, }, Sessions = [descriptor], }; return new ComposedSessionConfig( sessionId, configFilePath, statusFilePath, stderrLogPath, document); } /// /// Builds a probe session-config document (Campaign LA plan §LA2/ /// §LA3 review finding F2): the session carries mode: "probe", /// no character selector, and no policy — the host /// reports the account's character roster over the status stream and /// exits without entering the world. Probes carry an explicit empty /// plugins allow-list so a plugin installed on the machine cannot /// run merely because the probe has no character-level plugin settings. /// public static ComposedSessionConfig ComposeProbe( ServerProfile server, AccountProfile account, LauncherInstallRecord install, ApplicationPathSet paths, string sessionId) { ArgumentNullException.ThrowIfNull(server); ArgumentNullException.ThrowIfNull(account); ArgumentNullException.ThrowIfNull(install); ArgumentNullException.ThrowIfNull(paths); ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); (string configFilePath, string statusFilePath, string stderrLogPath) = BuildSessionPaths(paths, sessionId); var descriptor = new SessionDescriptor { Id = sessionId, Mode = "probe", Endpoint = new SessionEndpointDescriptor { Host = server.Host, Port = server.Port, }, Account = account.Account, Character = null, Policy = null, Credential = new SessionCredentialDescriptor(), Plugins = [], LoginCommands = null, LoginCommandDelayMs = null, StatusFile = statusFilePath, }; var document = new SessionConfigDocument { Process = new SessionProcessSettings { Content = new SessionContentDescriptor { DatDirectory = install.DatDirectory, PreparedAssetPath = install.PreparedAssetPath, }, }, Sessions = [descriptor], }; return new ComposedSessionConfig( sessionId, configFilePath, statusFilePath, stderrLogPath, document); } /// Composes and writes session.json to /// , creating the /// per-session directory. The status file itself is created by the /// launched host, not the launcher. public static ComposedSessionConfig ComposeAndWrite( ServerProfile server, AccountProfile account, CharacterProfile character, LauncherInstallRecord install, ApplicationPathSet paths, string sessionId, int? loginCommandDelayMs = null) { ComposedSessionConfig composed = Compose( server, account, character, install, paths, sessionId, loginCommandDelayMs); return Write(composed); } /// Probe counterpart to . It /// writes the pinned mode: "probe" document and never includes /// a character selector, policy, plugin set, login commands, or password. /// public static ComposedSessionConfig ComposeProbeAndWrite( ServerProfile server, AccountProfile account, LauncherInstallRecord install, ApplicationPathSet paths, string sessionId) => Write(ComposeProbe(server, account, install, paths, sessionId)); /// /// Maps a character's configured plugin ids to the session config's /// allow-list. /// /// /// /// LA5 distinguishes an OMITTED allow-list (load every discovered plugin) /// from an explicit EMPTY one (load none). A brand-new character profile /// starts with an empty list, which meant a client that ships plugins /// loaded none of them until the user typed an id — "ships with the /// client" and "works out of the box" were different things, and the /// difference was invisible: nothing was logged, the panel simply never /// appeared. /// /// /// So "nothing configured" now maps to omitted — plugins are on by /// default, including ones installed later. The opt-out is kept, because /// losing it would be a real regression for anyone running a stripped /// session: the literal id none maps to the explicit empty list. /// /// private static List? ComposePluginAllowList(IReadOnlyList configured) { if (configured.Count == 0) return null; // default: load all if (configured.Count == 1 && string.Equals(configured[0], "none", StringComparison.OrdinalIgnoreCase)) { return []; // explicit: load none } return [.. configured]; } private static ComposedSessionConfig Write(ComposedSessionConfig composed) { string? directory = Path.GetDirectoryName(composed.ConfigFilePath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } using FileStream stream = File.Create(composed.ConfigFilePath); JsonSerializer.Serialize(stream, composed.Document, SerializerOptions); return composed; } /// Serializes the composed document exactly as /// would write it — used by golden-file /// tests that assert on the JSON text without touching disk. public static string Serialize(SessionConfigDocument document) => JsonSerializer.Serialize(document, SerializerOptions); private static (string ConfigFilePath, string StatusFilePath, string StderrLogPath) BuildSessionPaths( ApplicationPathSet paths, string sessionId) { string sessionDirectory = Path.Combine( paths.CacheDirectory, "launcher", "sessions", sessionId); return ( Path.Combine(sessionDirectory, "session.json"), Path.Combine(sessionDirectory, "status.jsonl"), // fix #406 sibling gap: lives beside status.jsonl in the same // per-session directory. Path.Combine(sessionDirectory, "client.err.log")); } private static SessionCharacterSelector BuildSelector(CharacterProfile character) { // A parsed id of 0 is not a usable selector — both host loaders // (App/Headless) reject `id: 0` outright, so falling through to // the name selector here is the only shape that reaches a real // character (Campaign LA plan §LA3 review finding F10). if (CharacterIdFormat.TryParse(character.Id, out uint id) && id != 0) { return new SessionCharacterSelector { Id = id }; } if (!string.IsNullOrWhiteSpace(character.Name)) { return new SessionCharacterSelector { Name = character.Name }; } throw new InvalidOperationException( "Character has neither a usable id nor a name to select by."); } }