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:
@@ -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);
|
||||
|
||||
@@ -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 <profile.json></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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user