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>
234 lines
7.1 KiB
C#
234 lines
7.1 KiB
C#
using System.Diagnostics;
|
|
|
|
namespace Nonemm.Digital;
|
|
|
|
/// 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 bridgePath;
|
|
private readonly string? prefix;
|
|
private readonly string? logFolder;
|
|
private readonly Queue<string> lastErrors = new();
|
|
private readonly SemaphoreSlim writing = new(1, 1);
|
|
private readonly Lock writingLog = new();
|
|
private Process? bridge;
|
|
private StreamWriter? log;
|
|
private DateTime started;
|
|
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. `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 ProcessBridgeChannel(
|
|
string bridgePath,
|
|
string? prefix = null,
|
|
string? wine = "wine",
|
|
string? logFolder = null)
|
|
{
|
|
this.bridgePath = bridgePath;
|
|
this.prefix = prefix;
|
|
this.wine = wine;
|
|
this.logFolder = logFolder;
|
|
}
|
|
|
|
public event EventHandler<string>? LineReceived;
|
|
|
|
public event EventHandler<string>? Failed;
|
|
|
|
public Task StartAsync(CancellationToken cancellation = default)
|
|
{
|
|
OpenLog();
|
|
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);
|
|
Log(">", line);
|
|
}
|
|
finally
|
|
{
|
|
writing.Release();
|
|
}
|
|
}
|
|
|
|
public async Task<string> ToWindowsPathAsync(
|
|
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);
|
|
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();
|
|
lock (writingLog)
|
|
{
|
|
log?.Dispose();
|
|
log = null;
|
|
}
|
|
}
|
|
|
|
/// One file per run of the engine, named for when it started. It is the
|
|
/// whole protocol with a millisecond stamp on every line, which is what
|
|
/// says who keyed the transmitter and when.
|
|
private void OpenLog()
|
|
{
|
|
if (logFolder is null)
|
|
{
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
Directory.CreateDirectory(logFolder);
|
|
started = DateTime.Now;
|
|
log = new StreamWriter(
|
|
Path.Combine(logFolder, $"digital-{started:yyyyMMdd-HHmmss}.log"),
|
|
append: false)
|
|
{
|
|
AutoFlush = true,
|
|
};
|
|
}
|
|
catch (IOException)
|
|
{
|
|
// a log that cannot be written stops nothing
|
|
log = null;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
log = null;
|
|
}
|
|
}
|
|
|
|
/// The buffer question goes out every 50 ms and is answered just as often,
|
|
/// which would bury everything else. The question is left out and the
|
|
/// answer is written only when the count has changed.
|
|
private void Log(string direction, string line)
|
|
{
|
|
if (log is null)
|
|
{
|
|
return;
|
|
}
|
|
(string verb, string[] fields) = BridgeLine.Read(line);
|
|
if (verb == "buffer")
|
|
{
|
|
if (direction == ">" || fields.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
if (!int.TryParse(fields[0], out int count) || count == lastCount)
|
|
{
|
|
return;
|
|
}
|
|
lastCount = count;
|
|
}
|
|
lock (writingLog)
|
|
{
|
|
log?.WriteLine(
|
|
$"{(DateTime.Now - started).TotalMilliseconds,9:0} ms {direction} {line.Replace('\t', ' ')}");
|
|
}
|
|
}
|
|
|
|
private Process Start(string program, params string[] arguments)
|
|
{
|
|
ProcessStartInfo start = new()
|
|
{
|
|
FileName = wine ?? program,
|
|
RedirectStandardInput = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
};
|
|
if (wine is not null)
|
|
{
|
|
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)
|
|
{
|
|
Log("<", 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);
|
|
}
|
|
}
|
|
}
|