Add the digital interface, running MMTTY under Wine
MMTTY and 2Tone have no socket or pipe interface: N1MM hosts XMMT.ocx and exchanges window messages with the engine. A Linux process cannot load that control, so bridge/nonemm-mmtty-bridge.exe hosts it under Wine and passes lines over its standard input and output. docs/digital-bridge.md states the protocol and what the control needs. The window is N1MM's: receive pane with coloured callsigns, grab list, call stacking, twenty-four macro buttons and the engine controls. One left click copies what is under it — a callsign to the callsign box, anything else to the exchange box the contest keeps for that kind of value. Config > Digital registers XMMT.ocx in the Wine prefix on its own, making the prefix first if it is not there. The engine, the bridge and the control start at the copies shipped beside the program. The bridge reads the control's own events. OnTranslateMessage carries only the messages the control has no event for, so nothing was ever decoded through it. hamlib's data mode names read as digital modes now, and a mode typed into the callsign box changes mode the way a frequency changes band, so a station with no radio can reach RTTY at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
20
src/Nonemm.Digital/BridgeChannel.cs
Normal file
20
src/Nonemm.Digital/BridgeChannel.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// The line connection to a bridge process. What is behind it is Wine in
|
||||
/// production and a fake in the tests.
|
||||
public interface BridgeChannel : IDisposable
|
||||
{
|
||||
/// A line the bridge wrote. Raised on the reader thread.
|
||||
event EventHandler<string>? LineReceived;
|
||||
|
||||
/// The bridge stopped on its own. The text says what is known about why.
|
||||
event EventHandler<string>? Failed;
|
||||
|
||||
Task StartAsync(CancellationToken cancellation = default);
|
||||
|
||||
Task SendAsync(string line, CancellationToken cancellation = default);
|
||||
|
||||
/// The path as the engine sees it. Wine has its own drive letters, and the
|
||||
/// engine is started by name from a Windows command line.
|
||||
Task<string> ToWindowsPathAsync(string path, CancellationToken cancellation = default);
|
||||
}
|
||||
58
src/Nonemm.Digital/BridgeLine.cs
Normal file
58
src/Nonemm.Digital/BridgeLine.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// One line of the bridge protocol: a verb, then its fields, separated by tabs
|
||||
/// and ended by a newline. Tab, newline and backslash inside a field are
|
||||
/// escaped, so a field never splits a line. The C++ bridge writes and reads the
|
||||
/// same format; `docs/digital-bridge.md` states it for both sides.
|
||||
public static class BridgeLine
|
||||
{
|
||||
public static string Write(string verb, params string[] fields) =>
|
||||
fields.Length == 0 ? verb : verb + "\t" + string.Join("\t", fields.Select(Escape));
|
||||
|
||||
public static (string Verb, string[] Fields) Read(string line)
|
||||
{
|
||||
string[] parts = line.Split('\t');
|
||||
return (parts[0], parts.Skip(1).Select(Unescape).ToArray());
|
||||
}
|
||||
|
||||
private static string Escape(string field)
|
||||
{
|
||||
StringBuilder escaped = new(field.Length);
|
||||
foreach (char c in field)
|
||||
{
|
||||
_ = c switch
|
||||
{
|
||||
'\\' => escaped.Append("\\\\"),
|
||||
'\t' => escaped.Append("\\t"),
|
||||
'\r' => escaped.Append("\\r"),
|
||||
'\n' => escaped.Append("\\n"),
|
||||
_ => escaped.Append(c),
|
||||
};
|
||||
}
|
||||
return escaped.ToString();
|
||||
}
|
||||
|
||||
private static string Unescape(string field)
|
||||
{
|
||||
StringBuilder plain = new(field.Length);
|
||||
for (int i = 0; i < field.Length; i++)
|
||||
{
|
||||
if (field[i] != '\\' || i + 1 == field.Length)
|
||||
{
|
||||
plain.Append(field[i]);
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
plain.Append(field[i] switch
|
||||
{
|
||||
't' => '\t',
|
||||
'r' => '\r',
|
||||
'n' => '\n',
|
||||
_ => field[i],
|
||||
});
|
||||
}
|
||||
return plain.ToString();
|
||||
}
|
||||
}
|
||||
31
src/Nonemm.Digital/DigitalEngine.cs
Normal file
31
src/Nonemm.Digital/DigitalEngine.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// A digital modem that decodes what is on the air and sends what is typed:
|
||||
/// MMTTY or 2Tone behind the Wine bridge today, fldigi later. The window above
|
||||
/// it works through this and does not know which one is running.
|
||||
public interface DigitalEngine : IDisposable
|
||||
{
|
||||
/// The modem is running and has told us its version.
|
||||
bool IsConnected { get; }
|
||||
|
||||
bool IsTransmitting { get; }
|
||||
|
||||
/// Text the modem has decoded, in the pieces it arrived in: usually one
|
||||
/// character. Raised on the reader thread, so a handler that touches the
|
||||
/// screen has to post.
|
||||
event EventHandler<string>? Received;
|
||||
|
||||
event EventHandler<bool>? TransmitChanged;
|
||||
|
||||
event EventHandler<bool>? ConnectionChanged;
|
||||
|
||||
/// Starts the modem and waits until it is connected. Throws if it does not
|
||||
/// come up.
|
||||
Task StartAsync(CancellationToken cancellation = default);
|
||||
|
||||
/// Puts the text in the transmit buffer, which starts the transmission.
|
||||
Task SendAsync(string text, CancellationToken cancellation = default);
|
||||
|
||||
/// Drops whatever has not gone out yet, which is what Escape does.
|
||||
Task AbortAsync(CancellationToken cancellation = default);
|
||||
}
|
||||
46
src/Nonemm.Digital/DigitalEngineSender.cs
Normal file
46
src/Nonemm.Digital/DigitalEngineSender.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Nonemm.Keying;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// The digital engine as something that sends a message, so a macro goes out
|
||||
/// through the same expander, the same `{END}` handling and the same ESM as a
|
||||
/// CW message does.
|
||||
public sealed class DigitalEngineSender : MessageSender
|
||||
{
|
||||
private readonly DigitalEngine engine;
|
||||
|
||||
public DigitalEngineSender(DigitalEngine engine)
|
||||
{
|
||||
this.engine = engine;
|
||||
engine.TransmitChanged += WhenTransmitChanged;
|
||||
}
|
||||
|
||||
public bool IsReady => engine.IsConnected;
|
||||
|
||||
/// The engine says when it has stopped transmitting, which is the same
|
||||
/// thing a keyer reports.
|
||||
public bool ReportsCompletion => true;
|
||||
|
||||
public event EventHandler? Finished;
|
||||
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
||||
engine.SendAsync(text, cancellation);
|
||||
|
||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||
engine.AbortAsync(cancellation);
|
||||
|
||||
/// RTTY runs at the speed the engine is set to, so there is nothing to set
|
||||
/// per message.
|
||||
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public void Dispose() => engine.TransmitChanged -= WhenTransmitChanged;
|
||||
|
||||
private void WhenTransmitChanged(object? sender, bool transmitting)
|
||||
{
|
||||
if (!transmitting)
|
||||
{
|
||||
Finished?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
220
src/Nonemm.Digital/MmttyEngine.cs
Normal file
220
src/Nonemm.Digital/MmttyEngine.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// MMTTY or 2Tone, started and driven through the bridge. The startup is
|
||||
/// N1MM's: the engine is given a title, the PTT port out of Mmtty.INI and a
|
||||
/// command line, and an engine that fails to initialise is started again up to
|
||||
/// ten times, which is what N1MM's retry does.
|
||||
public sealed class MmttyEngine : DigitalEngine
|
||||
{
|
||||
private const int StartAttempts = 10;
|
||||
|
||||
private readonly BridgeChannel bridge;
|
||||
private readonly MmttyOptions options;
|
||||
private readonly Channel<string> lines = Channel.CreateUnbounded<string>();
|
||||
private readonly TimeSpan startPatience;
|
||||
private TaskCompletionSource<bool>? starting;
|
||||
private MmttySettings settings = new();
|
||||
private string command = "";
|
||||
private int attempts;
|
||||
private Task? reading;
|
||||
|
||||
public MmttyEngine(BridgeChannel bridge, MmttyOptions options, TimeSpan? startPatience = null)
|
||||
{
|
||||
this.bridge = bridge;
|
||||
this.options = options;
|
||||
this.startPatience = startPatience ?? TimeSpan.FromSeconds(30);
|
||||
bridge.LineReceived += (_, line) => lines.Writer.TryWrite(line);
|
||||
bridge.Failed += (_, why) => Stopped(why);
|
||||
}
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public bool IsTransmitting { get; private set; }
|
||||
|
||||
/// The version MMTTY reported when it connected.
|
||||
public string Version { get; private set; } = "";
|
||||
|
||||
public int MarkHertz { get; private set; }
|
||||
|
||||
public int SpaceHertz { get; private set; }
|
||||
|
||||
/// MMTTY's AFC, net and reverse bits. Kept here because a toggle is sent as
|
||||
/// the whole word, so the current one has to be known.
|
||||
public int Switches { get; private set; }
|
||||
|
||||
public event EventHandler<string>? Received;
|
||||
|
||||
public event EventHandler<bool>? TransmitChanged;
|
||||
|
||||
public event EventHandler<bool>? ConnectionChanged;
|
||||
|
||||
/// Anything the bridge says about itself, for the status line.
|
||||
public event EventHandler<string>? Reported;
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
settings = MmttySettings.Read(options.SettingsPath);
|
||||
Switches = settings.Switches;
|
||||
await bridge.StartAsync(cancellation).ConfigureAwait(false);
|
||||
command = options.CommandLine(
|
||||
await bridge.ToWindowsPathAsync(options.EnginePath, cancellation).ConfigureAwait(false));
|
||||
starting = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
attempts = 1;
|
||||
reading ??= Task.Run(ReadAsync, CancellationToken.None);
|
||||
await OpenAsync(cancellation).ConfigureAwait(false);
|
||||
await starting.Task.WaitAsync(startPatience, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(BridgeLine.Write("send", text), cancellation);
|
||||
|
||||
/// One typed character, which MMTTY transmits as it arrives.
|
||||
public Task TypeAsync(char character, CancellationToken cancellation = default) =>
|
||||
PostAsync(MmttyMessage.TypeCharacter, character, cancellation);
|
||||
|
||||
/// N1MM's abort, which is what its RX button and Escape do: drop PTT and
|
||||
/// let the engine stop where it is.
|
||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||
SetPttAsync(false, cancellation);
|
||||
|
||||
/// N1MM's `{TX}` and `{RX}`. MMTTY sends what is in its buffer before it
|
||||
/// drops PTT, so a macro that ends with `{RX}` still goes out in full.
|
||||
public Task SetPttAsync(bool on, CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(BridgeLine.Write("ptt", on ? "1" : "0"), cancellation);
|
||||
|
||||
public Task ReturnToReceiveAsync(CancellationToken cancellation = default) =>
|
||||
SetPttAsync(false, cancellation);
|
||||
|
||||
/// Moves the demodulator, keeping the shift the engine reported.
|
||||
public async Task TuneAsync(int markHertz, CancellationToken cancellation = default)
|
||||
{
|
||||
int shift = SpaceHertz - MarkHertz;
|
||||
await PostAsync(MmttyMessage.MarkFrequency, markHertz, cancellation).ConfigureAwait(false);
|
||||
await PostAsync(MmttyMessage.SpaceFrequency, markHertz + shift, cancellation)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task SetSwitchesAsync(int switches, CancellationToken cancellation = default) =>
|
||||
PostAsync(MmttyMessage.Switches, switches, cancellation);
|
||||
|
||||
public Task PostAsync(MmttyMessage message, int parameter, CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(
|
||||
BridgeLine.Write("post", ((int)message).ToString(), parameter.ToString()),
|
||||
cancellation);
|
||||
|
||||
/// Shuts the engine down and stops the bridge.
|
||||
public async Task StopAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
await bridge.SendAsync(BridgeLine.Write("quit"), cancellation).ConfigureAwait(false);
|
||||
if (reading is not null)
|
||||
{
|
||||
lines.Writer.TryComplete();
|
||||
await reading.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lines.Writer.TryComplete();
|
||||
bridge.Dispose();
|
||||
}
|
||||
|
||||
private Task OpenAsync(CancellationToken cancellation) =>
|
||||
bridge.SendAsync(
|
||||
BridgeLine.Write("open", options.Title, PttPort(), command),
|
||||
cancellation);
|
||||
|
||||
private string PttPort() => options.PttPort ?? settings.PttPort;
|
||||
|
||||
private async Task ReadAsync()
|
||||
{
|
||||
await foreach (string line in lines.Reader.ReadAllAsync().ConfigureAwait(false))
|
||||
{
|
||||
(string verb, string[] fields) = BridgeLine.Read(line);
|
||||
await ActOnAsync(verb, fields).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ActOnAsync(string verb, string[] fields)
|
||||
{
|
||||
switch (verb)
|
||||
{
|
||||
case "connected":
|
||||
Version = Field(fields, 0);
|
||||
IsConnected = true;
|
||||
starting?.TrySetResult(true);
|
||||
ConnectionChanged?.Invoke(this, true);
|
||||
break;
|
||||
case "disconnected":
|
||||
await DisconnectedAsync(Number(fields, 0)).ConfigureAwait(false);
|
||||
break;
|
||||
case "rx":
|
||||
Received?.Invoke(this, ((char)Number(fields, 0)).ToString());
|
||||
break;
|
||||
case "tx":
|
||||
IsTransmitting = Number(fields, 0) == 1;
|
||||
TransmitChanged?.Invoke(this, IsTransmitting);
|
||||
break;
|
||||
case "mark":
|
||||
MarkHertz = Number(fields, 0);
|
||||
break;
|
||||
case "space":
|
||||
SpaceHertz = Number(fields, 0);
|
||||
break;
|
||||
case "switch":
|
||||
Switches = Number(fields, 0);
|
||||
break;
|
||||
case "error":
|
||||
Stopped(Field(fields, 0));
|
||||
break;
|
||||
case "log":
|
||||
Reported?.Invoke(this, Field(fields, 0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Status 2 is MMTTY failing to come up, which happens often enough that
|
||||
/// N1MM starts it again rather than telling the operator.
|
||||
private async Task DisconnectedAsync(int status)
|
||||
{
|
||||
if (status != 2)
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionChanged?.Invoke(this, false);
|
||||
return;
|
||||
}
|
||||
if (attempts >= StartAttempts)
|
||||
{
|
||||
Stopped($"{options.EnginePath} failed to start after {attempts} tries");
|
||||
return;
|
||||
}
|
||||
attempts++;
|
||||
Reported?.Invoke(this, $"the engine failed to start, trying again ({attempts})");
|
||||
await OpenAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void Stopped(string why)
|
||||
{
|
||||
if (starting?.TrySetException(new InvalidOperationException(why)) == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (IsConnected)
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionChanged?.Invoke(this, false);
|
||||
}
|
||||
Reported?.Invoke(this, why);
|
||||
}
|
||||
|
||||
private static string Field(string[] fields, int index) =>
|
||||
index < fields.Length ? fields[index] : "";
|
||||
|
||||
private static int Number(string[] fields, int index) =>
|
||||
int.TryParse(Field(fields, index), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
17
src/Nonemm.Digital/MmttyMessage.cs
Normal file
17
src/Nonemm.Digital/MmttyMessage.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// What can be posted to MMTTY, from N1MM's use of the OCX. Message 0 hands the
|
||||
/// engine the host window it reports back to, and the bridge posts that itself
|
||||
/// as part of starting up.
|
||||
public enum MmttyMessage
|
||||
{
|
||||
Shutdown = 2,
|
||||
TypeCharacter = 4,
|
||||
Rate = 8,
|
||||
MarkFrequency = 9,
|
||||
SpaceFrequency = 10,
|
||||
Switches = 11,
|
||||
DefaultProfile = 12,
|
||||
Setup = 13,
|
||||
Profile = 24,
|
||||
}
|
||||
66
src/Nonemm.Digital/MmttyOptions.cs
Normal file
66
src/Nonemm.Digital/MmttyOptions.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// MMTTY's four window sizes. The letter is the command line switch the engine
|
||||
/// is started with.
|
||||
public enum EngineWindow
|
||||
{
|
||||
Normal,
|
||||
Small,
|
||||
Medium1,
|
||||
Medium2,
|
||||
}
|
||||
|
||||
/// What the engine needs to start: which program, which window, and which
|
||||
/// serial port keys it. The path is a Linux path; the bridge is given the
|
||||
/// Windows form of it.
|
||||
public sealed record MmttyOptions
|
||||
{
|
||||
public required string EnginePath { get; init; }
|
||||
|
||||
/// 1 or 2. A two-radio station runs an engine for each, and the second one
|
||||
/// must not answer to the first one's window title.
|
||||
public int Number { get; init; } = 1;
|
||||
|
||||
public EngineWindow Window { get; init; } = EngineWindow.Normal;
|
||||
|
||||
public bool OnTop { get; init; } = true;
|
||||
|
||||
/// The port MMTTY keys FSK and PTT on. Left null, it is read from Mmtty.INI
|
||||
/// beside the program, which is where MMTTY itself keeps it.
|
||||
public string? PttPort { get; init; }
|
||||
|
||||
/// 2Tone names its own window and takes none of MMTTY's switches except the
|
||||
/// window size.
|
||||
public bool IsTwoTone =>
|
||||
Path.GetFileName(EnginePath).Equals("2tone.exe", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// What the OCX is told to run. The engine has to be quoted: it usually
|
||||
/// sits under Program Files.
|
||||
public string CommandLine(string windowsEnginePath)
|
||||
{
|
||||
string line = $"\"{windowsEnginePath}\"{WindowSwitch()}";
|
||||
if (!OnTop)
|
||||
{
|
||||
line += " -a";
|
||||
}
|
||||
return IsTwoTone ? line : line + " -Z";
|
||||
}
|
||||
|
||||
/// The title the OCX gives the engine window. 2Tone titles its own window
|
||||
/// and ignores this.
|
||||
public string Title => $"RTTY Engine {Number}";
|
||||
|
||||
/// The window to look for once the engine is up.
|
||||
public string WindowTitle => IsTwoTone ? $"DI{Number} G3YYD 2Tone" : Title;
|
||||
|
||||
public string SettingsPath =>
|
||||
Path.Combine(Path.GetDirectoryName(EnginePath) ?? ".", "Mmtty.INI");
|
||||
|
||||
private string WindowSwitch() => Window switch
|
||||
{
|
||||
EngineWindow.Small => " -t",
|
||||
EngineWindow.Medium1 => " -s",
|
||||
EngineWindow.Medium2 => " -u",
|
||||
_ => " -r",
|
||||
};
|
||||
}
|
||||
63
src/Nonemm.Digital/MmttySettings.cs
Normal file
63
src/Nonemm.Digital/MmttySettings.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// The part of Mmtty.INI that the logger has to agree with. MMTTY keeps AFC,
|
||||
/// net and reverse in the file and reports them back as switch bits, so the
|
||||
/// starting state has to be read from the same place or the first toggle moves
|
||||
/// the wrong way.
|
||||
public sealed record MmttySettings
|
||||
{
|
||||
public const int AfcBit = 4;
|
||||
public const int NetBit = 8;
|
||||
public const int ReverseBit = 256;
|
||||
|
||||
public string PttPort { get; init; } = "";
|
||||
|
||||
public bool IsAfcOn { get; init; }
|
||||
|
||||
public bool IsNetOn { get; init; }
|
||||
|
||||
public bool IsReversed { get; init; }
|
||||
|
||||
/// The switch word MMTTY starts with.
|
||||
public int Switches =>
|
||||
(IsAfcOn ? AfcBit : 0) | (IsNetOn ? NetBit : 0) | (IsReversed ? ReverseBit : 0);
|
||||
|
||||
/// Reads the `[Define]` section. A file that is not there is not an error:
|
||||
/// MMTTY writes it when it first exits, so a new installation has none.
|
||||
public static MmttySettings Read(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new MmttySettings();
|
||||
}
|
||||
Dictionary<string, string> define = ReadSection(path, "Define");
|
||||
return new MmttySettings
|
||||
{
|
||||
PttPort = define.GetValueOrDefault("PTT", ""),
|
||||
IsAfcOn = define.GetValueOrDefault("AFC") == "1",
|
||||
IsNetOn = define.GetValueOrDefault("TxNet") == "1",
|
||||
IsReversed = define.GetValueOrDefault("Rev") == "1",
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadSection(string path, string section)
|
||||
{
|
||||
Dictionary<string, string> values = new(StringComparer.OrdinalIgnoreCase);
|
||||
bool inSection = false;
|
||||
foreach (string line in File.ReadLines(path))
|
||||
{
|
||||
string text = line.Trim();
|
||||
if (text.StartsWith('[') && text.EndsWith(']'))
|
||||
{
|
||||
inSection = text[1..^1].Equals(section, StringComparison.OrdinalIgnoreCase);
|
||||
continue;
|
||||
}
|
||||
int equals = text.IndexOf('=');
|
||||
if (inSection && equals > 0)
|
||||
{
|
||||
values[text[..equals].Trim()] = text[(equals + 1)..].Trim();
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
13
src/Nonemm.Digital/Nonemm.Digital.csproj
Normal file
13
src/Nonemm.Digital/Nonemm.Digital.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Nonemm.Keying\Nonemm.Keying.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
144
src/Nonemm.Digital/WineBridgeChannel.cs
Normal file
144
src/Nonemm.Digital/WineBridgeChannel.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// Runs the bridge under Wine and talks to it over its standard input and
|
||||
/// output. Wine writes its own diagnostics to standard error, so the protocol
|
||||
/// has standard output to itself.
|
||||
public sealed class WineBridgeChannel : BridgeChannel
|
||||
{
|
||||
private const int KeptErrorLines = 20;
|
||||
|
||||
private readonly string wine;
|
||||
private readonly string bridgePath;
|
||||
private readonly string? prefix;
|
||||
private readonly Queue<string> lastErrors = new();
|
||||
private readonly SemaphoreSlim writing = new(1, 1);
|
||||
private Process? bridge;
|
||||
|
||||
/// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default,
|
||||
/// which is what a station with one prefix wants.
|
||||
public WineBridgeChannel(string bridgePath, string? prefix = null, string wine = "wine")
|
||||
{
|
||||
this.bridgePath = bridgePath;
|
||||
this.prefix = prefix;
|
||||
this.wine = wine;
|
||||
}
|
||||
|
||||
public event EventHandler<string>? LineReceived;
|
||||
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
public Task StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
bridge = Start(bridgePath);
|
||||
_ = ReadOutputAsync(bridge);
|
||||
_ = ReadErrorsAsync(bridge);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task SendAsync(string line, CancellationToken cancellation = default)
|
||||
{
|
||||
Process running = bridge ?? throw new InvalidOperationException("the bridge is not started");
|
||||
await writing.WaitAsync(cancellation).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await running.StandardInput.WriteLineAsync(line.AsMemory(), cancellation)
|
||||
.ConfigureAwait(false);
|
||||
await running.StandardInput.FlushAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
writing.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> ToWindowsPathAsync(
|
||||
string path,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
using Process winepath = Start("winepath", "-w", path);
|
||||
string converted = await winepath.StandardOutput.ReadToEndAsync(cancellation)
|
||||
.ConfigureAwait(false);
|
||||
await winepath.WaitForExitAsync(cancellation).ConfigureAwait(false);
|
||||
if (winepath.ExitCode != 0 || converted.Trim().Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"winepath -w {path} failed with exit code {winepath.ExitCode}");
|
||||
}
|
||||
return converted.Trim();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (bridge is { HasExited: false })
|
||||
{
|
||||
bridge.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// the bridge stopped on its own, so there is nothing left to kill
|
||||
}
|
||||
bridge?.Dispose();
|
||||
writing.Dispose();
|
||||
}
|
||||
|
||||
private Process Start(string program, params string[] arguments)
|
||||
{
|
||||
ProcessStartInfo start = new()
|
||||
{
|
||||
FileName = wine,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
start.ArgumentList.Add(program);
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
if (prefix is not null)
|
||||
{
|
||||
start.Environment["WINEPREFIX"] = prefix;
|
||||
}
|
||||
return Process.Start(start)
|
||||
?? throw new InvalidOperationException($"{wine} {program} did not start");
|
||||
}
|
||||
|
||||
private async Task ReadOutputAsync(Process running)
|
||||
{
|
||||
while (await running.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line)
|
||||
{
|
||||
LineReceived?.Invoke(this, line);
|
||||
}
|
||||
await running.WaitForExitAsync().ConfigureAwait(false);
|
||||
Failed?.Invoke(this, $"the bridge exited with code {running.ExitCode}: {ErrorTail()}");
|
||||
}
|
||||
|
||||
private async Task ReadErrorsAsync(Process running)
|
||||
{
|
||||
while (await running.StandardError.ReadLineAsync().ConfigureAwait(false) is { } line)
|
||||
{
|
||||
lock (lastErrors)
|
||||
{
|
||||
lastErrors.Enqueue(line);
|
||||
if (lastErrors.Count > KeptErrorLines)
|
||||
{
|
||||
lastErrors.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string ErrorTail()
|
||||
{
|
||||
lock (lastErrors)
|
||||
{
|
||||
return string.Join(" / ", lastErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/Nonemm.Digital/WinePrefixSetup.cs
Normal file
106
src/Nonemm.Digital/WinePrefixSetup.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// Puts XMMT.ocx into a Wine prefix and registers it, which is what the bridge
|
||||
/// needs before it can create the control. The steps are the ones in
|
||||
/// docs/digital-bridge.md: make the prefix if it is not there, copy the control
|
||||
/// into the Windows folder, run regsvr32.
|
||||
public sealed class WinePrefixSetup
|
||||
{
|
||||
private readonly string? prefix;
|
||||
private readonly string wine;
|
||||
|
||||
/// `prefix` is the WINEPREFIX to set up. Left null, Wine uses its default.
|
||||
public WinePrefixSetup(string? prefix = null, string wine = "wine")
|
||||
{
|
||||
this.prefix = prefix;
|
||||
this.wine = wine;
|
||||
}
|
||||
|
||||
/// Where a 32-bit control goes. A 64-bit prefix keeps 32-bit code in
|
||||
/// syswow64 and has a second regsvr32 there to register it with; the
|
||||
/// 64-bit one cannot load the control at all.
|
||||
public static (string Folder, string Regsvr32) Places(bool isSixtyFourBit) =>
|
||||
isSixtyFourBit
|
||||
? (@"C:\windows\syswow64", @"C:\windows\syswow64\regsvr32.exe")
|
||||
: (@"C:\windows\system32", @"C:\windows\system32\regsvr32.exe");
|
||||
|
||||
/// Copies the control in and registers it. It returns what to show the
|
||||
/// operator, and throws when a step fails.
|
||||
public async Task<string> RegisterAsync(
|
||||
string controlPath,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
if (!File.Exists(controlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"{controlPath} is not there", controlPath);
|
||||
}
|
||||
if (prefix is not null && !Directory.Exists(prefix))
|
||||
{
|
||||
await RunAsync("wineboot", ["-u"], cancellation, creating: true).ConfigureAwait(false);
|
||||
}
|
||||
string windows = await ToLinuxPathAsync(@"C:\windows", cancellation).ConfigureAwait(false);
|
||||
(string folder, string regsvr32) = Places(Directory.Exists(Path.Combine(windows, "syswow64")));
|
||||
string target = Path.Combine(
|
||||
await ToLinuxPathAsync(folder, cancellation).ConfigureAwait(false),
|
||||
"XMMT.ocx");
|
||||
File.Copy(controlPath, target, overwrite: true);
|
||||
await RunAsync(regsvr32, [$@"{folder}\XMMT.ocx"], cancellation).ConfigureAwait(false);
|
||||
return $"XMMT.ocx registered in {folder}";
|
||||
}
|
||||
|
||||
private async Task<string> ToLinuxPathAsync(string path, CancellationToken cancellation)
|
||||
{
|
||||
string converted = await RunAsync("winepath", ["-u", path], cancellation)
|
||||
.ConfigureAwait(false);
|
||||
return converted.Trim().Length > 0
|
||||
? converted.Trim()
|
||||
: throw new InvalidOperationException($"winepath -u {path} said nothing");
|
||||
}
|
||||
|
||||
/// Wine writes its own diagnostics to standard error and says nothing on
|
||||
/// standard output unless the program does, so a failure is reported with
|
||||
/// the error text.
|
||||
private async Task<string> RunAsync(
|
||||
string program,
|
||||
string[] arguments,
|
||||
CancellationToken cancellation,
|
||||
bool creating = false)
|
||||
{
|
||||
ProcessStartInfo start = new()
|
||||
{
|
||||
FileName = wine,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
start.ArgumentList.Add(program);
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
if (prefix is not null)
|
||||
{
|
||||
start.Environment["WINEPREFIX"] = prefix;
|
||||
}
|
||||
if (creating)
|
||||
{
|
||||
// only on a prefix that is being made: Wine stops if it is set on
|
||||
// a 64-bit prefix that is already there
|
||||
start.Environment["WINEARCH"] = "win32";
|
||||
}
|
||||
using Process running = Process.Start(start)
|
||||
?? throw new InvalidOperationException($"{wine} {program} did not start");
|
||||
Task<string> output = running.StandardOutput.ReadToEndAsync(cancellation);
|
||||
Task<string> errors = running.StandardError.ReadToEndAsync(cancellation);
|
||||
await running.WaitForExitAsync(cancellation).ConfigureAwait(false);
|
||||
if (running.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{program} {string.Join(' ', arguments)} failed with exit code "
|
||||
+ $"{running.ExitCode}: {(await errors.ConfigureAwait(false)).Trim()}");
|
||||
}
|
||||
return await output.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user