Add the Avalonia application and the station network
The entry window types contacts and colours them as they are typed; the log, check, bandmap, score and packet windows read the same session. Contacts are shared with the other stations of a multi-operator entry in N1MM's contact message, so an N1MM station on the same network sees them too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
117
src/Nonemm.App/Configuration/Settings.cs
Normal file
117
src/Nonemm.App/Configuration/Settings.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// What the program remembers between runs.
|
||||
public sealed record Settings
|
||||
{
|
||||
public string DatabasePath { get; init; } = "";
|
||||
|
||||
public int ContestNumber { get; init; }
|
||||
|
||||
public StoredStation Station { get; init; } = new();
|
||||
|
||||
public string ClusterHost { get; init; } = "";
|
||||
|
||||
public int ClusterPort { get; init; } = 7373;
|
||||
|
||||
public IReadOnlyList<string> ClusterCommands { get; init; } = [];
|
||||
|
||||
public string RigctldHost { get; init; } = "127.0.0.1";
|
||||
|
||||
public int RigctldPort { get; init; } = 4532;
|
||||
|
||||
public bool RadioEnabled { get; init; }
|
||||
|
||||
public bool ClusterEnabled { get; init; }
|
||||
|
||||
public string NetworkStationName { get; init; } = "";
|
||||
|
||||
public int NetworkPort { get; init; } = 12060;
|
||||
|
||||
public bool NetworkEnabled { get; init; }
|
||||
|
||||
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
|
||||
|
||||
public static Settings Load(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new Settings();
|
||||
}
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(File.ReadAllText(path), SettingsJson.Default.Settings)
|
||||
?? new Settings();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// a settings file that will not parse is replaced rather than
|
||||
// stopping the program before a contest
|
||||
return new Settings();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(string path) =>
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(this, SettingsJson.Default.Settings));
|
||||
}
|
||||
|
||||
/// The operator's station as it is stored, kept separate from `StationInfo` so
|
||||
/// the file format does not follow every change to the domain type.
|
||||
public sealed record StoredStation
|
||||
{
|
||||
public string Callsign { get; init; } = "";
|
||||
|
||||
public int CqZone { get; init; }
|
||||
|
||||
public int ItuZone { get; init; }
|
||||
|
||||
public string Continent { get; init; } = "";
|
||||
|
||||
public string CountryPrefix { get; init; } = "";
|
||||
|
||||
public string State { get; init; } = "";
|
||||
|
||||
public string Province { get; init; } = "";
|
||||
|
||||
public string ArrlSection { get; init; } = "";
|
||||
|
||||
public string GridSquare { get; init; } = "";
|
||||
|
||||
public string County { get; init; } = "";
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Power { get; init; } = "";
|
||||
|
||||
public string Club { get; init; } = "";
|
||||
|
||||
public int Check { get; init; }
|
||||
|
||||
public string Precedence { get; init; } = "A";
|
||||
|
||||
public StationInfo ToStationInfo() => new()
|
||||
{
|
||||
Callsign = Callsign,
|
||||
CqZone = CqZone,
|
||||
ItuZone = ItuZone,
|
||||
Continent = Continent,
|
||||
CountryPrefix = CountryPrefix,
|
||||
State = State,
|
||||
Province = Province,
|
||||
ArrlSection = ArrlSection,
|
||||
GridSquare = GridSquare,
|
||||
County = County,
|
||||
Name = Name,
|
||||
Power = Power,
|
||||
Club = Club,
|
||||
Check = Check,
|
||||
Precedence = Precedence,
|
||||
};
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(Settings))]
|
||||
[JsonSourceGenerationOptions(WriteIndented = true)]
|
||||
internal sealed partial class SettingsJson : JsonSerializerContext;
|
||||
59
src/Nonemm.App/Configuration/SupportFileDownloader.cs
Normal file
59
src/Nonemm.App/Configuration/SupportFileDownloader.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// Fetches the country file and the callsign database from where they are
|
||||
/// published, which is where N1MM fetches them from too. The file is checked
|
||||
/// that it parses as what it claims to be before it replaces the old one, so a
|
||||
/// site that answers with an apology page cannot cost an operator their
|
||||
/// multipliers mid-contest.
|
||||
public sealed class SupportFileDownloader
|
||||
{
|
||||
public const string CountryFileUrl = "https://www.country-files.com/cty/wl_cty.dat";
|
||||
public const string CallDatabaseUrl = "https://www.supercheckpartial.com/MASTER.SCP";
|
||||
|
||||
private readonly HttpClient http;
|
||||
|
||||
public SupportFileDownloader(HttpClient http) => this.http = http;
|
||||
|
||||
public Task<string> DownloadCountryFileAsync(string path, CancellationToken cancellation = default) =>
|
||||
DownloadAsync(CountryFileUrl, path, text => CountryFile.Parse(text).Entities.Count, "entities", cancellation);
|
||||
|
||||
public Task<string> DownloadCallDatabaseAsync(string path, CancellationToken cancellation = default) =>
|
||||
DownloadAsync(CallDatabaseUrl, path, text => CallDatabase.Parse(text).Count, "callsigns", cancellation);
|
||||
|
||||
private async Task<string> DownloadAsync(
|
||||
string url,
|
||||
string path,
|
||||
Func<string, int> check,
|
||||
string what,
|
||||
CancellationToken cancellation)
|
||||
{
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = await http.GetStringAsync(url, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException e)
|
||||
{
|
||||
throw new InvalidOperationException($"could not fetch {url}: {e.Message}", e);
|
||||
}
|
||||
|
||||
int count;
|
||||
try
|
||||
{
|
||||
count = check(text);
|
||||
}
|
||||
catch (FormatException e)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{url} did not answer with a usable file, so the old one is untouched: {e.Message}", e);
|
||||
}
|
||||
|
||||
string temporary = path + ".new";
|
||||
await File.WriteAllTextAsync(temporary, text, cancellation).ConfigureAwait(false);
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
return $"{count} {what}";
|
||||
}
|
||||
}
|
||||
47
src/Nonemm.App/Configuration/UserPaths.cs
Normal file
47
src/Nonemm.App/Configuration/UserPaths.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// Where the operator's files live. Windows keeps them under Documents the way
|
||||
/// N1MM does; everywhere else follows the XDG configuration directory.
|
||||
public sealed class UserPaths
|
||||
{
|
||||
public UserPaths(string root)
|
||||
{
|
||||
Root = root;
|
||||
Databases = Path.Combine(root, "Databases");
|
||||
UserDefinedContests = Path.Combine(root, "UserDefinedContests");
|
||||
SupportFiles = Path.Combine(root, "SupportFiles");
|
||||
}
|
||||
|
||||
public static UserPaths Default() => new(DefaultRoot());
|
||||
|
||||
public string Root { get; }
|
||||
|
||||
public string Databases { get; }
|
||||
|
||||
public string UserDefinedContests { get; }
|
||||
|
||||
public string SupportFiles { get; }
|
||||
|
||||
public string SettingsFile => Path.Combine(Root, "settings.json");
|
||||
|
||||
public string CountryFile => Path.Combine(SupportFiles, "wl_cty.dat");
|
||||
|
||||
public string CallDatabaseFile => Path.Combine(SupportFiles, "MASTER.SCP");
|
||||
|
||||
public void CreateFolders()
|
||||
{
|
||||
Directory.CreateDirectory(Root);
|
||||
Directory.CreateDirectory(Databases);
|
||||
Directory.CreateDirectory(UserDefinedContests);
|
||||
Directory.CreateDirectory(SupportFiles);
|
||||
}
|
||||
|
||||
private static string DefaultRoot() =>
|
||||
OperatingSystem.IsWindows()
|
||||
? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
"Nonemm")
|
||||
: Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"nonemm");
|
||||
}
|
||||
Reference in New Issue
Block a user