tray: --import-profile merges a profile document into the user config

RioJoy.Tray --import-profile <profile.json> merges a single-profile
document (a repo's profiles/*.json) into %APPDATA%\RIOJoy\config.json
via ConfigStore.ImportProfile: same-name profiles are replaced in place
(FindProfile's case-insensitive convention), everything else appended,
all other config content preserved. Refuses (exit 3) while a tray
instance is running - a live tray rewrites the config from memory and
would silently discard the import. Documents that a Name must be stated
in the file itself: RioProfile defaults Name to 'Unnamed', so the import
checks the raw JSON, not the deserialized object. This is the profile
install story for bench machines and pods (previously: hand-paste into
the Profiles array).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-30 09:33:25 -05:00
co-authored by Claude Fable 5
parent 6967e66837
commit 3512c89dca
4 changed files with 176 additions and 3 deletions
+44
View File
@@ -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();
}
/// <summary>
/// Deserialize a single-profile document (the shape shipped in a repo's
/// <c>profiles/*.json</c>) with the same serializer settings as the config.
/// </summary>
public static RioProfile DeserializeProfile(string json)
{
if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
return JsonConvert.DeserializeObject<RioProfile>(json, Options)
?? throw new JsonSerializationException("Profile JSON deserialized to null.");
}
/// <summary>
/// Merge the single-profile file at <paramref name="profilePath"/> into the
/// config at <paramref name="configPath"/> (created with defaults if absent).
/// A profile with the same name (case-insensitive, the
/// <see cref="AppConfig.FindProfile"/> convention) is replaced in place;
/// otherwise the profile is appended. All other config content is preserved.
/// </summary>
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<string>("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);
}
}
/// <summary>Outcome of <see cref="ConfigStore.ImportProfile"/>.</summary>
public sealed record ProfileImportResult(string Name, bool Replaced);
+49 -2
View File
@@ -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.
///
/// <para><c>--import-profile &lt;profile.json&gt;</c> merges a single-profile
/// document (a repo's <c>profiles/*.json</c>) 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.</para>
/// </summary>
[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 <profile.json>");
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;
}
}
}
+2 -1
View File
@@ -17,7 +17,8 @@ namespace RioJoy.Tray;
/// </summary>
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);
@@ -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<RioProfile>(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<Newtonsoft.Json.JsonSerializationException>(
() => ConfigStore.ImportProfile(Path.Combine(dir, "config.json"), profilePath));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void Load_MissingFile_ReturnsDefaults()
{