diff --git a/src/RioJoy.Core/Profiles/ConfigStore.cs b/src/RioJoy.Core/Profiles/ConfigStore.cs index 54cabb7..ff1d88b 100644 --- a/src/RioJoy.Core/Profiles/ConfigStore.cs +++ b/src/RioJoy.Core/Profiles/ConfigStore.cs @@ -52,4 +52,48 @@ public static class ConfigStore if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(path)); return File.Exists(path) ? Deserialize(File.ReadAllText(path)) : new AppConfig(); } + + /// + /// Deserialize a single-profile document (the shape shipped in a repo's + /// profiles/*.json) with the same serializer settings as the config. + /// + public static RioProfile DeserializeProfile(string json) + { + if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json)); + return JsonConvert.DeserializeObject(json, Options) + ?? throw new JsonSerializationException("Profile JSON deserialized to null."); + } + + /// + /// Merge the single-profile file at into the + /// config at (created with defaults if absent). + /// A profile with the same name (case-insensitive, the + /// convention) is replaced in place; + /// otherwise the profile is appended. All other config content is preserved. + /// + public static ProfileImportResult ImportProfile(string configPath, string profilePath) + { + string profileJson = File.ReadAllText(profilePath); + RioProfile profile = DeserializeProfile(profileJson); + // Name keys the merge, and RioProfile defaults it to "Unnamed" — so a + // document that never states one must be rejected, not merged under the + // class default. + if (Newtonsoft.Json.Linq.JObject.Parse(profileJson).Value("Name") is not { Length: > 0 }) + throw new JsonSerializationException($"Profile file '{profilePath}' has no Name."); + + AppConfig config = Load(configPath); + int existing = config.Profiles.FindIndex( + p => string.Equals(p.Name, profile.Name, StringComparison.OrdinalIgnoreCase)); + bool replaced = existing >= 0; + if (replaced) + config.Profiles[existing] = profile; + else + config.Profiles.Add(profile); + + Save(config, configPath); + return new ProfileImportResult(profile.Name!, replaced); + } } + +/// Outcome of . +public sealed record ProfileImportResult(string Name, bool Replaced); diff --git a/src/RioJoy.Tray/Program.cs b/src/RioJoy.Tray/Program.cs index 9079431..5fa5ae4 100644 --- a/src/RioJoy.Tray/Program.cs +++ b/src/RioJoy.Tray/Program.cs @@ -1,3 +1,5 @@ +using RioJoy.Core.Profiles; + namespace RioJoy.Tray; internal static class Program @@ -12,18 +14,63 @@ internal static class Program /// Entry point. RIOJoy runs as a background tray application with no main /// window: an ApplicationContext owns the NotifyIcon and the runtime, so the /// message loop stays alive while the only UI is the tray icon and its menu. + /// + /// --import-profile <profile.json> merges a single-profile + /// document (a repo's profiles/*.json) into this user's config and + /// exits without starting the tray. Output goes to stdout/stderr, which a + /// GUI-subsystem exe only delivers when redirected — check the exit code + /// (0 ok, 1 failed, 2 usage, 3 tray running) when scripting it. /// [STAThread] - private static void Main() + private static int Main(string[] args) { + if (args.Length >= 1 && string.Equals(args[0], "--import-profile", StringComparison.OrdinalIgnoreCase)) + return ImportProfile(args); + using var instance = new Mutex(initiallyOwned: true, SingleInstanceMutex, out bool createdNew); if (!createdNew) - return; // another RIOJoy is already running in this session + return 0; // another RIOJoy is already running in this session // net48 has no source-generated ApplicationConfiguration.Initialize(); // do the equivalent setup directly. Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new TrayApplicationContext()); + return 0; + } + + private static int ImportProfile(string[] args) + { + if (args.Length != 2) + { + Console.Error.WriteLine("usage: RioJoy.Tray --import-profile "); + return 2; + } + + // A running tray holds the config in memory and rewrites it on its own + // saves, which would silently discard this import — refuse instead. + // (OpenExisting + catch rather than TryOpenExisting: net40 lacks the Try form.) + try + { + using Mutex running = Mutex.OpenExisting(SingleInstanceMutex); + Console.Error.WriteLine("RIOJoy is running in this session - quit it from the tray menu, import, then relaunch."); + return 3; + } + catch (WaitHandleCannotBeOpenedException) + { + // not running — proceed + } + + try + { + ProfileImportResult result = ConfigStore.ImportProfile(TrayApplicationContext.ConfigPath, args[1]); + Console.WriteLine($"{(result.Replaced ? "Replaced" : "Added")} profile '{result.Name}' in {TrayApplicationContext.ConfigPath}"); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"import failed: {ex.Message}"); + return 1; + } } } diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs index deca113..cd04b55 100644 --- a/src/RioJoy.Tray/TrayApplicationContext.cs +++ b/src/RioJoy.Tray/TrayApplicationContext.cs @@ -17,7 +17,8 @@ namespace RioJoy.Tray; /// internal sealed class TrayApplicationContext : ApplicationContext { - private static readonly string ConfigPath = + // Internal so Program's --import-profile writes the same store the tray reads. + internal static readonly string ConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RIOJoy", "config.json"); private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1); diff --git a/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs b/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs index 8348948..bd9104e 100644 --- a/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs +++ b/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs @@ -138,6 +138,87 @@ public class ConfigStoreTests Assert.Equal(File.ReadAllBytes(shipped), File.ReadAllBytes(reference)); } + [Fact] + public void ImportProfile_ShippedDescentFile_IntoFreshConfig() + { + string configPath = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}", "config.json"); + try + { + string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json"); + ProfileImportResult result = ConfigStore.ImportProfile(configPath, shipped); + + Assert.Equal("Descent", result.Name); + Assert.False(result.Replaced); + + RioProfile p = Assert.Single(ConfigStore.Load(configPath).Profiles); + Assert.Equal("Descent", p.Name); + // The routing must survive the import round-trip intact — this is the + // exact payload a cabinet/bench install writes. + Assert.NotNull(p.AxisRouting); + Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive }, p.AxisRouting!.Z); + } + finally + { + string? dir = Path.GetDirectoryName(configPath); + if (dir is not null && Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void ImportProfile_ReplacesSameName_PreservesEverythingElse() + { + string configPath = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}", "config.json"); + try + { + ConfigStore.Save(new AppConfig + { + DefaultRioComPort = "COM7", + NeutralProfileName = "Desktop", + Profiles = + { + new RioProfile { Name = "Desktop" }, + new RioProfile { Name = "descent", PlasmaGreeting = "STALE" }, // case-insensitive match + }, + }, configPath); + + string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json"); + ProfileImportResult result = ConfigStore.ImportProfile(configPath, shipped); + Assert.True(result.Replaced); + + AppConfig back = ConfigStore.Load(configPath); + Assert.Equal("COM7", back.DefaultRioComPort); // untouched settings survive + Assert.Equal("Desktop", back.NeutralProfileName); + Assert.Equal(2, back.Profiles.Count); // replaced in place, not appended + RioProfile descent = Assert.IsType(back.FindProfile("Descent")); + Assert.Equal("DESCENT", descent.PlasmaGreeting); // stale profile fully overwritten + } + finally + { + string? dir = Path.GetDirectoryName(configPath); + if (dir is not null && Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void ImportProfile_NamelessProfile_Throws() + { + string dir = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + string profilePath = Path.Combine(dir, "nameless.json"); + File.WriteAllText(profilePath, "{\"PlasmaGreeting\":\"X\"}"); + Assert.Throws( + () => ConfigStore.ImportProfile(Path.Combine(dir, "config.json"), profilePath)); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + [Fact] public void Load_MissingFile_ReturnsDefaults() {