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()
{