ApplicationIcon embeds it in the exe (Explorer, taskbar); MainForm pulls the same embedded icon for the title bar via ExtractAssociatedIcon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
511 lines
21 KiB
C#
511 lines
21 KiB
C#
using System.Diagnostics;
|
|
using System.IO.Ports;
|
|
using VRio.Core.Device;
|
|
using VRio.Core.Input;
|
|
|
|
namespace VRio.App;
|
|
|
|
/// <summary>
|
|
/// vRIO main window: the interactive cockpit panel on the left (the same
|
|
/// functional map RIOJoy's profile editor shows) and a control strip on the
|
|
/// right — COM port, device settings, and a live wire log. Open the COM port,
|
|
/// point RIOJoy at the other end of the null-modem pair, and every click here
|
|
/// arrives at RIOJoy exactly as if the physical cockpit sent it.
|
|
/// </summary>
|
|
internal sealed class MainForm : Form
|
|
{
|
|
private readonly VRioDevice _device = new();
|
|
private readonly VRioSerialService _service;
|
|
private readonly PanelCanvas _canvas = new();
|
|
private readonly InputRouter _router;
|
|
private readonly XInputGamepad _gamepad = new();
|
|
private readonly KeyboardLampMirror _lampMirror;
|
|
private readonly string _bindingsPath = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
|
|
|
|
private readonly ComboBox _portBox = new()
|
|
{
|
|
Location = new Point(80, 12),
|
|
Width = 128,
|
|
DropDownStyle = ComboBoxStyle.DropDownList,
|
|
};
|
|
private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 };
|
|
private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 };
|
|
private readonly Label _linkStatus = new()
|
|
{
|
|
Text = "Port closed.",
|
|
Location = new Point(12, 42),
|
|
AutoSize = true,
|
|
ForeColor = Color.Gray,
|
|
};
|
|
|
|
private readonly NumericUpDown _verMajor = new() { Location = new Point(80, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 4 };
|
|
private readonly NumericUpDown _verMinor = new() { Location = new Point(140, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 2 };
|
|
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 56), AutoSize = true, Checked = true };
|
|
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 82), Width = 140, Height = 26 };
|
|
private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 82), Width = 140, Height = 26 };
|
|
private readonly Button _testEnter = new() { Text = "Enter test mode", Location = new Point(10, 114), Width = 140, Height = 26 };
|
|
private readonly Button _testExit = new() { Text = "Exit test mode", Location = new Point(156, 114), Width = 140, Height = 26 };
|
|
|
|
private readonly CheckBox _kbInput = new() { Text = "Keyboard", Location = new Point(10, 22), AutoSize = true, Checked = true };
|
|
private readonly CheckBox _padInput = new() { Text = "Xbox gamepad", Location = new Point(120, 22), AutoSize = true, Checked = true };
|
|
private readonly CheckBox _invertY = new() { Text = "Invert joystick Y", Location = new Point(10, 46), AutoSize = true, Checked = true };
|
|
private readonly Label _padStatus = new()
|
|
{
|
|
Text = "No controller detected.",
|
|
Location = new Point(10, 70),
|
|
AutoSize = true,
|
|
ForeColor = Color.Gray,
|
|
};
|
|
private readonly Button _reloadBindings = new() { Text = "Reload bindings", Location = new Point(10, 92), Width = 140, Height = 26 };
|
|
private readonly Button _editBindings = new() { Text = "Edit bindings…", Location = new Point(156, 92), Width = 140, Height = 26 };
|
|
private readonly CheckBox _kbLights = new()
|
|
{
|
|
Text = "Mirror lamps on RGB keyboard (Dynamic Lighting)",
|
|
Location = new Point(10, 124),
|
|
AutoSize = true,
|
|
};
|
|
private readonly ComboBox _kbLightsTarget = new()
|
|
{
|
|
Location = new Point(10, 146),
|
|
Width = 286,
|
|
DropDownStyle = ComboBoxStyle.DropDownList,
|
|
Enabled = false, // populated while the mirror is on
|
|
};
|
|
|
|
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
|
|
private sealed record KbChoice(string? Id, string Name)
|
|
{
|
|
public override string ToString() => Name;
|
|
}
|
|
|
|
private readonly Label _counters = new()
|
|
{
|
|
Location = new Point(12, 414),
|
|
AutoSize = true,
|
|
Font = new Font("Consolas", 8f),
|
|
};
|
|
|
|
private readonly Label _help = new()
|
|
{
|
|
Location = new Point(12, 470),
|
|
MaximumSize = new Size(306, 0),
|
|
AutoSize = true,
|
|
ForeColor = Color.Gray,
|
|
Text = "Left-click a cell: momentary press. Right-click: latch it down. " +
|
|
"Drag the X/Y box and the Z / L / R gauges to move the axes. " +
|
|
"Keyboard and Xbox-pad input follow the bindings file (Edit bindings…).",
|
|
};
|
|
|
|
private readonly TextBox _logBox = new()
|
|
{
|
|
Location = new Point(12, 554),
|
|
Multiline = true,
|
|
ReadOnly = true,
|
|
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
|
|
BackColor = Color.FromArgb(24, 24, 24),
|
|
ForeColor = Color.Gainsboro,
|
|
Font = new Font("Consolas", 8f),
|
|
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left,
|
|
WordWrap = false,
|
|
};
|
|
private readonly Button _clearLog = new()
|
|
{
|
|
Text = "Clear log",
|
|
Width = 80,
|
|
Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
|
|
};
|
|
|
|
private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 };
|
|
private readonly System.Windows.Forms.Timer _inputTimer = new() { Interval = 16 };
|
|
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
|
private double _lastInputTick;
|
|
|
|
public MainForm()
|
|
{
|
|
// ProductVersion carries the git stamp (see StampGitVersion in the csproj).
|
|
Text = $"vRIO v{Application.ProductVersion} — Virtual RIO cockpit device";
|
|
// Title-bar/taskbar icon from the exe's embedded ApplicationIcon (vwe.ico).
|
|
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
|
|
// Fit the window to its content: the cockpit canvas plus the 330px
|
|
// control strip, with just enough height for the strip's log area.
|
|
ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640));
|
|
MinimumSize = new Size(1000, 620);
|
|
StartPosition = FormStartPosition.CenterScreen;
|
|
KeyPreview = true; // form-level key routing for the input bindings
|
|
|
|
_service = new VRioSerialService(_device);
|
|
_router = new InputRouter(_device);
|
|
_lampMirror = new KeyboardLampMirror(_device);
|
|
|
|
// Panel canvas, scrolled if the window is smaller than the grid.
|
|
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
|
scroller.Controls.Add(_canvas);
|
|
|
|
Controls.Add(scroller);
|
|
Controls.Add(BuildControlStrip());
|
|
|
|
// Canvas ↔ device wiring.
|
|
_canvas.LampProvider = _device.GetLamp;
|
|
_canvas.AxisProvider = _device.GetAxis;
|
|
_canvas.AddressPressed += _device.PressAddress;
|
|
_canvas.AddressReleased += _device.ReleaseAddress;
|
|
_canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value);
|
|
|
|
// Router-driven presses light up like clicks (router runs on the UI thread).
|
|
_router.AddressHeldChanged += _canvas.SetExternalHeld;
|
|
|
|
// Device / service events arrive on worker threads; marshal to the UI.
|
|
_device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate);
|
|
_device.AxesChanged += () => RunOnUi(_canvas.Invalidate);
|
|
// A host reset re-zeroes the axes behind the router's back.
|
|
_device.ResetReceived += _ => RunOnUi(_router.ResetAxisState);
|
|
_device.Logged += line => RunOnUi(() => PrependLog(line));
|
|
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
|
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
|
|
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
|
|
_service.HostHandshake += high => RunOnUi(() =>
|
|
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
|
|
|
|
_rescan.Click += (_, _) => RefreshPorts();
|
|
_openClose.Click += (_, _) => ToggleOpen();
|
|
_verMajor.ValueChanged += (_, _) => _device.VersionMajor = (byte)_verMajor.Value;
|
|
_verMinor.ValueChanged += (_, _) => _device.VersionMinor = (byte)_verMinor.Value;
|
|
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
|
|
_centerAxes.Click += (_, _) =>
|
|
{
|
|
foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis)))
|
|
_device.SetAxis(axis, 0);
|
|
_router.ResetAxisState();
|
|
};
|
|
_lampsOff.Click += (_, _) =>
|
|
{
|
|
_device.ClearLamps();
|
|
_canvas.Invalidate();
|
|
};
|
|
_testEnter.Click += (_, _) => _device.SendTestMode(1);
|
|
_testExit.Click += (_, _) => _device.SendTestMode(0);
|
|
_clearLog.Click += (_, _) => _logBox.Clear();
|
|
|
|
_kbInput.CheckedChanged += (_, _) =>
|
|
{
|
|
if (!_kbInput.Checked)
|
|
_router.ReleaseAllKeys();
|
|
};
|
|
_invertY.CheckedChanged += (_, _) => _device.InvertJoystickY = _invertY.Checked;
|
|
_device.InvertJoystickY = _invertY.Checked; // the field initializer raises no event
|
|
_kbLights.CheckedChanged += (_, _) =>
|
|
{
|
|
_lampMirror.Enabled = _kbLights.Checked;
|
|
_kbLightsTarget.Enabled = _kbLights.Checked;
|
|
};
|
|
_kbLightsTarget.SelectedIndexChanged += (_, _) =>
|
|
_lampMirror.SetTarget((_kbLightsTarget.SelectedItem as KbChoice)?.Id);
|
|
_lampMirror.KeyboardsChanged += list => RunOnUi(() => RebuildKeyboardPicker(list));
|
|
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
|
|
_kbLightsTarget.SelectedIndex = 0;
|
|
_reloadBindings.Click += (_, _) => LoadBindings();
|
|
_editBindings.Click += (_, _) => OpenBindingsFile();
|
|
|
|
_uiTimer.Tick += (_, _) => UpdateStatus();
|
|
_uiTimer.Start();
|
|
|
|
_inputTimer.Tick += (_, _) => InputTick();
|
|
_inputTimer.Start();
|
|
|
|
FormClosed += (_, _) =>
|
|
{
|
|
_uiTimer.Dispose();
|
|
_inputTimer.Dispose();
|
|
_lampMirror.Dispose();
|
|
_service.Dispose();
|
|
};
|
|
|
|
RefreshPorts();
|
|
UpdateStatus();
|
|
PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair.");
|
|
LoadBindings();
|
|
}
|
|
|
|
private Panel BuildControlStrip()
|
|
{
|
|
var panel = new Panel
|
|
{
|
|
Dock = DockStyle.Right,
|
|
Width = 330,
|
|
// Height must be set before the anchored children are added:
|
|
// Anchor offsets are captured against the parent's current size,
|
|
// and the 100px default would pin the log box's bottom far below
|
|
// the panel, making it grow past the window instead of scrolling.
|
|
Height = ClientSize.Height,
|
|
Padding = new Padding(8),
|
|
BorderStyle = BorderStyle.FixedSingle,
|
|
};
|
|
|
|
panel.Controls.Add(new Label { Text = "COM port:", Location = new Point(12, 15), AutoSize = true });
|
|
panel.Controls.Add(_portBox);
|
|
panel.Controls.Add(_rescan);
|
|
panel.Controls.Add(_openClose);
|
|
panel.Controls.Add(_linkStatus);
|
|
|
|
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 150) };
|
|
device.Controls.Add(new Label { Text = "Firmware:", Location = new Point(10, 27), AutoSize = true });
|
|
device.Controls.Add(_verMajor);
|
|
device.Controls.Add(new Label { Text = ".", Location = new Point(127, 27), AutoSize = true });
|
|
device.Controls.Add(_verMinor);
|
|
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit });
|
|
panel.Controls.Add(device);
|
|
|
|
var input = new GroupBox { Text = "Input", Location = new Point(12, 224), Size = new Size(306, 182) };
|
|
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget });
|
|
panel.Controls.Add(input);
|
|
|
|
panel.Controls.Add(_counters);
|
|
panel.Controls.Add(_help);
|
|
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 536), AutoSize = true });
|
|
|
|
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
|
|
panel.Controls.Add(_logBox);
|
|
|
|
_clearLog.Location = new Point(12, ClientSize.Height - 36);
|
|
panel.Controls.Add(_clearLog);
|
|
|
|
return panel;
|
|
}
|
|
|
|
// ---- Port handling -----------------------------------------------------
|
|
|
|
private void RefreshPorts()
|
|
{
|
|
string? current = _portBox.SelectedItem?.ToString();
|
|
_portBox.Items.Clear();
|
|
foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
|
|
_portBox.Items.Add(name);
|
|
|
|
if (_portBox.Items.Count == 0)
|
|
return;
|
|
int idx = current is null ? -1 : _portBox.Items.IndexOf(current);
|
|
_portBox.SelectedIndex = idx >= 0 ? idx : 0;
|
|
}
|
|
|
|
private void ToggleOpen()
|
|
{
|
|
if (_service.IsOpen)
|
|
{
|
|
_service.Close();
|
|
return;
|
|
}
|
|
|
|
if (_portBox.SelectedItem?.ToString() is not { } port)
|
|
{
|
|
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " +
|
|
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
_service.Open(port);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageBox.Show(this, $"Could not open {port}:\n{ex.Message}", "vRIO",
|
|
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
|
}
|
|
}
|
|
|
|
private void OnConnectionChanged(bool open)
|
|
{
|
|
_openClose.Text = open ? "Close" : "Open";
|
|
_portBox.Enabled = _rescan.Enabled = !open;
|
|
_linkStatus.Text = open ? $"Serving {_service.PortName} @ 9600 8N1." : "Port closed.";
|
|
_linkStatus.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
|
UpdateStatus();
|
|
}
|
|
|
|
// ---- Keyboard / gamepad input -------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Keys route to the panel unless the user is in a control that needs
|
|
/// them (port list, firmware spinners, log box scrolling).
|
|
/// </summary>
|
|
private bool KeyboardRoutingActive =>
|
|
_kbInput.Checked &&
|
|
!_portBox.ContainsFocus && !_verMajor.ContainsFocus && !_verMinor.ContainsFocus && !_logBox.ContainsFocus;
|
|
|
|
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
|
{
|
|
// Intercept WM_KEYDOWN before dialog-key processing, or arrows/space
|
|
// would move focus and click buttons instead of reaching the panel.
|
|
const int WM_KEYDOWN = 0x0100, WM_SYSKEYDOWN = 0x0104;
|
|
if ((msg.Msg is WM_KEYDOWN or WM_SYSKEYDOWN)
|
|
&& (keyData & (Keys.Control | Keys.Alt)) == 0
|
|
&& KeyboardRoutingActive)
|
|
{
|
|
string name = (keyData & Keys.KeyCode).ToString();
|
|
if (_router.HasKeyBinding(name))
|
|
{
|
|
_router.KeyDown(name);
|
|
return true;
|
|
}
|
|
}
|
|
return base.ProcessCmdKey(ref msg, keyData);
|
|
}
|
|
|
|
protected override void OnKeyUp(KeyEventArgs e)
|
|
{
|
|
// Unconditional: the router ignores keys it never saw go down, and a
|
|
// release must land even if the checkbox flipped mid-hold.
|
|
_router.KeyUp(e.KeyCode.ToString());
|
|
base.OnKeyUp(e);
|
|
}
|
|
|
|
protected override void OnDeactivate(EventArgs e)
|
|
{
|
|
_router.ReleaseAllKeys(); // key-up events are lost once unfocused
|
|
base.OnDeactivate(e);
|
|
}
|
|
|
|
private void InputTick()
|
|
{
|
|
double now = _clock.Elapsed.TotalSeconds;
|
|
double dt = Math.Min(0.25, now - _lastInputTick); // cap catch-up after a stall
|
|
_lastInputTick = now;
|
|
|
|
if (_padInput.Checked && _gamepad.TryRead(out var pad))
|
|
_router.SetPadState(pad);
|
|
else
|
|
_router.SetPadState(default); // releases whatever the pad held
|
|
|
|
_router.Tick(dt);
|
|
|
|
string status = _padInput.Checked
|
|
? _gamepad.Connected ? $"Controller #{_gamepad.UserIndex + 1} connected." : "No controller detected."
|
|
: "Gamepad input off.";
|
|
if (_padStatus.Text != status)
|
|
{
|
|
_padStatus.Text = status;
|
|
_padStatus.ForeColor = _gamepad.Connected && _padInput.Checked ? Color.ForestGreen : Color.Gray;
|
|
}
|
|
}
|
|
|
|
private void LoadBindings()
|
|
{
|
|
try
|
|
{
|
|
string text;
|
|
if (File.Exists(_bindingsPath))
|
|
{
|
|
text = File.ReadAllText(_bindingsPath);
|
|
}
|
|
else
|
|
{
|
|
// First run: materialize the commented default file so
|
|
// "Edit bindings…" has something self-documenting to open.
|
|
Directory.CreateDirectory(Path.GetDirectoryName(_bindingsPath)!);
|
|
File.WriteAllText(_bindingsPath, BindingProfileFormat.DefaultText);
|
|
text = BindingProfileFormat.DefaultText;
|
|
}
|
|
|
|
var profile = BindingProfileFormat.Parse(text, out var errors);
|
|
_router.Profile = profile;
|
|
_lampMirror.SetProfile(profile);
|
|
foreach (string error in errors)
|
|
PrependLog($"Bindings: {error}");
|
|
PrependLog($"Bindings loaded: {profile.Count} ({_bindingsPath})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
PrependLog($"Bindings load failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void OpenBindingsFile()
|
|
{
|
|
try
|
|
{
|
|
if (!File.Exists(_bindingsPath))
|
|
LoadBindings(); // writes the default file
|
|
Process.Start(new ProcessStartInfo(_bindingsPath) { UseShellExecute = true });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageBox.Show(this, $"Could not open {_bindingsPath}:\n{ex.Message}", "vRIO",
|
|
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
|
}
|
|
}
|
|
|
|
/// <summary>Refresh the lamp-mirror keyboard picker, keeping the pick if it survived.</summary>
|
|
private void RebuildKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
|
|
{
|
|
string? selected = (_kbLightsTarget.SelectedItem as KbChoice)?.Id;
|
|
_kbLightsTarget.BeginUpdate();
|
|
_kbLightsTarget.Items.Clear();
|
|
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
|
|
int select = 0;
|
|
foreach ((string id, string name) in keyboards)
|
|
{
|
|
int idx = _kbLightsTarget.Items.Add(new KbChoice(id, name));
|
|
if (id == selected)
|
|
select = idx;
|
|
}
|
|
// Falls back to "All keyboards" if the picked device vanished (the
|
|
// selection-changed handler re-targets the mirror accordingly).
|
|
_kbLightsTarget.SelectedIndex = select;
|
|
_kbLightsTarget.EndUpdate();
|
|
}
|
|
|
|
// ---- Status / log ------------------------------------------------------
|
|
|
|
private void UpdateStatus()
|
|
{
|
|
long polls = _device.AnalogRequests;
|
|
long dropped = _device.AnalogDropped;
|
|
long bad = _device.BadChecksums;
|
|
bool wedged = _device.AnalogWedged;
|
|
_counters.Text = $"Analog polls served: {polls}\n" +
|
|
$"Analog polls dropped: {dropped}\n" +
|
|
$"Bad checksums: {bad}";
|
|
|
|
_canvas.StatusText = _service.IsOpen
|
|
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" +
|
|
(wedged
|
|
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
|
|
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
|
|
: "PORT CLOSED\nOpen a COM port to go live.";
|
|
}
|
|
|
|
private void PrependLog(string line)
|
|
{
|
|
// Newest entry on top; older lines flow downward and eventually fall
|
|
// off the bounded end, so the visible top is always current.
|
|
string text = $"[{_clock.Elapsed.TotalSeconds,8:F2}s] {line}{Environment.NewLine}" + _logBox.Text;
|
|
|
|
// Bound the log so an all-day session doesn't grow without limit;
|
|
// cut at a line boundary so the tail isn't half an entry.
|
|
if (text.Length > 120_000)
|
|
{
|
|
int cut = text.LastIndexOf(Environment.NewLine, 60_000, StringComparison.Ordinal);
|
|
text = text.Substring(0, cut < 0 ? 60_000 : cut + Environment.NewLine.Length);
|
|
}
|
|
|
|
_logBox.Text = text;
|
|
_logBox.SelectionStart = 0;
|
|
_logBox.ScrollToCaret();
|
|
}
|
|
|
|
private void RunOnUi(Action action)
|
|
{
|
|
if (IsDisposed)
|
|
return;
|
|
if (InvokeRequired)
|
|
{
|
|
try { BeginInvoke(action); }
|
|
catch (ObjectDisposedException) { }
|
|
catch (InvalidOperationException) { } // handle not created / closing
|
|
return;
|
|
}
|
|
action();
|
|
}
|
|
}
|