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:
@@ -7,3 +7,4 @@ obj/
|
||||
/mmtty/
|
||||
/bridge/XMMT.ocx
|
||||
engine-probe.log
|
||||
/dist/
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
| | |
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
**Window ▸ Digital Interface** opens N1MM's digital interface. **Engine ▸
|
||||
Settings** says where MMTTY or 2Tone is; **Engine ▸ Start** runs it under Wine.
|
||||
`docs/digital-bridge.md` covers setting the Wine prefix up.
|
||||
Settings** says where MMTTY or 2Tone is; **Engine ▸ Start** runs it: under Wine on Linux, directly on Windows.
|
||||
`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:
|
||||
|
||||
@@ -1040,7 +1070,7 @@ windows redraw where they stand.
|
||||
| `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client |
|
||||
| `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.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.App` | The Avalonia windows |
|
||||
|
||||
|
||||
+21
-3
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
describes, in the prefix named in the same dialog, and writes the result under
|
||||
|
||||
Executable
+15
@@ -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"
|
||||
@@ -699,10 +699,12 @@ public sealed class AppSession : IDisposable
|
||||
OnTop = Settings.DigitalEngineOnTop,
|
||||
PttPort = Settings.DigitalPttPort.Trim().Length > 0 ? Settings.DigitalPttPort : null,
|
||||
};
|
||||
WineBridgeChannel channel = new(
|
||||
ProcessBridgeChannel channel = new(
|
||||
Settings.DigitalBridgePath,
|
||||
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);
|
||||
MmttyEngine started = new(channel, options);
|
||||
await started.StartAsync();
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
<TabItem Header="Engine">
|
||||
<ScrollViewer>
|
||||
<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." />
|
||||
<TextBlock Classes="label" Text="Engine program (mmtty.exe or 2Tone.exe)" />
|
||||
<Grid ColumnDefinitions="*,6,Auto">
|
||||
@@ -136,13 +136,13 @@
|
||||
<TextBox Name="BridgeBox" />
|
||||
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseBridge" />
|
||||
</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)" />
|
||||
<TextBox Name="PrefixBox" Grid.Row="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="Wine command" />
|
||||
<TextBox Name="WineBox" Grid.Row="1" Grid.Column="2" />
|
||||
</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">
|
||||
<TextBox Name="ControlBox" />
|
||||
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseControl" />
|
||||
|
||||
@@ -106,6 +106,14 @@ public sealed partial class DigitalDialog : Window
|
||||
ControlBox.Text = settings.DigitalControlPath;
|
||||
PrefixBox.Text = settings.DigitalWinePrefix;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -251,12 +259,20 @@ public sealed partial class DigitalDialog : Window
|
||||
{
|
||||
RegisterButton.IsEnabled = false;
|
||||
Say("Registering…");
|
||||
WinePrefixSetup setup = new(
|
||||
PrefixBox.Text?.Trim() is { Length: > 0 } prefix ? prefix : null,
|
||||
WineBox.Text?.Trim() is { Length: > 0 } wine ? wine : "wine");
|
||||
string control = ControlBox.Text?.Trim() ?? "";
|
||||
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)
|
||||
{
|
||||
|
||||
+20
-10
@@ -2,14 +2,15 @@ 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
|
||||
/// Runs the bridge and talks to it over its standard input and output. On
|
||||
/// Linux the bridge runs under Wine, which writes its own diagnostics to
|
||||
/// standard error, so the protocol has standard output to itself. On Windows
|
||||
/// `wine` is null and the bridge is started directly.
|
||||
public sealed class ProcessBridgeChannel : BridgeChannel
|
||||
{
|
||||
private const int KeptErrorLines = 20;
|
||||
|
||||
private readonly string wine;
|
||||
private readonly string? wine;
|
||||
private readonly string bridgePath;
|
||||
private readonly string? prefix;
|
||||
private readonly string? logFolder;
|
||||
@@ -22,12 +23,13 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
private int lastCount = int.MinValue;
|
||||
|
||||
/// `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.
|
||||
public WineBridgeChannel(
|
||||
public ProcessBridgeChannel(
|
||||
string bridgePath,
|
||||
string? prefix = null,
|
||||
string wine = "wine",
|
||||
string? wine = "wine",
|
||||
string? logFolder = null)
|
||||
{
|
||||
this.bridgePath = bridgePath;
|
||||
@@ -70,6 +72,10 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
string path,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
if (wine is null)
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
using Process winepath = Start("winepath", "-w", path);
|
||||
string converted = await winepath.StandardOutput.ReadToEndAsync(cancellation)
|
||||
.ConfigureAwait(false);
|
||||
@@ -168,13 +174,17 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
{
|
||||
ProcessStartInfo start = new()
|
||||
{
|
||||
FileName = wine,
|
||||
FileName = wine ?? program,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
start.ArgumentList.Add(program);
|
||||
if (wine is not null)
|
||||
{
|
||||
start.ArgumentList.Add(program);
|
||||
}
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ public static class Program
|
||||
|
||||
using ProbeLog log = new(Option(options, "out", "engine-probe.log"));
|
||||
MmttyEngine engine = new(
|
||||
new WineBridgeChannel(
|
||||
new ProcessBridgeChannel(
|
||||
Option(options, "bridge", Path.Combine("bridge", "nonemm-mmtty-bridge.exe")),
|
||||
options.GetValueOrDefault("prefix"),
|
||||
logFolder: options.GetValueOrDefault("log")),
|
||||
|
||||
Reference in New Issue
Block a user