Run the digital bridge on Windows

The bridge launched its helper only through Wine. It now starts any
process, so on Windows it runs the helper directly. WineBridgeChannel is
renamed ProcessBridgeChannel.

WindowsControlSetup sets up the control side on Windows. The digital
dialog, AppSession, README and docs/digital-bridge.md describe the
Windows setup. publish-windows.sh builds a Windows release.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 21:56:47 +00:00
co-authored by Claude Sonnet 5.5
parent 0ad894bfae
commit c18d79ba5a
11 changed files with 211 additions and 27 deletions
+1
View File
@@ -7,3 +7,4 @@ obj/
/mmtty/ /mmtty/
/bridge/XMMT.ocx /bridge/XMMT.ocx
engine-probe.log engine-probe.log
/dist/
+34 -4
View File
@@ -40,6 +40,36 @@ Everything that judges a station — the bar under the callsign box, a row in th
check window, a spot on the bandmap, a line in the log — is coloured by the same check window, a spot on the bandmap, a line in the log — is coloured by the same
scorer: red for a dupe, green for a new multiplier, blue for points. scorer: red for a dupe, green for a new multiplier, blue for points.
## Windows
Build the Windows copy on any machine that has the .NET 10 SDK:
```sh
./publish-windows.sh # writes dist/nonemm-win-x64/ and dist/nonemm-win-x64.zip
```
On Windows itself, run this from PowerShell instead:
```powershell
dotnet publish src\Nonemm.App -c Release -r win-x64 --self-contained -o dist\nonemm-win-x64
```
The result needs no .NET install. Unzip it anywhere you can write to and run
`Nonemm.App.exe`. Files go in `Documents\N1MM Logger+` and `Documents\Nonemm`,
or in the folder `NONEMM_HOME` names.
What differs from Linux:
| Part | On Windows |
| --- | --- |
| MMTTY and 2Tone | The bridge starts directly, with no Wine. Register `bridge\XMMT.ocx` once with the **Register** button in the digital settings. Windows asks for administrator rights, because the control is written to the registry. Keep the file where it is afterwards. See `docs/digital-bridge.md` |
| CW | `cwdaemon` has no Windows build. Use a WinKeyer on its COM port |
| Radio | Start `rigctld` from the Windows hamlib build, with a COM port: `rigctld -m 2028 -r COM3` |
| Voice playback | Plays through PowerShell's `Media.SoundPlayer`. The command is a setting |
| Voice recording | Windows has no recorder to call. Install `sox` or `ffmpeg` and put its command in the keyer settings |
No part of this has been run on Windows yet. The tests pass on Linux only.
## What it does ## What it does
| | | | | |
@@ -56,13 +86,13 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
| Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines | | Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines |
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message | | Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
| Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros; phone through the voice keyer, which plays each operator's own recordings | | Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros; phone through the voice keyer, which plays each operator's own recordings |
| Digital | N1MM's digital interface window, running MMTTY or 2Tone under Wine through the same `XMMT.ocx` N1MM drives them with | | Digital | N1MM's digital interface window, running MMTTY or 2Tone (under Wine on Linux, directly on Windows) through the same `XMMT.ocx` N1MM drives them with |
### The digital window ### The digital window
**Window ▸ Digital Interface** opens N1MM's digital interface. **Engine ▸ **Window ▸ Digital Interface** opens N1MM's digital interface. **Engine ▸
Settings** says where MMTTY or 2Tone is; **Engine ▸ Start** runs it under Wine. Settings** says where MMTTY or 2Tone is; **Engine ▸ Start** runs it: under Wine on Linux, directly on Windows.
`docs/digital-bridge.md` covers setting the Wine prefix up. `docs/digital-bridge.md` covers setting up the Wine prefix or, on Windows, registering the control.
What the window does, and what each part of it is for: What the window does, and what each part of it is for:
@@ -1040,7 +1070,7 @@ windows redraw where they stand.
| `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client | | `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client |
| `Nonemm.Network` | Contacts shared between the stations of a multi-operator entry | | `Nonemm.Network` | Contacts shared between the stations of a multi-operator entry |
| `Nonemm.Keying` | CW through `cwdaemon` or a WinKeyer, and the voice keyer's recordings | | `Nonemm.Keying` | CW through `cwdaemon` or a WinKeyer, and the voice keyer's recordings |
| `Nonemm.Digital` | MMTTY and 2Tone, over the Wine bridge in `bridge/` | | `Nonemm.Digital` | MMTTY and 2Tone, over the bridge in `bridge/` |
| `Nonemm.Session` | The contest in progress, and one entry position per radio — no UI toolkit | | `Nonemm.Session` | The contest in progress, and one entry position per radio — no UI toolkit |
| `Nonemm.App` | The Avalonia windows | | `Nonemm.App` | The Avalonia windows |
+21 -3
View File
@@ -1,9 +1,9 @@
# MMTTY and 2Tone through Wine # MMTTY and 2Tone through the bridge
MMTTY is a Windows program with no socket, pipe or file interface. N1MM does not MMTTY is a Windows program with no socket, pipe or file interface. N1MM does not
talk to it directly either: it hosts `XMMT.ocx`, a 32-bit ActiveX control that talk to it directly either: it hosts `XMMT.ocx`, a 32-bit ActiveX control that
starts `MMTTY.EXE` and exchanges window messages with it. A Linux process cannot starts `MMTTY.EXE` and exchanges window messages with it. A Linux process cannot
load that control, so Nonemm runs a small Windows program under Wine — load that control, so Nonemm runs a small Windows program (under Wine on Linux) —
`bridge/nonemm-mmtty-bridge.exe` — which hosts the control and passes lines over `bridge/nonemm-mmtty-bridge.exe` — which hosts the control and passes lines over
its standard input and output. its standard input and output.
@@ -98,7 +98,25 @@ Three things cost a day between them, so they are written down:
message, closes the engine's windows, and kills the process if it is still message, closes the engine's windows, and kills the process if it is still
there five seconds later. there five seconds later.
## Setting it up ## Setting it up on Windows
There is no Wine. `ProcessBridgeChannel` starts `nonemm-mmtty-bridge.exe`
directly, and the engine path goes to the bridge as it is.
The control must be registered once. The Register button in Config > Digital
does it with `WindowsControlSetup`, which runs the 32-bit `regsvr32` (the one in
`SysWOW64` on 64-bit Windows) on `XMMT.ocx` in place. Windows shows the
administrator prompt, because registering writes to `HKEY_CLASSES_ROOT`. The same
by hand, from an administrator prompt:
```bat
%WINDIR%\SysWOW64\regsvr32.exe bridge\XMMT.ocx
```
Do not move `XMMT.ocx` after that, or register it again. Nothing in this path
has been run on Windows.
## Setting it up under Wine
Config > Digital has a Register button. It does what the next paragraph Config > Digital has a Register button. It does what the next paragraph
describes, in the prefix named in the same dialog, and writes the result under describes, in the prefix named in the same dialog, and writes the result under
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Builds a self-contained Windows x64 copy in dist/nonemm-win-x64 and zips it.
# The target machine needs no .NET install. Pass another runtime id as the
# first argument, for example win-arm64 (the bridge and XMMT.ocx are 32 bit
# x86 either way).
rid="${1:-win-x64}"
cd "$(dirname "$0")" || exit 1
out="dist/nonemm-$rid"
rm -rf "$out"
./build.sh publish src/Nonemm.App -c Release -r "$rid" --self-contained -o "$out" || exit 1
rm -f "$out"/*.pdb
rm -f "dist/nonemm-$rid.zip"
python3 -c 'import shutil, sys; shutil.make_archive(sys.argv[1], "zip", "dist", sys.argv[2])' \
"dist/nonemm-$rid" "nonemm-$rid" || exit 1
echo "dist/nonemm-$rid.zip"
+4 -2
View File
@@ -699,10 +699,12 @@ public sealed class AppSession : IDisposable
OnTop = Settings.DigitalEngineOnTop, OnTop = Settings.DigitalEngineOnTop,
PttPort = Settings.DigitalPttPort.Trim().Length > 0 ? Settings.DigitalPttPort : null, PttPort = Settings.DigitalPttPort.Trim().Length > 0 ? Settings.DigitalPttPort : null,
}; };
WineBridgeChannel channel = new( ProcessBridgeChannel channel = new(
Settings.DigitalBridgePath, Settings.DigitalBridgePath,
Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null, Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine", OperatingSystem.IsWindows()
? null
: Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine",
Paths.Diagnostics); Paths.Diagnostics);
MmttyEngine started = new(channel, options); MmttyEngine started = new(channel, options);
await started.StartAsync(); await started.StartAsync();
+3 -3
View File
@@ -124,7 +124,7 @@
<TabItem Header="Engine"> <TabItem Header="Engine">
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="4,8,10,4"> <StackPanel Margin="4,8,10,4">
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75" <TextBlock Name="EngineNote" TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="MMTTY and 2Tone are Windows programs. They run under Wine, driven through the same XMMT.ocx control N1MM uses, with a small bridge program in between. See docs/digital-bridge.md for setting the Wine prefix up." /> Text="MMTTY and 2Tone are Windows programs. They run under Wine, driven through the same XMMT.ocx control N1MM uses, with a small bridge program in between. See docs/digital-bridge.md for setting the Wine prefix up." />
<TextBlock Classes="label" Text="Engine program (mmtty.exe or 2Tone.exe)" /> <TextBlock Classes="label" Text="Engine program (mmtty.exe or 2Tone.exe)" />
<Grid ColumnDefinitions="*,6,Auto"> <Grid ColumnDefinitions="*,6,Auto">
@@ -136,13 +136,13 @@
<TextBox Name="BridgeBox" /> <TextBox Name="BridgeBox" />
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseBridge" /> <Button Grid.Column="2" Content="Browse…" Click="OnBrowseBridge" />
</Grid> </Grid>
<Grid ColumnDefinitions="*,8,160" RowDefinitions="Auto,Auto"> <Grid Name="WineRow" ColumnDefinitions="*,8,160" RowDefinitions="Auto,Auto">
<TextBlock Classes="label" Text="Wine prefix (empty for Wine's own)" /> <TextBlock Classes="label" Text="Wine prefix (empty for Wine's own)" />
<TextBox Name="PrefixBox" Grid.Row="1" /> <TextBox Name="PrefixBox" Grid.Row="1" />
<TextBlock Grid.Column="2" Classes="label" Text="Wine command" /> <TextBlock Grid.Column="2" Classes="label" Text="Wine command" />
<TextBox Name="WineBox" Grid.Row="1" Grid.Column="2" /> <TextBox Name="WineBox" Grid.Row="1" Grid.Column="2" />
</Grid> </Grid>
<TextBlock Classes="label" Text="Control (XMMT.ocx), registered in the prefix above" /> <TextBlock Name="ControlLabel" Classes="label" Text="Control (XMMT.ocx), registered in the prefix above" />
<Grid ColumnDefinitions="*,6,Auto,6,Auto"> <Grid ColumnDefinitions="*,6,Auto,6,Auto">
<TextBox Name="ControlBox" /> <TextBox Name="ControlBox" />
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseControl" /> <Button Grid.Column="2" Content="Browse…" Click="OnBrowseControl" />
+20 -4
View File
@@ -106,6 +106,14 @@ public sealed partial class DigitalDialog : Window
ControlBox.Text = settings.DigitalControlPath; ControlBox.Text = settings.DigitalControlPath;
PrefixBox.Text = settings.DigitalWinePrefix; PrefixBox.Text = settings.DigitalWinePrefix;
WineBox.Text = settings.DigitalWineCommand; WineBox.Text = settings.DigitalWineCommand;
if (OperatingSystem.IsWindows())
{
WineRow.IsVisible = false;
EngineNote.Text = "MMTTY and 2Tone are driven through the same XMMT.ocx control N1MM uses, "
+ "with a small bridge program in between. Register the control once, before the first start. "
+ "See docs/digital-bridge.md.";
ControlLabel.Text = "Control (XMMT.ocx), registered where it is. Keep the file there";
}
PttBox.Text = settings.DigitalPttPort; PttBox.Text = settings.DigitalPttPort;
} }
@@ -251,12 +259,20 @@ public sealed partial class DigitalDialog : Window
{ {
RegisterButton.IsEnabled = false; RegisterButton.IsEnabled = false;
Say("Registering…"); Say("Registering…");
WinePrefixSetup setup = new( string control = ControlBox.Text?.Trim() ?? "";
PrefixBox.Text?.Trim() is { Length: > 0 } prefix ? prefix : null,
WineBox.Text?.Trim() is { Length: > 0 } wine ? wine : "wine");
try try
{ {
Say(await setup.RegisterAsync(ControlBox.Text?.Trim() ?? "")); if (OperatingSystem.IsWindows())
{
Say(await new WindowsControlSetup().RegisterAsync(control));
}
else
{
WinePrefixSetup setup = new(
PrefixBox.Text?.Trim() is { Length: > 0 } prefix ? prefix : null,
WineBox.Text?.Trim() is { Length: > 0 } wine ? wine : "wine");
Say(await setup.RegisterAsync(control));
}
} }
catch (Exception failure) when (failure is IOException or InvalidOperationException) catch (Exception failure) when (failure is IOException or InvalidOperationException)
{ {
@@ -2,14 +2,15 @@ using System.Diagnostics;
namespace Nonemm.Digital; namespace Nonemm.Digital;
/// Runs the bridge under Wine and talks to it over its standard input and /// Runs the bridge and talks to it over its standard input and output. On
/// output. Wine writes its own diagnostics to standard error, so the protocol /// Linux the bridge runs under Wine, which writes its own diagnostics to
/// has standard output to itself. /// standard error, so the protocol has standard output to itself. On Windows
public sealed class WineBridgeChannel : BridgeChannel /// `wine` is null and the bridge is started directly.
public sealed class ProcessBridgeChannel : BridgeChannel
{ {
private const int KeptErrorLines = 20; private const int KeptErrorLines = 20;
private readonly string wine; private readonly string? wine;
private readonly string bridgePath; private readonly string bridgePath;
private readonly string? prefix; private readonly string? prefix;
private readonly string? logFolder; private readonly string? logFolder;
@@ -22,12 +23,13 @@ public sealed class WineBridgeChannel : BridgeChannel
private int lastCount = int.MinValue; private int lastCount = int.MinValue;
/// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default, /// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default,
/// which is what a station with one prefix wants. `logFolder`, when it is /// which is what a station with one prefix wants. `wine` is the Wine
/// command, or null to run the bridge without Wine. `logFolder`, when it is
/// given, is where the protocol log for this run is written. /// given, is where the protocol log for this run is written.
public WineBridgeChannel( public ProcessBridgeChannel(
string bridgePath, string bridgePath,
string? prefix = null, string? prefix = null,
string wine = "wine", string? wine = "wine",
string? logFolder = null) string? logFolder = null)
{ {
this.bridgePath = bridgePath; this.bridgePath = bridgePath;
@@ -70,6 +72,10 @@ public sealed class WineBridgeChannel : BridgeChannel
string path, string path,
CancellationToken cancellation = default) CancellationToken cancellation = default)
{ {
if (wine is null)
{
return Path.GetFullPath(path);
}
using Process winepath = Start("winepath", "-w", path); using Process winepath = Start("winepath", "-w", path);
string converted = await winepath.StandardOutput.ReadToEndAsync(cancellation) string converted = await winepath.StandardOutput.ReadToEndAsync(cancellation)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -168,13 +174,17 @@ public sealed class WineBridgeChannel : BridgeChannel
{ {
ProcessStartInfo start = new() ProcessStartInfo start = new()
{ {
FileName = wine, FileName = wine ?? program,
RedirectStandardInput = true, RedirectStandardInput = true,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true,
}; };
start.ArgumentList.Add(program); if (wine is not null)
{
start.ArgumentList.Add(program);
}
foreach (string argument in arguments) foreach (string argument in arguments)
{ {
start.ArgumentList.Add(argument); start.ArgumentList.Add(argument);
+58
View File
@@ -0,0 +1,58 @@
using System.Diagnostics;
namespace Nonemm.Digital;
/// Registers XMMT.ocx on Windows, which is what the bridge needs before it can
/// create the control. The control is registered where it is, so the file must
/// stay in place afterwards.
public sealed class WindowsControlSetup
{
/// The control is 32 bit. On 64-bit Windows the 32-bit regsvr32 is in
/// SysWOW64, and the one in System32 cannot load it.
public static string Regsvr32Path(string windowsFolder) =>
Directory.Exists(Path.Combine(windowsFolder, "SysWOW64"))
? Path.Combine(windowsFolder, "SysWOW64", "regsvr32.exe")
: Path.Combine(windowsFolder, "System32", "regsvr32.exe");
/// Registers the control and returns what to show the operator. It writes
/// to HKEY_CLASSES_ROOT, so it runs elevated: Windows asks the operator to
/// allow that. It throws when the operator refuses or regsvr32 fails.
public async Task<string> RegisterAsync(
string controlPath,
CancellationToken cancellation = default)
{
if (!File.Exists(controlPath))
{
throw new FileNotFoundException($"{controlPath} is not there", controlPath);
}
string regsvr32 = Regsvr32Path(Environment.GetFolderPath(Environment.SpecialFolder.Windows));
ProcessStartInfo start = new()
{
FileName = regsvr32,
ArgumentList = { "/s", Path.GetFullPath(controlPath) },
UseShellExecute = true,
Verb = "runas",
};
Process running;
try
{
running = Process.Start(start)
?? throw new InvalidOperationException($"{regsvr32} did not start");
}
catch (System.ComponentModel.Win32Exception refused)
{
throw new InvalidOperationException(
$"{regsvr32} did not start: {refused.Message}", refused);
}
using (running)
{
await running.WaitForExitAsync(cancellation).ConfigureAwait(false);
if (running.ExitCode != 0)
{
throw new InvalidOperationException(
$"{regsvr32} /s {controlPath} failed with exit code {running.ExitCode}");
}
}
return $"{Path.GetFileName(controlPath)} registered";
}
}
@@ -0,0 +1,34 @@
using Nonemm.Digital;
namespace Nonemm.Digital.Tests;
public class WindowsControlSetupTests
{
[Fact]
public void ASixtyFourBitWindowsRegistersWithTheCopyInSysWow64()
{
string windows = Directory.CreateTempSubdirectory().FullName;
Directory.CreateDirectory(Path.Combine(windows, "SysWOW64"));
Assert.Equal(
Path.Combine(windows, "SysWOW64", "regsvr32.exe"),
WindowsControlSetup.Regsvr32Path(windows));
}
[Fact]
public void AThirtyTwoBitWindowsRegistersWithTheCopyInSystem32()
{
string windows = Directory.CreateTempSubdirectory().FullName;
Assert.Equal(
Path.Combine(windows, "System32", "regsvr32.exe"),
WindowsControlSetup.Regsvr32Path(windows));
}
[Fact]
public async Task AControlThatIsNotThereIsSaidSo()
{
await Assert.ThrowsAsync<FileNotFoundException>(
() => new WindowsControlSetup().RegisterAsync("/nowhere/XMMT.ocx"));
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ public static class Program
using ProbeLog log = new(Option(options, "out", "engine-probe.log")); using ProbeLog log = new(Option(options, "out", "engine-probe.log"));
MmttyEngine engine = new( MmttyEngine engine = new(
new WineBridgeChannel( new ProcessBridgeChannel(
Option(options, "bridge", Path.Combine("bridge", "nonemm-mmtty-bridge.exe")), Option(options, "bridge", Path.Combine("bridge", "nonemm-mmtty-bridge.exe")),
options.GetValueOrDefault("prefix"), options.GetValueOrDefault("prefix"),
logFolder: options.GetValueOrDefault("log")), logFolder: options.GetValueOrDefault("log")),