From 8d5a6dff20c4c784e3ae57e6a9edb026b1cfadbc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 03:30:56 +0000 Subject: [PATCH 1/8] Add pre-release deep repo review report (2026-07-07) Full read-through of production source, build, and test run ahead of the next major release. Documents 6 correctness findings (thread-marshalling inconsistency in config load, mixed UTC/local CSV timestamps, scope plotting the truncated display value, invisible --connect warning, UTC timestamp display, dead scope key mappings), robustness notes, tech debt (Terminal.Gui CS0618 set), and confirms security/release-engineering posture. 696/696 tests passing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PD9QJLoPtFsT3JTcKtB4j4 --- docs/deep-repo-review-2026-07-07.md | 153 ++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/deep-repo-review-2026-07-07.md diff --git a/docs/deep-repo-review-2026-07-07.md b/docs/deep-repo-review-2026-07-07.md new file mode 100644 index 0000000..6ee5e86 --- /dev/null +++ b/docs/deep-repo-review-2026-07-07.md @@ -0,0 +1,153 @@ +# Deep repository review — pre-release (2026-07-07) + +Scope: full read-through of all production source (`OpcUa/`, `App/`, `Configuration/`, +`Utilities/`, `Program.cs`), project files, and CI/CD workflows, plus a clean +Release build and full test run. + +**Verdict: the codebase is in good shape for a major release.** Build is clean +(0 errors), all **696 tests pass**, the security posture is solid, and the +recent hardening work (reconnect races, CSV invariance, formula-injection +neutralization, secure-by-default certificates) clearly shows. The findings +below are ordered by severity; items 1–4 are the ones worth fixing before +tagging the release. + +--- + +## Correctness findings + +### 1. UI-thread marshalling is inconsistent in `MainWindow.LoadConfigurationAsync` / `ConnectAsync` + +The codebase's own convention (e.g. `DisconnectAsync`, `OnConnectionError`, +`WriteToAddressSpaceNodeAsync`) is that continuations after `await` may resume +off the UI thread, so all UI work is wrapped in `UiThread.Run(...)`. The +config-load path violates that convention in several places: + +- `MainWindow.cs:1197-1200` — `Application.Run(pwDialog)` (password prompt) runs directly after `await _configService.LoadAsync(...)`. +- `MainWindow.cs:1240` and `MainWindow.cs:433` — `_addressSpaceView.Initialize(...)` called directly after awaited connects. +- `MainWindow.cs:1266-1273` and `MainWindow.cs:1291` — `_configService.Reset()`, `UpdateWindowTitle()`, and `MessageBox.ErrorQuery(...)` called directly after awaits. + +Either these are latent cross-thread UI calls (the CLI `--config` startup path +exercises them on every launch), or the marshalling elsewhere is unnecessary — +the two patterns can't both be right. Recommend wrapping the UI portions of +`LoadConfigurationAsync` in `UiThread.Run` (or restructuring so the dialog and +view updates happen on the UI thread) to match the rest of the file. + +### 2. CSV recordings can mix UTC and local timestamps in the same column + +`CsvRecordingManager.WriteRecord` (`Utilities/CsvRecordingManager.cs:426-427`): + +```csharp +var timestamp = item.Timestamp?.ToString("yyyy-MM-ddTHH:mm:ss.fff", ...) + ?? DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fff", ...); +``` + +`item.Timestamp` is the OPC UA `SourceTimestamp`, which is **UTC**; +the fallback `DateTime.Now` is **local time**. Rows in a single file can +therefore be hours apart for the same instant, and there is no timezone +designator to disambiguate. This undercuts the README's "locale-independent +ISO 8601" claim. Recommend normalizing both to UTC and appending `Z` +(e.g. `DateTime.UtcNow` fallback + `yyyy-MM-ddTHH:mm:ss.fffZ`, or `"O"` on a +UTC-kind value). + +### 3. Scope plots the truncated display string instead of the full-precision value + +`ScopeView.OnValueChanged` parses `node.Value` (`App/Views/ScopeView.cs:203-206`), +which is the display string produced by `SubscriptionManager.FormatValue` — +`"F2"`, i.e. quantized to two decimal places. Consequences for an app named +*opcilloscope*: + +- Any signal with amplitude below ~0.01 flatlines in the scope. +- All plotted data is stair-stepped to 0.01 resolution. +- Boolean signals ("True"/"False") are not plottable at all. + +The model already carries a lossless representation: `MonitoredNode.RawValue` +(round-trip, invariant). Recommend parsing `RawValue` (falling back to +`Value`), and optionally mapping booleans to 0/1. + +### 4. The "--connect not implemented" warning is invisible + +`Program.cs:83-88` writes the warning to stderr *after* `Application.Init()`, +so it is lost in the alternate screen buffer — the same problem the config-path +validation at `Program.cs:63-67` was explicitly moved before init to avoid. +Users passing an `opc.tcp://` URL get silence. Move the check before +`Application.Init()` (the URL is already parsed by then). + +### 5. Monitored-variable timestamps display raw UTC beside local log times + +`MonitoredNode.TimestampString` (`OpcUa/Models/MonitoredNode.cs:84`) formats the +UTC `SourceTimestamp` as bare `HH:mm:ss`, while the log pane shows local +`DateTime.Now` times. In any non-UTC timezone the "Time" column visibly +disagrees with the log for the same event. Recommend `ToLocalTime()` for +display (CSV export is a separate concern — see finding 2). + +### 6. Dead/incorrect key mappings in `ScopeView.OnKeyDown` + +`App/Views/ScopeView.cs:911-919`: `KeyCode.D0 when key.IsShift` is commented +"`+` key" but Shift+0 is `)` on US layouts; `KeyCode.D9 when key.IsShift` +(`(`) is mapped to zoom-out. Harmless in practice because the `'='`/`'+'`/`'-'` +cases handle the real keys, but the shifted-digit cases are wrong and should be +removed or corrected. + +--- + +## Robustness notes (lower priority) + +- **`OpcUaClientWrapper._session` swap race** (`OpcUa/OpcUaClientWrapper.cs:265-287, 638-660`): + `Disconnect`/`DisconnectAsync` both do `var session = _session; if (session != null) { _session = null; ... }`. + Two concurrent callers can both capture the same session and double-close/dispose it + (exceptions are swallowed, but `Disconnected` fires twice). A small lock around the + field swap (as already done for `_reconnectCts`) would close it. +- **Stuck "(reconnecting...)" rows**: if all four reconnect attempts fail, + `ConnectionManager` reports `Disconnected` but the monitored rows keep the stale + "(reconnecting...)" value until a manual reconnect. Consider marking them Bad/disconnected. +- **Doc/code mismatch**: `SubscriptionSettings.SamplingIntervalMs` doc comment says + "Valid range: 0-10000" (`Configuration/Models/OpcilloscopeConfig.cs:83`) but + `SubscriptionManager` clamps to 0–60000 (`OpcUa/SubscriptionManager.cs:57`). +- **Write support for custom data types**: `DataTypeResolver.Resolve` maps any non-ns0 + data type (including server-defined subtypes of Double etc.) to `Variant`, so writes + send the raw string and may be rejected by the server. Worth documenting as a limitation. +- **`WriteValueDialog` trims input** (`ValidateAndParse`), so a string value with + intentional leading/trailing whitespace cannot be written. +- **`NodeBrowser.GetChildrenAsync`** issues one `Read` per variable for data-type names; + a single batched `ReadAsync` would reduce chatter on large folders (the cache already + helps on repeat types). +- **`MainWindow.Dispose`** does not unhook `_connectionManager.StateChanged/ValueChanged/ + ConnectionError/AutoReconnectTriggered` or `_addressSpaceView.NodeSelected/...`. + Benign for an app-lifetime window; listed for completeness. + +## Tech debt + +- **200 CS0618 warnings** in a Release build, all from Terminal.Gui 2.4.5 deprecations + (`Application.Invoke`, `Application.AddTimeout`, static `Clipboard`, ...). These are + the announced removal set for a future Terminal.Gui release — worth a scheduled + migration to the instance-based `IApplication` APIs, and consider `TreatWarningsAsErrors` + with a curated `NoWarn` afterwards so new warnings can't accumulate silently. + +## Security posture — good + +- Certificate validation is secure-by-default; `--insecure` is an explicit, logged opt-in + decided per-certificate (`OpcUaClientWrapper.OnCertificateValidation`). +- Credentials force secure-endpoint preference, are never persisted, and a clear warning + is logged if they would travel over `SecurityMode=None`. (Consider hard-failing that + case unless `--insecure` is set, rather than warning only.) +- CSV formula injection (CWE-1236) is neutralized with a numeric-value carve-out; + RFC 4180 quoting applied after neutralization. +- Config loading caps file size (1 MB), rejects newer major versions, and normalizes + explicit JSON nulls. + +## Release engineering — good + +- CI builds, tests, publishes, and smoke-tests the self-contained binary under a PTY. +- Release workflow: 6 RIDs, per-platform smoke tests, license + third-party notices + bundled, SHA256SUMS generated, MinVer-driven versioning with the informational-version + About display already handling `+metadata` stripping. +- `InvariantGlobalization=true` plus the explicit `InvariantCulture` call sites make the + locale behavior consistent; tests cover the tricky cultures. + +## Test suite + +696/696 passing locally (Release, .NET 10). Coverage is strong across +configuration round-trips, CSV invariance/injection, subscription lifecycle, +reconnect, authentication, and the keybinding system. The scope/braille layer +has unit coverage; finding 3 above suggests adding a test asserting that +sub-0.01-amplitude signals survive into `SeriesData.Samples`. From fec4b3cc3cae7a45af76797a041c08fb8e582d05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 07:58:01 +0000 Subject: [PATCH 2/8] Fix the four pre-release review findings - CSV recording: normalize all timestamps to UTC with an explicit 'Z' designator. Previously the OPC UA SourceTimestamp (UTC) and the DateTime.Now fallback (local) could mix timezones in one file with no way to tell them apart. Kind=Unspecified is treated as UTC per the OPC UA convention; Kind=Local is converted. - Scope: sample the full-precision, culture-invariant RawValue instead of the "F2"-truncated display Value, which quantized plots to 0.01 resolution and flattened smaller-amplitude signals entirely. Booleans now plot as 0/1 so digital signals are visible. - MainWindow: marshal all UI work after awaits onto the UI thread in LoadConfigurationAsync, SaveConfigurationAsync, ConnectAsync and ReconnectAsync, matching the convention used everywhere else in the file. The password prompt now runs via a new UiThread.RunAsync helper that queues a modal dialog on the UI loop and awaits its result. - Program: print the "--connect not implemented" warning before Application.Init() so it is not lost in the alternate screen buffer (same pattern as the existing config-path validation). 710/710 tests pass; adds 14 tests covering UTC normalization, the Z designator, RawValue preference, boolean plotting and invariant parsing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PD9QJLoPtFsT3JTcKtB4j4 --- App/MainWindow.cs | 53 +++++++----- App/Views/ScopeView.cs | 30 ++++++- Program.cs | 16 ++-- .../App/Views/ScopeViewSampleTests.cs | 86 +++++++++++++++++++ .../Utilities/CsvRecordingManagerTests.cs | 37 +++++++- Utilities/CsvRecordingManager.cs | 23 +++-- Utilities/UiThread.cs | 24 ++++++ docs/deep-repo-review-2026-07-07.md | 7 ++ 8 files changed, 235 insertions(+), 41 deletions(-) create mode 100644 Tests/Opcilloscope.Tests/App/Views/ScopeViewSampleTests.cs diff --git a/App/MainWindow.cs b/App/MainWindow.cs index ba618e6..49350f8 100644 --- a/App/MainWindow.cs +++ b/App/MainWindow.cs @@ -430,7 +430,8 @@ private async Task ConnectAsync(string endpoint, int publishingInterval = 250, C if (success) { _lastEndpoint = endpoint; - _addressSpaceView.Initialize(_connectionManager.NodeBrowser); + // The connect continuation may resume off the UI thread. + UiThread.Run(() => _addressSpaceView.Initialize(_connectionManager.NodeBrowser)); } } finally @@ -480,7 +481,8 @@ private async Task ReconnectAsync() if (success) { - _addressSpaceView.Initialize(_connectionManager.NodeBrowser); + // The reconnect continuation may resume off the UI thread. + UiThread.Run(() => _addressSpaceView.Initialize(_connectionManager.NodeBrowser)); _logger.Info("Reconnected successfully - subscriptions restored"); } else @@ -1194,18 +1196,24 @@ private async Task LoadConfigurationAsync(string filePath) if (authType == AuthenticationType.UserName && !string.IsNullOrEmpty(config.Server.Authentication.Username)) { - using var pwDialog = new PasswordPromptDialog( - config.Server.Authentication.Username, - config.Server.EndpointUrl); - Application.Run(pwDialog); - - if (!pwDialog.Confirmed) + // This continuation may resume off the UI thread, so run the + // modal prompt via the UI loop and await its outcome. + var (confirmed, password) = await UiThread.RunAsync(() => + { + using var pwDialog = new PasswordPromptDialog( + config.Server.Authentication.Username, + config.Server.EndpointUrl); + Application.Run(pwDialog); + return (pwDialog.Confirmed, pwDialog.Password); + }); + + if (!confirmed) { // Nothing was torn down, but the load already switched the Ctrl+S // target to this file; revert to untitled so a save cannot write // the still-running session's state over it. _configService.Reset(); - UpdateWindowTitle(); + UiThread.Run(UpdateWindowTitle); _logger.Info("Password prompt cancelled - skipping connection"); return; } @@ -1213,7 +1221,7 @@ private async Task LoadConfigurationAsync(string filePath) credentials = new ConnectionCredentials( AuthenticationType.UserName, config.Server.Authentication.Username, - pwDialog.Password); + password); } // Tear down the current session first: stops any active recording and @@ -1237,7 +1245,7 @@ private async Task LoadConfigurationAsync(string filePath) _lastEndpoint = config.Server.EndpointUrl; _currentMetadata = config.Metadata; - _addressSpaceView.Initialize(_connectionManager.NodeBrowser); + UiThread.Run(() => _addressSpaceView.Initialize(_connectionManager.NodeBrowser)); // Subscribe to saved nodes foreach (var node in config.MonitoredNodes.Where(n => n.Enabled)) @@ -1254,7 +1262,7 @@ private async Task LoadConfigurationAsync(string filePath) } _recentFiles.Add(filePath); - UpdateWindowTitle(); + UiThread.Run(UpdateWindowTitle); var nodeCount = config.MonitoredNodes.Count(n => n.Enabled); _logger.Info($"Configuration loaded: {nodeCount} nodes"); @@ -1265,12 +1273,15 @@ private async Task LoadConfigurationAsync(string filePath) // would let a save overwrite it with the now-empty session state. _configService.Reset(); _currentMetadata = null; - UpdateWindowTitle(); _logger.Error($"Failed to connect to {config.Server.EndpointUrl}"); - MessageBox.ErrorQuery(Application.Instance, "Connection Failed", - $"Could not connect to server:\n{config.Server.EndpointUrl}\n\nThe previous connection has been closed. Use Connect to reconnect.", - "OK"); + UiThread.Run(() => + { + UpdateWindowTitle(); + MessageBox.ErrorQuery(Application.Instance, "Connection Failed", + $"Could not connect to server:\n{config.Server.EndpointUrl}\n\nThe previous connection has been closed. Use Connect to reconnect.", + "OK"); + }); } } else @@ -1281,14 +1292,15 @@ private async Task LoadConfigurationAsync(string filePath) _currentMetadata = config.Metadata; _recentFiles.Add(filePath); - UpdateWindowTitle(); + UiThread.Run(UpdateWindowTitle); _logger.Info("Configuration loaded (no server connection)"); } } catch (Exception ex) { _logger.Error($"Failed to load configuration: {ex.Message}"); - MessageBox.ErrorQuery(Application.Instance, "Error", $"Failed to load configuration:\n{ex.Message}", "OK"); + UiThread.Run(() => + MessageBox.ErrorQuery(Application.Instance, "Error", $"Failed to load configuration:\n{ex.Message}", "OK")); } finally { @@ -1326,14 +1338,15 @@ private async Task SaveConfigurationAsync(string filePath) _currentMetadata = config.Metadata; _recentFiles.Add(filePath); - UpdateWindowTitle(); + UiThread.Run(UpdateWindowTitle); _logger.Info($"Configuration saved to {filePath}"); } catch (Exception ex) { _logger.Error($"Failed to save configuration: {ex.Message}"); - MessageBox.ErrorQuery(Application.Instance, "Error", $"Failed to save:\n{ex.Message}", "OK"); + UiThread.Run(() => + MessageBox.ErrorQuery(Application.Instance, "Error", $"Failed to save:\n{ex.Message}", "OK")); } finally { diff --git a/App/Views/ScopeView.cs b/App/Views/ScopeView.cs index 69784d7..2c0fdad 100644 --- a/App/Views/ScopeView.cs +++ b/App/Views/ScopeView.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Terminal.Gui; using Opcilloscope.OpcUa; using Opcilloscope.OpcUa.Models; @@ -169,7 +170,7 @@ public void BindToNodes(IReadOnlyList nodes, SubscriptionManager }; // Try to parse current value as initial sample - if (TryParseValue(node.Value, out var value)) + if (TryGetSample(node, out var value)) { series.Samples.Add(new TimestampedSample(DateTime.Now, value)); series.CurrentValue = value; @@ -201,7 +202,7 @@ private void OnValueChanged(MonitoredNode node) lock (_lock) { var series = _series.FirstOrDefault(s => s.Node.ClientHandle == node.ClientHandle); - if (series != null && TryParseValue(node.Value, out var value)) + if (series != null && TryGetSample(node, out var value)) { var sample = new TimestampedSample(DateTime.Now, value); series.Samples.Add(sample); @@ -965,7 +966,20 @@ private static string FormatAxisValue(float value) return value.ToString("F2"); } - private static bool TryParseValue(string? valueStr, out float value) + /// + /// Extracts a plottable sample from a node. Prefers the full-precision, + /// culture-invariant over the display + /// , which is truncated to two decimals + /// ("F2") and would quantize the plot to 0.01 resolution — flattening any + /// signal with a smaller amplitude entirely. + /// + internal static bool TryGetSample(MonitoredNode node, out float value) + { + var source = string.IsNullOrEmpty(node.RawValue) ? node.Value : node.RawValue; + return TryParseValue(source, out value); + } + + internal static bool TryParseValue(string? valueStr, out float value) { value = 0; if (string.IsNullOrWhiteSpace(valueStr)) @@ -975,7 +989,15 @@ private static bool TryParseValue(string? valueStr, out float value) if (str.StartsWith("(") && str.EndsWith(")")) return false; - return float.TryParse(str, out value); + // Booleans plot as 0/1 so digital signals are visible on the scope. + if (bool.TryParse(str, out var boolean)) + { + value = boolean ? 1f : 0f; + return true; + } + + // RawValue is culture-invariant, so parse with the matching culture. + return float.TryParse(str, NumberStyles.Float, CultureInfo.InvariantCulture, out value); } protected override void Dispose(bool disposing) diff --git a/Program.cs b/Program.cs index acaab5b..090a9e3 100644 --- a/Program.cs +++ b/Program.cs @@ -66,6 +66,15 @@ static int Main(string[] args) return 1; } + // Warn about unimplemented auto-connect before initializing the terminal; + // once the alternate screen buffer is active the message would be lost. + if (string.IsNullOrEmpty(configPath) && !string.IsNullOrEmpty(autoConnectUrl)) + { + Console.Error.WriteLine( + $"Warning: Auto-connect via command-line URL ('{autoConnectUrl}') is not currently implemented. " + + "Please use a configuration file with an endpoint URL instead."); + } + #pragma warning disable IL2026 // Terminal.Gui Application.Init uses reflection and is not AOT-compatible Application.Init(); #pragma warning restore IL2026 @@ -79,13 +88,6 @@ static int Main(string[] args) { mainWindow.LoadConfigFromCommandLine(configPath); } - // Otherwise, if auto-connect URL provided, show warning (not yet implemented) - else if (!string.IsNullOrEmpty(autoConnectUrl)) - { - Console.Error.WriteLine( - $"Warning: Auto-connect via command-line URL ('{autoConnectUrl}') is not currently implemented. " + - "Please use a configuration file with an endpoint URL instead."); - } Application.Run(mainWindow); } diff --git a/Tests/Opcilloscope.Tests/App/Views/ScopeViewSampleTests.cs b/Tests/Opcilloscope.Tests/App/Views/ScopeViewSampleTests.cs new file mode 100644 index 0000000..9ebe5e7 --- /dev/null +++ b/Tests/Opcilloscope.Tests/App/Views/ScopeViewSampleTests.cs @@ -0,0 +1,86 @@ +using Opc.Ua; +using Opcilloscope.App.Views; +using Opcilloscope.OpcUa.Models; + +namespace Opcilloscope.Tests.App.Views; + +/// +/// Tests for ScopeView's sample extraction: the scope must plot the +/// full-precision RawValue rather than the "F2"-truncated display Value, +/// and must render booleans as 0/1. +/// +public class ScopeViewSampleTests +{ + private static MonitoredNode Node(string value, string rawValue = "") => new() + { + NodeId = new NodeId(1234), + DisplayName = "TestNode", + Value = value, + RawValue = rawValue + }; + + [Fact] + public void TryGetSample_PrefersRawValueOverDisplayValue() + { + // Display value is truncated to two decimals; RawValue is lossless. + var node = Node(value: "0.00", rawValue: "0.0042"); + + Assert.True(ScopeView.TryGetSample(node, out var sample)); + Assert.Equal(0.0042f, sample, precision: 6); + } + + [Fact] + public void TryGetSample_SubCentAmplitudeSignal_SurvivesIntoSample() + { + // A signal with amplitude below 0.01 flatlined when the "F2" display + // string was parsed; RawValue preserves it. + var node = Node(value: "0.00", rawValue: "0.005"); + + Assert.True(ScopeView.TryGetSample(node, out var sample)); + Assert.NotEqual(0f, sample); + } + + [Fact] + public void TryGetSample_FallsBackToDisplayValue_WhenRawValueIsEmpty() + { + var node = Node(value: "42.50"); + + Assert.True(ScopeView.TryGetSample(node, out var sample)); + Assert.Equal(42.5f, sample, precision: 4); + } + + [Theory] + [InlineData("True", 1f)] + [InlineData("False", 0f)] + [InlineData("true", 1f)] + [InlineData("false", 0f)] + public void TryGetSample_BooleanValues_PlotAsZeroOrOne(string raw, float expected) + { + var node = Node(value: raw, rawValue: raw); + + Assert.True(ScopeView.TryGetSample(node, out var sample)); + Assert.Equal(expected, sample); + } + + [Theory] + [InlineData("(pending)")] + [InlineData("(reconnecting...)")] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-number")] + public void TryGetSample_NonNumericValues_AreRejected(string raw) + { + var node = Node(value: raw, rawValue: raw); + + Assert.False(ScopeView.TryGetSample(node, out _)); + } + + [Fact] + public void TryParseValue_UsesInvariantDecimalSeparator() + { + // RawValue is culture-invariant ('.' decimal separator); parsing must + // match regardless of the ambient culture. + Assert.True(ScopeView.TryParseValue("3.14", out var value)); + Assert.Equal(3.14f, value, precision: 4); + } +} diff --git a/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs b/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs index 891a16f..162dc0f 100644 --- a/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs +++ b/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs @@ -280,8 +280,36 @@ public void RecordValue_UsesIso8601TimestampFormat() // Assert var content = File.ReadAllText(filePath); - // Should use ISO 8601 format with T separator - Assert.Contains("2026-01-06T14:30:45.678", content); + // Should use ISO 8601 format with T separator and UTC 'Z' designator + // (Kind=Unspecified is treated as UTC per the OPC UA convention). + Assert.Contains("2026-01-06T14:30:45.678Z", content); + } + + [Fact] + public void RecordValue_LocalKindTimestamp_IsConvertedToUtc() + { + // Arrange - a Kind=Local timestamp must be converted to UTC before + // formatting so a single file never mixes timezones. + var filePath = Path.Combine(_testDirectory, "test.csv"); + _manager.StartRecording(filePath); + var localTime = new DateTime(2026, 1, 6, 14, 30, 45, 678, DateTimeKind.Local); + var expected = localTime.ToUniversalTime() + .ToString("yyyy-MM-ddTHH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); + var node = new MonitoredNode + { + DisplayName = "TestNode", + NodeId = new NodeId(1234), + Value = "100", + Timestamp = localTime + }; + + // Act + _manager.RecordValue(node); + _manager.StopRecording(); + + // Assert + var content = File.ReadAllText(filePath); + Assert.Contains(expected, content); } [Fact] @@ -604,8 +632,9 @@ public void RecordValue_NullTimestampFallback_IsIso8601_UnderFinnishCulture() var lines = File.ReadAllLines(filePath); Assert.True(lines.Length >= 2); var timestamp = lines[1].Split(',')[0]; - // ISO 8601: 'T' separator and ':' time separators (fi-FI would emit '.') - Assert.Matches(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$", timestamp); + // ISO 8601 UTC: 'T' separator, ':' time separators (fi-FI would + // emit '.') and a 'Z' designator on the normalized-to-UTC instant. + Assert.Matches(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$", timestamp); }); } diff --git a/Utilities/CsvRecordingManager.cs b/Utilities/CsvRecordingManager.cs index a191f2d..c82f0b9 100644 --- a/Utilities/CsvRecordingManager.cs +++ b/Utilities/CsvRecordingManager.cs @@ -7,10 +7,11 @@ namespace Opcilloscope.Utilities; /// /// Manages CSV recording of monitored variable value changes. /// Writes data to file in real-time as values change using a background queue. -/// Output is culture-invariant: timestamps are ISO 8601 (Gregorian calendar, -/// '.' decimal / ':' time separators regardless of locale) and values are the -/// full-precision raw representation ('.' decimal separator, arrays as -/// semicolon-joined elements) rather than the truncated UI display string. +/// Output is culture-invariant: timestamps are ISO 8601 UTC with a 'Z' +/// designator (Gregorian calendar, '.' decimal / ':' time separators +/// regardless of locale) and values are the full-precision raw representation +/// ('.' decimal separator, arrays as semicolon-joined elements) rather than +/// the truncated UI display string. /// public class CsvRecordingManager : IDisposable { @@ -423,8 +424,18 @@ private void WriteRecord(RecordSnapshot item) // is replaced by the culture's time separator (fi-FI uses '.') // and the culture's default calendar applies (th-TH uses the // Buddhist calendar), which would break the ISO 8601 contract. - var timestamp = item.Timestamp?.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture) - ?? DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture); + // All timestamps are normalized to UTC with an explicit 'Z' + // designator: OPC UA source timestamps are UTC while the + // no-timestamp fallback used to be local time, so a single file + // could silently mix timezones with no way to tell them apart. + var ts = item.Timestamp ?? DateTime.UtcNow; + if (ts.Kind == DateTimeKind.Local) + { + ts = ts.ToUniversalTime(); + } + // Kind=Unspecified is treated as UTC (the OPC UA convention) + // rather than local, so the recorded instant never shifts. + var timestamp = ts.ToString("yyyy-MM-ddTHH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); // Escape values for CSV (RFC 4180 quoting plus formula // injection neutralization for server-supplied fields). diff --git a/Utilities/UiThread.cs b/Utilities/UiThread.cs index d01433e..b608c18 100644 --- a/Utilities/UiThread.cs +++ b/Utilities/UiThread.cs @@ -14,4 +14,28 @@ public static void Run(Action action) { Application.Invoke(action); } + + /// + /// Executes a function on the UI thread and asynchronously returns its result. + /// Use from async continuations (which may resume on a background thread) when + /// the result is needed before proceeding — e.g. running a modal dialog. + /// Safe to call from the UI thread itself: the work is queued for a later + /// iteration of the main loop and awaited rather than blocked on. + /// + public static Task RunAsync(Func func) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Application.Invoke(() => + { + try + { + tcs.SetResult(func()); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + return tcs.Task; + } } diff --git a/docs/deep-repo-review-2026-07-07.md b/docs/deep-repo-review-2026-07-07.md index 6ee5e86..ccf69e7 100644 --- a/docs/deep-repo-review-2026-07-07.md +++ b/docs/deep-repo-review-2026-07-07.md @@ -11,6 +11,13 @@ neutralization, secure-by-default certificates) clearly shows. The findings below are ordered by severity; items 1–4 are the ones worth fixing before tagging the release. +> **Update (same day):** findings 1–4 are now **fixed on this branch** +> (see the follow-up commit). CSV timestamps are UTC with a `Z` designator, +> the scope samples `RawValue` (and plots booleans as 0/1), the config-load +> path marshals all UI work via `UiThread.Run`/`UiThread.RunAsync`, and the +> `--connect` warning prints before terminal init. 710/710 tests pass, +> including 14 new tests covering the fixes. + --- ## Correctness findings From 6a04697d4abc26a74d39f8ea20501e649b05c3f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 12:55:07 +0000 Subject: [PATCH 3/8] Centralize Terminal.Gui static Application access behind helpers Route all UI-layer access to the deprecated static Application object through a small helper surface in Utilities/, alongside the existing UiThread.Run wrapper: - TerminalUi.AddTimeout/RemoveTimeout for main-loop timers - TerminalUi.RunModal/RequestStop for modal dialogs - TerminalUi.Query/ErrorQuery so call sites stop passing Application.Instance to MessageBox - TerminalUi.TrySetClipboardData for the static Clipboard class - TerminalUi.TopRunnableView/IsTopRunnable for top-level view queries All call sites in App/ now use the helpers; behavior is unchanged. The remaining direct uses of the deprecated statics are confined to TerminalUi/UiThread plus the entry points that need the application lifecycle itself (Program.cs Init/Run/Shutdown, MainWindow Application.KeyDown, ThemeManager Application.Driver), which the next commit migrates to the instance-based IApplication API. CS0618 count: 101 -> 27, now concentrated in 5 files. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Cb6n3buYyULFMq4AursDkF --- App/Dialogs/ConnectDialog.cs | 17 +++--- App/Dialogs/HelpDialog.cs | 3 +- App/Dialogs/OpenConfigDialog.cs | 9 +-- App/Dialogs/PasswordPromptDialog.cs | 5 +- App/Dialogs/SaveConfigDialog.cs | 17 +++--- App/Dialogs/SaveRecordingDialog.cs | 12 ++-- App/Dialogs/ScopeDialog.cs | 7 ++- App/Dialogs/WriteValueDialog.cs | 8 +-- App/FocusManager.cs | 7 ++- App/MainWindow.cs | 66 +++++++++++----------- App/Views/AddressSpaceView.cs | 7 ++- App/Views/LogView.cs | 4 +- App/Views/MonitoredVariablesView.cs | 9 +-- App/Views/NodeDetailsView.cs | 18 +++--- App/Views/ScopeView.cs | 6 +- Utilities/TerminalUi.cs | 87 +++++++++++++++++++++++++++++ 16 files changed, 189 insertions(+), 93 deletions(-) create mode 100644 Utilities/TerminalUi.cs diff --git a/App/Dialogs/ConnectDialog.cs b/App/Dialogs/ConnectDialog.cs index 78272f2..02f9840 100644 --- a/App/Dialogs/ConnectDialog.cs +++ b/App/Dialogs/ConnectDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Themes; using Opcilloscope.OpcUa; using AppThemeManager = Opcilloscope.App.Themes.ThemeManager; @@ -174,7 +175,7 @@ public ConnectDialog( if (ValidateInput()) { _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } }; @@ -188,7 +189,7 @@ public ConnectDialog( cancelButton.Accepting += (_, _) => { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); }; Add(endpointLabel, protocolLabel, _endpointField, @@ -206,7 +207,7 @@ private bool ValidateInput() if (string.IsNullOrEmpty(serverAddress)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a server address", "OK"); + TerminalUi.ErrorQuery("Error", "Please enter a server address", "OK"); return false; } @@ -215,20 +216,20 @@ private bool ValidateInput() var uri = new Uri(EndpointUrl); if (string.IsNullOrEmpty(uri.Host)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Invalid host in server address", "OK"); + TerminalUi.ErrorQuery("Error", "Invalid host in server address", "OK"); return false; } } catch { - MessageBox.ErrorQuery(Application.Instance, "Error", "Invalid server address format", "OK"); + TerminalUi.ErrorQuery("Error", "Invalid server address format", "OK"); return false; } var interval = _publishIntervalField.Value; if (interval < 100 || interval > 10000) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Publishing interval must be between 100 and 10000 ms", "OK"); + TerminalUi.ErrorQuery("Error", "Publishing interval must be between 100 and 10000 ms", "OK"); return false; } @@ -237,7 +238,7 @@ private bool ValidateInput() var username = _usernameField.Text?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(username)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a username", "OK"); + TerminalUi.ErrorQuery("Error", "Please enter a username", "OK"); _usernameField.SetFocus(); return false; } @@ -245,7 +246,7 @@ private bool ValidateInput() var password = _passwordField.Text ?? string.Empty; if (string.IsNullOrEmpty(password)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a password", "OK"); + TerminalUi.ErrorQuery("Error", "Please enter a password", "OK"); _passwordField.SetFocus(); return false; } diff --git a/App/Dialogs/HelpDialog.cs b/App/Dialogs/HelpDialog.cs index ee3ac5a..54e68a7 100644 --- a/App/Dialogs/HelpDialog.cs +++ b/App/Dialogs/HelpDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Keybindings; using Opcilloscope.App.Themes; using ThemeManager = Opcilloscope.App.Themes.ThemeManager; @@ -121,7 +122,7 @@ private static string GenerateHelpFromBindings(KeybindingManager manager) private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { SetScheme(theme.MainColorScheme); BorderStyle = theme.EmphasizedBorderStyle; diff --git a/App/Dialogs/OpenConfigDialog.cs b/App/Dialogs/OpenConfigDialog.cs index 79b3728..d702dd0 100644 --- a/App/Dialogs/OpenConfigDialog.cs +++ b/App/Dialogs/OpenConfigDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Themes; using Opcilloscope.Configuration; using AppThemeManager = Opcilloscope.App.Themes.ThemeManager; @@ -90,7 +91,7 @@ public OpenConfigDialog() cancelButton.Accepting += (_, _) => { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); }; Add(_directoryLabel, _fileListView, openButton, browseButton, cancelButton); @@ -121,7 +122,7 @@ private void Confirm() { SelectedFilePath = _files[_fileListView.SelectedItem!.Value].FullName; _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } } @@ -139,13 +140,13 @@ private void OnBrowse(object? sender, CommandEventArgs e) Path = ConfigurationService.GetDefaultConfigDirectory() }; - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (!dialog.Canceled && dialog.Path != null) { SelectedFilePath = dialog.Path.ToString()!; _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } } } diff --git a/App/Dialogs/PasswordPromptDialog.cs b/App/Dialogs/PasswordPromptDialog.cs index 7b1c8ea..d1ff098 100644 --- a/App/Dialogs/PasswordPromptDialog.cs +++ b/App/Dialogs/PasswordPromptDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Themes; using AppThemeManager = Opcilloscope.App.Themes.ThemeManager; @@ -61,7 +62,7 @@ public PasswordPromptDialog(string username, string endpoint) okButton.Accepting += (_, _) => { _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); }; var cancelButton = new Button @@ -74,7 +75,7 @@ public PasswordPromptDialog(string username, string endpoint) cancelButton.Accepting += (_, _) => { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); }; Add(promptLabel, endpointLabel, _passwordField, okButton, cancelButton); diff --git a/App/Dialogs/SaveConfigDialog.cs b/App/Dialogs/SaveConfigDialog.cs index 264de06..cffad25 100644 --- a/App/Dialogs/SaveConfigDialog.cs +++ b/App/Dialogs/SaveConfigDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Themes; using Opcilloscope.Configuration; using AppThemeManager = Opcilloscope.App.Themes.ThemeManager; @@ -161,7 +162,7 @@ private void OnBrowseDirectory(object? sender, CommandEventArgs e) // We'll let user navigate to any directory and extract the directory path }; - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (!dialog.Canceled && dialog.Path != null) { @@ -198,13 +199,13 @@ private void OnSave(object? sender, CommandEventArgs e) return; _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } private void OnCancel(object? sender, CommandEventArgs e) { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); } private bool ValidateSave() @@ -213,7 +214,7 @@ private bool ValidateSave() var filename = _currentFilename.Trim(); if (string.IsNullOrEmpty(filename)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a filename", "OK"); + TerminalUi.ErrorQuery("Error", "Please enter a filename", "OK"); return false; } @@ -221,7 +222,7 @@ private bool ValidateSave() var invalidChars = Path.GetInvalidFileNameChars(); if (filename.IndexOfAny(invalidChars) >= 0) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Filename contains invalid characters", "OK"); + TerminalUi.ErrorQuery("Error", "Filename contains invalid characters", "OK"); return false; } @@ -229,7 +230,7 @@ private bool ValidateSave() var directory = _currentDirectory.Trim(); if (string.IsNullOrEmpty(directory)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please specify a directory", "OK"); + TerminalUi.ErrorQuery("Error", "Please specify a directory", "OK"); return false; } @@ -243,7 +244,7 @@ private bool ValidateSave() } catch (Exception ex) { - MessageBox.ErrorQuery(Application.Instance, "Error", $"Cannot create directory: {ex.Message}", "OK"); + TerminalUi.ErrorQuery("Error", $"Cannot create directory: {ex.Message}", "OK"); return false; } @@ -251,7 +252,7 @@ private bool ValidateSave() var fullPath = FilePath; if (File.Exists(fullPath)) { - var result = MessageBox.Query(Application.Instance, "Confirm Overwrite", + var result = TerminalUi.Query("Confirm Overwrite", $"File '{Path.GetFileName(fullPath)}' already exists.\nDo you want to replace it?", "Yes", "No"); if (result != 0) // "No" selected diff --git a/App/Dialogs/SaveRecordingDialog.cs b/App/Dialogs/SaveRecordingDialog.cs index ba82f1f..cbef01a 100644 --- a/App/Dialogs/SaveRecordingDialog.cs +++ b/App/Dialogs/SaveRecordingDialog.cs @@ -114,7 +114,7 @@ public SaveRecordingDialog(string defaultDirectory, string defaultFilename) if (ValidateAndSetPath()) { _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } }; @@ -128,7 +128,7 @@ public SaveRecordingDialog(string defaultDirectory, string defaultFilename) cancelButton.Accepting += (_, _) => { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); }; Add(directoryLabel, _directoryField, fileListLabel, _fileListView, @@ -181,7 +181,7 @@ private void LoadDirectory(string directory) } catch (Exception ex) { - MessageBox.ErrorQuery(Application.Instance, "Error", $"Cannot access directory:\n{ex.Message}", "OK"); + TerminalUi.ErrorQuery("Error", $"Cannot access directory:\n{ex.Message}", "OK"); } } @@ -248,7 +248,7 @@ private bool ValidateAndSetPath() if (string.IsNullOrEmpty(filename)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a filename", "OK"); + TerminalUi.ErrorQuery("Error", "Please enter a filename", "OK"); return false; } @@ -259,7 +259,7 @@ private bool ValidateAndSetPath() var invalidChars = Path.GetInvalidFileNameChars(); if (filename.IndexOfAny(invalidChars) >= 0) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Filename contains invalid characters", "OK"); + TerminalUi.ErrorQuery("Error", "Filename contains invalid characters", "OK"); return false; } @@ -268,7 +268,7 @@ private bool ValidateAndSetPath() // Check if file already exists if (File.Exists(fullPath)) { - var result = MessageBox.Query(Application.Instance, "Confirm Overwrite", + var result = TerminalUi.Query("Confirm Overwrite", $"File already exists:\n{filename}\n\nOverwrite?", "Yes", "No"); if (result != 0) diff --git a/App/Dialogs/ScopeDialog.cs b/App/Dialogs/ScopeDialog.cs index 312f61c..04e094a 100644 --- a/App/Dialogs/ScopeDialog.cs +++ b/App/Dialogs/ScopeDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Views; using Opcilloscope.App.Themes; using Opcilloscope.OpcUa; @@ -63,7 +64,7 @@ public ScopeDialog( Y = 0, Text = $"{Theme.ButtonPrefix}CLOSE{Theme.ButtonSuffix}", }.WithScheme(Theme.ButtonColorScheme); - _closeButton.Accepting += (_, _) => Application.RequestStop(); + _closeButton.Accepting += (_, _) => TerminalUi.RequestStop(); buttonFrame.Add(_pauseButton, _closeButton); @@ -88,7 +89,7 @@ private void OnPauseToggle(object? _, CommandEventArgs _1) private void OnPauseStateChanged(bool isPaused) { - Application.Invoke(() => + UiThread.Run(() => { _pauseButton.Text = isPaused ? $"{Theme.ButtonPrefix}RESUME{Theme.ButtonSuffix}" @@ -98,7 +99,7 @@ private void OnPauseStateChanged(bool isPaused) private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { Title = $"{theme.TitleDecoration}[ SCOPE ]{theme.TitleDecoration}"; ThemeStyler.ApplyToDialog(this, theme); diff --git a/App/Dialogs/WriteValueDialog.cs b/App/Dialogs/WriteValueDialog.cs index 8034bb7..d8381e8 100644 --- a/App/Dialogs/WriteValueDialog.cs +++ b/App/Dialogs/WriteValueDialog.cs @@ -148,7 +148,7 @@ public WriteValueDialog(NodeId nodeId, string nodeName, BuiltInType dataType, st if (ValidateAndParse()) { // Show confirmation dialog before writing - var confirmResult = MessageBox.Query(Application.Instance, + var confirmResult = TerminalUi.Query( "Confirm Write", $"Write '{_valueField.Text}' to {nodeName}?", "Yes", "No"); @@ -156,7 +156,7 @@ public WriteValueDialog(NodeId nodeId, string nodeName, BuiltInType dataType, st if (confirmResult == 0) // Yes was selected { _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } } }; @@ -164,7 +164,7 @@ public WriteValueDialog(NodeId nodeId, string nodeName, BuiltInType dataType, st cancelButton.Accepting += (_, _) => { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); }; // Add all controls @@ -210,7 +210,7 @@ private bool ValidateAndParse() // Check if write is supported for this data type if (!OpcValueConverter.IsWriteSupported(_dataType)) { - MessageBox.ErrorQuery(Application.Instance, "Write Error", $"Write not supported for data type: {_dataType}", "OK"); + TerminalUi.ErrorQuery("Write Error", $"Write not supported for data type: {_dataType}", "OK"); return false; } diff --git a/App/FocusManager.cs b/App/FocusManager.cs index b434fd0..3507009 100644 --- a/App/FocusManager.cs +++ b/App/FocusManager.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; namespace Opcilloscope.App; @@ -36,7 +37,7 @@ public FocusManager(params View[] panes) /// public void StartTracking() { - _pollTimer = Application.AddTimeout(TimeSpan.FromMilliseconds(100), PollFocus); + _pollTimer = TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(100), PollFocus); } /// @@ -46,14 +47,14 @@ public void StopTracking() { if (_pollTimer != null) { - Application.RemoveTimeout(_pollTimer); + TerminalUi.RemoveTimeout(_pollTimer); _pollTimer = null; } } private bool PollFocus() { - var focused = Application.TopRunnableView?.MostFocused; + var focused = TerminalUi.TopRunnableView?.MostFocused; var newPane = FindContainingPane(focused); if (newPane != _currentPane) diff --git a/App/MainWindow.cs b/App/MainWindow.cs index ba618e6..1b4f934 100644 --- a/App/MainWindow.cs +++ b/App/MainWindow.cs @@ -245,7 +245,7 @@ private void RunStatusBarStartup() }); UpdateConnectionStatusLabelPosition(); - _startupStatusTimer = Application.AddTimeout(TimeSpan.FromSeconds(1), () => + _startupStatusTimer = TerminalUi.AddTimeout(TimeSpan.FromSeconds(1), () => { step++; if (step == 1) @@ -404,7 +404,7 @@ private void ShowConnectDialog() currentInterval, currentCredentials.Type, currentCredentials.Username); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (dialog.Confirmed) { @@ -554,14 +554,14 @@ private void WriteToMonitoredVariable(MonitoredNode variable) if (!variable.IsWritable) { _logger.Warning($"Node '{variable.DisplayName}' is not writable"); - MessageBox.ErrorQuery(Application.Instance, "Write", $"Node '{variable.DisplayName}' is not writable.", "OK"); + TerminalUi.ErrorQuery("Write", $"Node '{variable.DisplayName}' is not writable.", "OK"); return; } if (!OpcValueConverter.IsWriteSupported(variable.DataType)) { _logger.Warning($"Write not supported for data type {variable.DataType}"); - MessageBox.ErrorQuery(Application.Instance, "Write", $"Write not supported for data type: {variable.DataType}", "OK"); + TerminalUi.ErrorQuery("Write", $"Write not supported for data type: {variable.DataType}", "OK"); return; } @@ -610,14 +610,14 @@ private async Task WriteToAddressSpaceNodeAsync(BrowsedNode node) if ((accessLevel & Opc.Ua.AccessLevels.CurrentWrite) == 0) { _logger.Warning($"Node '{node.DisplayName}' is not writable"); - UiThread.Run(() => MessageBox.ErrorQuery(Application.Instance, "Write", $"Node '{node.DisplayName}' is not writable.", "OK")); + UiThread.Run(() => TerminalUi.ErrorQuery("Write", $"Node '{node.DisplayName}' is not writable.", "OK")); return; } if (!OpcValueConverter.IsWriteSupported(builtInType)) { _logger.Warning($"Write not supported for data type {builtInType}"); - UiThread.Run(() => MessageBox.ErrorQuery(Application.Instance, "Write", $"Write not supported for data type: {builtInType}", "OK")); + UiThread.Run(() => TerminalUi.ErrorQuery("Write", $"Write not supported for data type: {builtInType}", "OK")); return; } @@ -627,7 +627,7 @@ private async Task WriteToAddressSpaceNodeAsync(BrowsedNode node) private void OpenWriteDialogAndWrite(Opc.Ua.NodeId nodeId, string displayName, Opc.Ua.BuiltInType dataType, string dataTypeName, string? currentValue) { using var dialog = new WriteValueDialog(nodeId, displayName, dataType, dataTypeName, currentValue); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (!dialog.Confirmed || dialog.ParsedValue == null) return; @@ -747,7 +747,7 @@ private void UpdateStatusBarShortcuts() private void OnApplicationKeyDown(object? sender, Key e) { if (e.Handled) return; - if (Application.TopRunnable != this) return; // Don't fire during dialogs + if (!TerminalUi.IsTopRunnable(this)) return; // Don't fire during dialogs if (IsViewNavigationKey(e)) return; // Let Enter/Space/etc reach local handlers @@ -835,7 +835,7 @@ private void OnConnectionError(string message) { UiThread.Run(() => { - MessageBox.ErrorQuery(Application.Instance, "Connection Error", message, "OK"); + TerminalUi.ErrorQuery("Connection Error", message, "OK"); }); } @@ -906,7 +906,7 @@ private void StartConnectingAnimation() { _isConnecting = true; _connectingDotCount = 1; - _connectingAnimationTimer = Application.AddTimeout(TimeSpan.FromMilliseconds(400), () => + _connectingAnimationTimer = TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(400), () => { if (!_isConnecting) return false; // Stop animation @@ -925,7 +925,7 @@ private void StopConnectingAnimation() _isConnecting = false; if (_connectingAnimationTimer != null) { - Application.RemoveTimeout(_connectingAnimationTimer); + TerminalUi.RemoveTimeout(_connectingAnimationTimer); _connectingAnimationTimer = null; } } @@ -957,7 +957,7 @@ private void LaunchScope() { if (_connectionManager.SubscriptionManager == null) { - MessageBox.Query(Application.Instance, "Scope", "Connect to a server first.", "OK"); + TerminalUi.Query("Scope", "Connect to a server first.", "OK"); return; } @@ -965,12 +965,12 @@ private void LaunchScope() if (selectedNodes.Count == 0) { - MessageBox.Query(Application.Instance, "Scope", "Select up to 5 nodes to display in Scope.\nUse Space to toggle selection on monitored variables.", "OK"); + TerminalUi.Query("Scope", "Select up to 5 nodes to display in Scope.\nUse Space to toggle selection on monitored variables.", "OK"); return; } using var dialog = new ScopeDialog(selectedNodes, _connectionManager.SubscriptionManager); - Application.Run(dialog); + TerminalUi.RunModal(dialog); } private void OnRecordRequested() @@ -984,7 +984,7 @@ private void OnRecordRequested() var subscriptionManager = _connectionManager.SubscriptionManager; if (subscriptionManager == null || !subscriptionManager.MonitoredVariables.Any()) { - MessageBox.Query(Application.Instance, "Record", "No variables to record. Subscribe to variables first.", "OK"); + TerminalUi.Query("Record", "No variables to record. Subscribe to variables first.", "OK"); return; } @@ -992,7 +992,7 @@ private void OnRecordRequested() var selectedCount = _monitoredVariablesView.ScopeSelectionCount; if (selectedCount == 0) { - MessageBox.Query(Application.Instance, "Record", + TerminalUi.Query("Record", "No variables selected for recording.\n\n" + "Use Space to select variables in the Sel column (◉).\n" + "Selected variables will be recorded and shown in Scope.", "OK"); @@ -1006,7 +1006,7 @@ private void OnRecordRequested() selectedCount); using var dialog = new SaveRecordingDialog(defaultDir, defaultFilename); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (dialog.Confirmed && dialog.FilePath != null) { @@ -1017,7 +1017,7 @@ private void OnRecordRequested() } else { - MessageBox.ErrorQuery(Application.Instance, "Recording Error", "Failed to start recording", "OK"); + TerminalUi.ErrorQuery("Recording Error", "Failed to start recording", "OK"); } } } @@ -1032,13 +1032,13 @@ private void OnStopRecordingRequested() StopRecordingStatusUpdates(); _csvRecordingManager.StopRecording(); _monitoredVariablesView.UpdateRecordingStatus("", false); - MessageBox.Query(Application.Instance, "Recording", $"Recording saved.\n{_csvRecordingManager.RecordCount} records written.", "OK"); + TerminalUi.Query("Recording", $"Recording saved.\n{_csvRecordingManager.RecordCount} records written.", "OK"); } private void StartRecordingStatusUpdates() { - // Use Terminal.Gui's Application.AddTimeout for periodic updates - _recordingStatusTimer = Application.AddTimeout(TimeSpan.FromSeconds(1), () => + // Use the UI main-loop timer for periodic updates + _recordingStatusTimer = TerminalUi.AddTimeout(TimeSpan.FromSeconds(1), () => { if (_csvRecordingManager.IsRecording) { @@ -1054,7 +1054,7 @@ private void StopRecordingStatusUpdates() { if (_recordingStatusTimer != null) { - Application.RemoveTimeout(_recordingStatusTimer); + TerminalUi.RemoveTimeout(_recordingStatusTimer); _recordingStatusTimer = null; } } @@ -1062,7 +1062,7 @@ private void StopRecordingStatusUpdates() private void ShowHelp() { using var dialog = new HelpDialog(_keybindingManager); - Application.Run(dialog); + TerminalUi.RunModal(dialog); } private void ShowAbout() @@ -1093,7 +1093,7 @@ industrial automation data in real-time. © 2026 Square Wave Systems License: MIT "; - MessageBox.Query(Application.Instance, "About opcilloscope", about, "OK"); + TerminalUi.Query("About opcilloscope", about, "OK"); } /// @@ -1130,7 +1130,7 @@ private void OpenConfig() using var dialog = new Dialogs.OpenConfigDialog(); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (dialog.Confirmed && dialog.SelectedFilePath != null) { @@ -1165,7 +1165,7 @@ private void SaveConfigAs() using var dialog = new Dialogs.SaveConfigDialog(defaultDir, defaultFilename); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (dialog.Confirmed) { @@ -1197,7 +1197,7 @@ private async Task LoadConfigurationAsync(string filePath) using var pwDialog = new PasswordPromptDialog( config.Server.Authentication.Username, config.Server.EndpointUrl); - Application.Run(pwDialog); + TerminalUi.RunModal(pwDialog); if (!pwDialog.Confirmed) { @@ -1268,7 +1268,7 @@ private async Task LoadConfigurationAsync(string filePath) UpdateWindowTitle(); _logger.Error($"Failed to connect to {config.Server.EndpointUrl}"); - MessageBox.ErrorQuery(Application.Instance, "Connection Failed", + TerminalUi.ErrorQuery("Connection Failed", $"Could not connect to server:\n{config.Server.EndpointUrl}\n\nThe previous connection has been closed. Use Connect to reconnect.", "OK"); } @@ -1288,7 +1288,7 @@ private async Task LoadConfigurationAsync(string filePath) catch (Exception ex) { _logger.Error($"Failed to load configuration: {ex.Message}"); - MessageBox.ErrorQuery(Application.Instance, "Error", $"Failed to load configuration:\n{ex.Message}", "OK"); + TerminalUi.ErrorQuery("Error", $"Failed to load configuration:\n{ex.Message}", "OK"); } finally { @@ -1333,7 +1333,7 @@ private async Task SaveConfigurationAsync(string filePath) catch (Exception ex) { _logger.Error($"Failed to save configuration: {ex.Message}"); - MessageBox.ErrorQuery(Application.Instance, "Error", $"Failed to save:\n{ex.Message}", "OK"); + TerminalUi.ErrorQuery("Error", $"Failed to save:\n{ex.Message}", "OK"); } finally { @@ -1362,7 +1362,7 @@ private void UpdateWindowTitle() /// True if the user confirms, false to cancel the operation. private bool ConfirmDiscardChanges() { - var result = MessageBox.Query(Application.Instance, + var result = TerminalUi.Query( "Unsaved Changes", "You have unsaved changes. Do you want to discard them?", "Discard", @@ -1377,7 +1377,7 @@ private bool ConfirmDiscardChanges() /// Path to the configuration file. public void LoadConfigFromCommandLine(string configPath) { - Application.AddTimeout(TimeSpan.FromMilliseconds(100), () => + TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(100), () => { LoadConfigurationAsync(configPath).FireAndForget(_logger); return false; @@ -1416,7 +1416,7 @@ protected override void Dispose(bool disposing) // Remove the startup status timer if it hasn't yet self-removed. if (_startupStatusTimer != null) { - Application.RemoveTimeout(_startupStatusTimer); + TerminalUi.RemoveTimeout(_startupStatusTimer); _startupStatusTimer = null; } diff --git a/App/Views/AddressSpaceView.cs b/App/Views/AddressSpaceView.cs index b81dc4d..db16124 100644 --- a/App/Views/AddressSpaceView.cs +++ b/App/Views/AddressSpaceView.cs @@ -1,5 +1,6 @@ using System.Text; using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.OpcUa; using Opcilloscope.OpcUa.Models; using Opcilloscope.App.Themes; @@ -80,7 +81,7 @@ public AddressSpaceView() private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { _emptyStateLabel.SetScheme(new Scheme { @@ -120,7 +121,7 @@ private async Task RefreshAsync() } // Update UI on main thread - Application.Invoke(() => + UiThread.Run(() => { _treeView.ClearObjects(); _treeView.AddObject(_rootNode); @@ -162,7 +163,7 @@ private async Task LoadChildrenAsync(BrowsedNode node) await _nodeBrowser.GetChildrenAsync(node); // Refresh the tree on UI thread after children are loaded - Application.Invoke(() => + UiThread.Run(() => { _treeView.RefreshObject(node); if (node.ChildrenLoaded && node.Children.Count > 0) diff --git a/App/Views/LogView.cs b/App/Views/LogView.cs index 49b1905..2535067 100644 --- a/App/Views/LogView.cs +++ b/App/Views/LogView.cs @@ -89,7 +89,7 @@ private void OnRowRender(object? sender, ListViewRowEventArgs e) private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { BorderStyle = theme.FrameLineStyle; _copyButton.SetScheme(theme.ButtonColorScheme); @@ -146,7 +146,7 @@ private void OnCopyClicked(object? sender, CommandEventArgs e) return; var logText = string.Join(Environment.NewLine, _displayedEntries); - Clipboard.TrySetClipboardData(logText); + TerminalUi.TrySetClipboardData(logText); } protected override void Dispose(bool disposing) diff --git a/App/Views/MonitoredVariablesView.cs b/App/Views/MonitoredVariablesView.cs index 7c88114..a5bf77c 100644 --- a/App/Views/MonitoredVariablesView.cs +++ b/App/Views/MonitoredVariablesView.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.OpcUa.Models; using Opcilloscope.App.Themes; using System.Collections.Concurrent; @@ -221,7 +222,7 @@ private void UpdateEmptyState() private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { BorderStyle = theme.EmphasizedBorderStyle; @@ -300,7 +301,7 @@ private void EnsureUpdateTimerRunning() return; _updateTimerRunning = true; - _updateTimer = Application.AddTimeout(TimeSpan.FromMilliseconds(UpdateBatchIntervalMs), ProcessPendingUpdates); + _updateTimer = TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(UpdateBatchIntervalMs), ProcessPendingUpdates); } } @@ -532,7 +533,7 @@ private void ToggleScopeSelectionForVariable(MonitoredNode variable) _ = Task.Run(async () => { await Task.Delay(2000); - Application.Invoke(() => _selectionFeedback.Visible = false); + UiThread.Run(() => _selectionFeedback.Visible = false); }); return; } @@ -589,7 +590,7 @@ protected override void Dispose(bool disposing) { if (_updateTimer != null) { - Application.RemoveTimeout(_updateTimer); + TerminalUi.RemoveTimeout(_updateTimer); _updateTimer = null; } _updateTimerRunning = false; diff --git a/App/Views/NodeDetailsView.cs b/App/Views/NodeDetailsView.cs index 7fc5aba..beca5a8 100644 --- a/App/Views/NodeDetailsView.cs +++ b/App/Views/NodeDetailsView.cs @@ -60,7 +60,7 @@ public NodeDetailsView() private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { // Update copy button styling _copyButton.SetScheme(theme.ButtonColorScheme); @@ -101,7 +101,7 @@ public async Task ShowNodeByIdAsync(NodeId? nodeId) if (nodeId == null || _nodeBrowser == null) { _currentNodeId = null; - Application.Invoke(() => + UiThread.Run(() => { _detailsLabel.Text = "Select a node to view details"; _copyButton.Enabled = false; @@ -113,7 +113,7 @@ public async Task ShowNodeByIdAsync(NodeId? nodeId) _currentNodeId = nodeId; var attrs = await _nodeBrowser.GetNodeAttributesAsync(nodeId); - Application.Invoke(() => + UiThread.Run(() => { // Guard against stale responses: rapid selection changes can complete // out of order, so only apply this result if it is still the current node. @@ -156,7 +156,7 @@ public async Task ShowNodeAsync(BrowsedNode? node) if (node == null || _nodeBrowser == null) { _currentNodeId = null; - Application.Invoke(() => + UiThread.Run(() => { _detailsLabel.Text = ""; _copyButton.Enabled = false; @@ -169,7 +169,7 @@ public async Task ShowNodeAsync(BrowsedNode? node) _currentNodeId = nodeId; var attrs = await _nodeBrowser.GetNodeAttributesAsync(nodeId); - Application.Invoke(() => + UiThread.Run(() => { // Guard against stale responses: rapid selection changes can complete // out of order, so only apply this result if it is still the current node. @@ -281,7 +281,7 @@ private async void OnCopyClicked(object? sender, CommandEventArgs e) if (cancellationToken.IsCancellationRequested) return; - Application.Invoke(() => + UiThread.Run(() => { if (attributes == null || attributes.Count == 0) { @@ -291,7 +291,7 @@ private async void OnCopyClicked(object? sender, CommandEventArgs e) } var formatted = NodeAttributeFormatter.Format(attributes); - var success = Clipboard.TrySetClipboardData(formatted); + var success = TerminalUi.TrySetClipboardData(formatted); if (success) { @@ -307,7 +307,7 @@ private async void OnCopyClicked(object? sender, CommandEventArgs e) catch (Exception ex) { _logger?.Error($"Error copying node attributes: {ex.Message}"); - Application.Invoke(() => ShowCopyResult("Err", originalText)); + UiThread.Run(() => ShowCopyResult("Err", originalText)); } } @@ -317,7 +317,7 @@ private async void OnCopyClicked(object? sender, CommandEventArgs e) private void ShowCopyResult(string result, string originalText) { _copyButton.Text = result; - Application.AddTimeout(TimeSpan.FromSeconds(1), () => + TerminalUi.AddTimeout(TimeSpan.FromSeconds(1), () => { _copyButton.Text = originalText; _copyButton.Enabled = _currentNodeId != null; diff --git a/App/Views/ScopeView.cs b/App/Views/ScopeView.cs index 69784d7..01eb2e4 100644 --- a/App/Views/ScopeView.cs +++ b/App/Views/ScopeView.cs @@ -133,7 +133,7 @@ private void OnThemeChanged(AppTheme newTheme) try { - Application.Invoke(() => + UiThread.Run(() => { ApplyTheme(); SetNeedsLayout(); @@ -278,14 +278,14 @@ private void StartUpdateTimer() if (_timerToken != null) return; // ~10 FPS update rate - _timerToken = Application.AddTimeout(TimeSpan.FromMilliseconds(100), OnTimerTick); + _timerToken = TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(100), OnTimerTick); } private void StopUpdateTimer() { if (_timerToken != null) { - Application.RemoveTimeout(_timerToken); + TerminalUi.RemoveTimeout(_timerToken); _timerToken = null; } } diff --git a/Utilities/TerminalUi.cs b/Utilities/TerminalUi.cs new file mode 100644 index 0000000..0ded345 --- /dev/null +++ b/Utilities/TerminalUi.cs @@ -0,0 +1,87 @@ +using Terminal.Gui; + +namespace Opcilloscope.Utilities; + +/// +/// Central access point for Terminal.Gui application services: main-loop timers, +/// modal dialogs, message boxes, clipboard, and top-level view queries. +/// All call sites route through here (and for thread +/// marshalling) so access to the Terminal.Gui application object is confined to +/// a handful of files rather than spread across the UI code. +/// +public static class TerminalUi +{ + /// + /// Adds a recurring timeout on the UI main loop. The callback runs on the UI + /// thread; returning true keeps the timer running, false stops it. + /// Returns a token for . + /// + public static object? AddTimeout(TimeSpan interval, Func callback) + { + return Application.AddTimeout(interval, callback); + } + + /// + /// Removes a timeout previously added with . + /// + public static void RemoveTimeout(object token) + { + Application.RemoveTimeout(token); + } + + /// + /// Runs a view (dialog) modally, blocking until it requests stop. + /// + public static void RunModal(IRunnable view) + { + Application.Run(view); + } + + /// + /// Requests that the currently running (top) view stop, closing a modal dialog. + /// + public static void RequestStop() + { + Application.RequestStop(); + } + + /// + /// Shows an informational/confirmation message box. Returns the index of the + /// button pressed, or null if the message box was dismissed without a choice. + /// + public static int? Query(string title, string message, params string[] buttons) + { + return MessageBox.Query(Application.Instance, title, message, buttons); + } + + /// + /// Shows an error message box. Returns the index of the button pressed, + /// or null if the message box was dismissed without a choice. + /// + public static int? ErrorQuery(string title, string message, params string[] buttons) + { + return MessageBox.ErrorQuery(Application.Instance, title, message, buttons); + } + + /// + /// Copies text to the OS clipboard. Returns true on success. + /// + public static bool TrySetClipboardData(string text) + { + return Clipboard.TrySetClipboardData(text); + } + + /// + /// Gets the view of the currently running (top) runnable, or null when none is running. + /// + public static View? TopRunnableView => Application.TopRunnableView; + + /// + /// Returns true when the given runnable is the currently running (top) one, + /// i.e. no dialog is running above it. + /// + public static bool IsTopRunnable(IRunnable runnable) + { + return Application.TopRunnable == runnable; + } +} From e628abee97934cc8d72f7a20175f78246d2227a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 13:05:31 +0000 Subject: [PATCH 4/8] Migrate to Terminal.Gui instance-based IApplication API Switch from the deprecated legacy static Application object to the instance-based model introduced in Terminal.Gui 2.4: - Program.cs creates the application with Application.Create(), runs app.Init()/app.Run(mainWindow), and disposes it on exit (Dispose is the instance-based replacement for Application.Shutdown). The IL2026 pragma is dropped: the instance lifecycle carries no trim attributes. - TerminalUi holds the IApplication instance (TerminalUi.App, assigned once at startup) and all helpers now call the non-deprecated instance members: Invoke, AddTimeout/RemoveTimeout, Run/RequestStop, MessageBox overloads taking IApplication, App.Clipboard, TopRunnable. - UiThread.Run marshals via TerminalUi, preserving the existing threading discipline for OPC UA background-thread callbacks. - MainWindow subscribes to keyboard events via App.Keyboard.KeyDown (TerminalUi.Add/RemoveKeyDownHandler) instead of Application.KeyDown. - ThemeManager reads the driver from the instance (TerminalUi.Driver). When no application is running (headless unit tests), fire-and-forget helpers (Invoke, timers, clipboard) are no-ops and interactive ones (modal dialogs, message boxes) throw, matching the effective legacy behavior where those paths were never reachable without a session. CS0618 count: 27 -> 1. The single remaining warning is the unrelated TextView deprecation (superseded by the external gui-cs/Editor package), handled in the next commit. Verified: 696/696 tests pass; TUI smoke-tested under a pseudo-tty (full render of menus/panes, Ctrl+Q quits, exit code 0; --help exits 0). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Cb6n3buYyULFMq4AursDkF --- App/MainWindow.cs | 4 +- App/Themes/ThemeManager.cs | 3 +- Program.cs | 20 +++++----- Utilities/TerminalUi.cs | 76 +++++++++++++++++++++++++++++++++----- Utilities/UiThread.cs | 7 ++-- 5 files changed, 83 insertions(+), 27 deletions(-) diff --git a/App/MainWindow.cs b/App/MainWindow.cs index 1b4f934..ca3caab 100644 --- a/App/MainWindow.cs +++ b/App/MainWindow.cs @@ -185,7 +185,7 @@ public MainWindow() DefaultKeybindings.Configure(_keybindingManager, this); // Intercept letter/symbol keys at application level before views consume them - Application.KeyDown += OnApplicationKeyDown; + TerminalUi.AddKeyDownHandler(OnApplicationKeyDown); // Focus tracking using polling-based FocusManager (workaround for Terminal.Gui v2 Enter event instability) // Only track the two interactive panes (AddressSpace and MonitoredVariables) @@ -1431,7 +1431,7 @@ protected override void Dispose(bool disposing) _focusManager.FocusChanged -= OnPanelFocusChanged; } - Application.KeyDown -= OnApplicationKeyDown; + TerminalUi.RemoveKeyDownHandler(OnApplicationKeyDown); _connectionManager.Dispose(); } diff --git a/App/Themes/ThemeManager.cs b/App/Themes/ThemeManager.cs index 2040b4b..abf5634 100644 --- a/App/Themes/ThemeManager.cs +++ b/App/Themes/ThemeManager.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; namespace Opcilloscope.App.Themes; @@ -84,7 +85,7 @@ private static void ApplyTerminalColorMode(AppTheme theme) { // Terminal.Gui 2.4 removed the static Application.Force16Colors; // the flag now lives on the driver itself. - if (Application.Driver is { } driver) + if (TerminalUi.Driver is { } driver) { driver.Force16Colors = theme.UseTerminalColors; } diff --git a/Program.cs b/Program.cs index acaab5b..b227451 100644 --- a/Program.cs +++ b/Program.cs @@ -1,6 +1,7 @@ using Terminal.Gui; using Opcilloscope.App; using Opcilloscope.OpcUa; +using Opcilloscope.Utilities; namespace Opcilloscope; @@ -8,7 +9,7 @@ class Program { static int Main(string[] args) { - bool initialized = false; + IApplication? app = null; try { // Parse command-line arguments before initializing the terminal, so that --help/-h @@ -66,10 +67,9 @@ static int Main(string[] args) return 1; } -#pragma warning disable IL2026 // Terminal.Gui Application.Init uses reflection and is not AOT-compatible - Application.Init(); -#pragma warning restore IL2026 - initialized = true; + app = Application.Create(); + TerminalUi.App = app; + app.Init(); var mainWindow = new MainWindow(); try @@ -87,7 +87,7 @@ static int Main(string[] args) "Please use a configuration file with an endpoint URL instead."); } - Application.Run(mainWindow); + app.Run(mainWindow); } finally { @@ -102,10 +102,10 @@ static int Main(string[] args) } finally { - if (initialized) - { - Application.Shutdown(); - } + // Disposing the application shuts down the terminal (the + // instance-based replacement for the legacy Application.Shutdown). + app?.Dispose(); + TerminalUi.App = null; } return 0; diff --git a/Utilities/TerminalUi.cs b/Utilities/TerminalUi.cs index 0ded345..780b421 100644 --- a/Utilities/TerminalUi.cs +++ b/Utilities/TerminalUi.cs @@ -9,16 +9,43 @@ namespace Opcilloscope.Utilities; /// marshalling) so access to the Terminal.Gui application object is confined to /// a handful of files rather than spread across the UI code. /// +/// +/// Backed by the instance-based model +/// (Application.Create()); is assigned once at startup +/// by Program.Main. When no application is running (unit tests construct +/// views headlessly), the fire-and-forget members (, timers, +/// clipboard) degrade to no-ops, while the interactive members (modal dialogs, +/// message boxes) throw, since silently skipping them would hide real bugs. +/// public static class TerminalUi { + /// + /// The running Terminal.Gui application instance. Set once by Program.Main + /// right after Application.Create(); null in headless unit tests. + /// + public static IApplication? App { get; set; } + + private static IApplication RequireApp() => + App ?? throw new InvalidOperationException("No Terminal.Gui application is running (TerminalUi.App is not set)."); + + /// + /// Executes an action on the UI thread via the application main loop. + /// No-op when no application is running. + /// + public static void Invoke(Action action) + { + App?.Invoke(action); + } + /// /// Adds a recurring timeout on the UI main loop. The callback runs on the UI /// thread; returning true keeps the timer running, false stops it. - /// Returns a token for . + /// Returns a token for , or null when no + /// application is running. /// public static object? AddTimeout(TimeSpan interval, Func callback) { - return Application.AddTimeout(interval, callback); + return App?.AddTimeout(interval, callback); } /// @@ -26,7 +53,7 @@ public static class TerminalUi /// public static void RemoveTimeout(object token) { - Application.RemoveTimeout(token); + App?.RemoveTimeout(token); } /// @@ -34,7 +61,7 @@ public static void RemoveTimeout(object token) /// public static void RunModal(IRunnable view) { - Application.Run(view); + RequireApp().Run(view); } /// @@ -42,7 +69,7 @@ public static void RunModal(IRunnable view) /// public static void RequestStop() { - Application.RequestStop(); + RequireApp().RequestStop(); } /// @@ -51,7 +78,7 @@ public static void RequestStop() /// public static int? Query(string title, string message, params string[] buttons) { - return MessageBox.Query(Application.Instance, title, message, buttons); + return MessageBox.Query(RequireApp(), title, message, buttons); } /// @@ -60,7 +87,7 @@ public static void RequestStop() /// public static int? ErrorQuery(string title, string message, params string[] buttons) { - return MessageBox.ErrorQuery(Application.Instance, title, message, buttons); + return MessageBox.ErrorQuery(RequireApp(), title, message, buttons); } /// @@ -68,13 +95,13 @@ public static void RequestStop() /// public static bool TrySetClipboardData(string text) { - return Clipboard.TrySetClipboardData(text); + return App?.Clipboard?.TrySetClipboardData(text) ?? false; } /// /// Gets the view of the currently running (top) runnable, or null when none is running. /// - public static View? TopRunnableView => Application.TopRunnableView; + public static View? TopRunnableView => App?.TopRunnableView; /// /// Returns true when the given runnable is the currently running (top) one, @@ -82,6 +109,35 @@ public static bool TrySetClipboardData(string text) /// public static bool IsTopRunnable(IRunnable runnable) { - return Application.TopRunnable == runnable; + return App?.TopRunnable == runnable; + } + + /// + /// The driver of the running application, or null when none is running + /// (e.g. in headless tests). + /// + public static IDriver? Driver => App?.Driver; + + /// + /// Subscribes to application-level key-down events, which fire before any + /// view processes the key. No-op when no application is running. + /// + public static void AddKeyDownHandler(EventHandler handler) + { + if (App?.Keyboard is { } keyboard) + { + keyboard.KeyDown += handler; + } + } + + /// + /// Unsubscribes a handler added with . + /// + public static void RemoveKeyDownHandler(EventHandler handler) + { + if (App?.Keyboard is { } keyboard) + { + keyboard.KeyDown -= handler; + } } } diff --git a/Utilities/UiThread.cs b/Utilities/UiThread.cs index d01433e..8fddc5c 100644 --- a/Utilities/UiThread.cs +++ b/Utilities/UiThread.cs @@ -1,5 +1,3 @@ -using Terminal.Gui; - namespace Opcilloscope.Utilities; /// @@ -8,10 +6,11 @@ namespace Opcilloscope.Utilities; public static class UiThread { /// - /// Executes an action on the UI thread. + /// Executes an action on the UI thread. No-op when no Terminal.Gui + /// application is running (e.g. in headless tests). /// public static void Run(Action action) { - Application.Invoke(action); + TerminalUi.Invoke(action); } } From a9af0939fcaf56291ddfd7f6c1f9ef873aff9695 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 13:07:46 +0000 Subject: [PATCH 5/8] Treat warnings as errors in the main project With the Terminal.Gui static-API migration complete the build is warning-free, so enable TreatWarningsAsErrors in Opcilloscope.csproj to stop deprecations from accumulating silently until a package bump turns them into a wall of errors. The one genuinely unfixable warning is suppressed at the call site with a targeted #pragma: HelpDialog's TextView is obsolete in Terminal.Gui 2.4.5, but its designated successor (EditorView) ships in the separate gui-cs/Editor package, and a read-only help pane does not justify a new dependency. Verified clean under warnings-as-errors: Debug and Release builds, dotnet publish (single-file, as used by ci.yml/release.yml), and the full test suite (696/696); TUI pseudo-tty smoke test still exits 0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Cb6n3buYyULFMq4AursDkF --- App/Dialogs/HelpDialog.cs | 8 +++++++- Opcilloscope.csproj | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/App/Dialogs/HelpDialog.cs b/App/Dialogs/HelpDialog.cs index 54e68a7..88c89ac 100644 --- a/App/Dialogs/HelpDialog.cs +++ b/App/Dialogs/HelpDialog.cs @@ -28,7 +28,12 @@ public HelpDialog(KeybindingManager keybindingManager) SetScheme(theme.MainColorScheme); BorderStyle = theme.EmphasizedBorderStyle; - // Create content view with the help text + // Create content view with the help text. + // TextView is obsolete in Terminal.Gui 2.4.5, superseded by EditorView from + // the separate gui-cs/Editor package. A read-only scrolling text pane is all + // that's needed here, so keep TextView rather than take on a new dependency; + // revisit if/when EditorView ships in the Terminal.Gui package itself. +#pragma warning disable CS0618 // TextView is obsolete (replacement lives in gui-cs/Editor) var contentView = new TextView { X = 1, @@ -45,6 +50,7 @@ public HelpDialog(KeybindingManager keybindingManager) HotFocus = new Attribute(theme.Foreground, theme.Background), Disabled = new Attribute(theme.MutedText, theme.Background) }); +#pragma warning restore CS0618 contentView.Text = GenerateHelpFromBindings(keybindingManager); diff --git a/Opcilloscope.csproj b/Opcilloscope.csproj index b1a5ae3..961076a 100644 --- a/Opcilloscope.csproj +++ b/Opcilloscope.csproj @@ -9,6 +9,12 @@ opcilloscope true + + true + v Brett Kinny From 4e82c6a4f49cb861aa9fb3fb10c25674243464c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 13:13:49 +0000 Subject: [PATCH 6/8] docs: update CLAUDE.md for instance-based Terminal.Gui application model The Terminal.Gui v2 API notes still told contributors to use the now- deprecated static Application.Invoke()/AddTimeout(). Replace that guidance with the instance-based model the codebase migrated to: route all UI-thread marshalling, timers, modal dialogs, message boxes, and clipboard access through the UiThread/TerminalUi helpers, and note that TreatWarningsAsErrors makes a static-Application call a build error. Also list Utilities/TerminalUi.cs in the project structure and fix the thread-safety and common-issues examples. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Cb6n3buYyULFMq4AursDkF --- CLAUDE.md | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a9daada..f99adc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,7 +124,8 @@ Opcilloscope/ │ ├── Utilities/ │ ├── Logger.cs # In-app logging service -│ ├── UiThread.cs # Thread marshalling for UI updates +│ ├── UiThread.cs # Thread marshalling for UI updates (via TerminalUi) +│ ├── TerminalUi.cs # Instance-based IApplication access (timers, dialogs, message boxes, clipboard) │ ├── CsvRecordingManager.cs # Background CSV recording of monitored values │ ├── OpcValueConverter.cs # OPC UA value type conversion utilities │ ├── TaskExtensions.cs # Async task helper extensions (FireAndForget) @@ -251,8 +252,28 @@ Record monitored variable values to CSV files: - Use `Height = n` instead of `Dim.Sized(n)` - Use `SetNeedsLayout()` or `Update()` instead of `SetNeedsDisplay()` - `ListView.SetSource()` requires `ObservableCollection` -- Use `Application.Invoke()` for thread marshalling (no MainLoop) -- Use `Application.AddTimeout()` for periodic updates + +#### Instance-based application model (do NOT use the static `Application`) +Terminal.Gui 2.4 deprecated the legacy static `Application` object (`Application.Invoke`, +`AddTimeout`, `Run`, `RequestStop`, `Instance`, `Driver`, `KeyDown`, `Init`/`Shutdown`, the +static `Clipboard`, etc.). The whole static surface is `[Obsolete]` and will be removed in a +future release, and `TreatWarningsAsErrors` is on — so a static-`Application` call is a build +error, not a warning. The app uses the instance-based model (`Application.Create()` → +`IApplication`) instead: +- `Program.Main` owns the lifecycle: `Application.Create()` → `app.Init()` → + `app.Run(mainWindow)` → `app.Dispose()` (Dispose replaces the obsolete `Shutdown`). It stores + the instance in `TerminalUi.App`. +- **All UI code routes through the helpers in `Utilities/`, never the static `Application`:** + - `UiThread.Run(...)` — marshal an action onto the UI thread (thread marshalling; no MainLoop) + - `TerminalUi.AddTimeout(...)` / `RemoveTimeout(...)` — periodic/one-shot main-loop timers + - `TerminalUi.RunModal(dialog)` / `RequestStop()` — open/close a modal dialog + - `TerminalUi.Query(...)` / `ErrorQuery(...)` — message boxes (no need to pass the app instance) + - `TerminalUi.TrySetClipboardData(...)` — OS clipboard + - `TerminalUi.Driver`, `TopRunnableView`, `IsTopRunnable(...)`, `Add`/`RemoveKeyDownHandler(...)` +- The direct `IApplication` uses (`Create`/`Init`/`Run`/`Dispose`, keyboard, driver) are confined + to `Program.cs`, `TerminalUi`, and `ThemeManager`. Add new helpers to `TerminalUi` rather than + reaching for the static API. In headless unit tests `TerminalUi.App` is null: fire-and-forget + helpers (Invoke, timers, clipboard) no-op and interactive ones (modal dialogs, message boxes) throw. ### OPC Foundation SDK API - Uses `Opc.Ua.Client.Session` for connection management @@ -414,13 +435,14 @@ Available test nodes: OPC Foundation callbacks arrive on background threads. All UI updates are marshalled to the UI thread: ```csharp -// Using UiThread helper +// Marshal onto the UI thread with the UiThread helper UiThread.Run(() => _monitoredVariablesView.UpdateVariable(variable)); - -// Using Application.Invoke directly -Application.Invoke(() => SetNeedsLayout()); +UiThread.Run(() => SetNeedsLayout()); ``` +Do not call the deprecated static `Application.Invoke()` directly — `UiThread.Run` wraps the +instance-based `IApplication.Invoke` (see the "Instance-based application model" note above). + ### Async Pattern with FireAndForget For async operations from synchronous event handlers: @@ -454,7 +476,7 @@ Automates release builds and publishing. 1. **`dotnet` command not found**: Install .NET SDK using the install script (see Environment Setup above) 2. **Tests fail with Xunit errors in main project**: Ensure `tests/**` is excluded in Opcilloscope.csproj -3. **UI thread exceptions**: Always use `Application.Invoke()` or `UiThread.Run()` for UI updates from background threads +3. **UI thread exceptions**: Always use `UiThread.Run()` for UI updates from background threads (it marshals via the instance-based `IApplication.Invoke`; do not call the deprecated static `Application.Invoke()`) 4. **Ambiguous NodeBrowser reference**: OPC Foundation has its own `Browser` class - use fully qualified names if needed 5. **Certificate validation errors**: Set `AutoAcceptUntrustedCertificates = true` in SecurityConfiguration for development 6. **Integration tests fail with "Unexpected error starting application"**: The OPC UA test server requires specific environment permissions - unit tests will still pass From 1caa41e5c49915f47300747ddfa92d7987ca6841 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:01:16 +0000 Subject: [PATCH 7/8] test: add unit tests for TerminalUi and UiThread helpers Cover the TerminalUi contract the migration introduced: fire-and-forget members (Invoke, AddTimeout/RemoveTimeout, TrySetClipboardData, TopRunnableView, IsTopRunnable, Driver, key-down handlers) degrade to no-ops when no application is running, while the interactive members (RunModal, RequestStop, Query, ErrorQuery) throw InvalidOperationException rather than silently skip. Also verify delegation to the running IApplication instance via a mock, and that UiThread.Run marshals through IApplication.Invoke. Closes the coverage gap flagged in PR review; both helpers previously had no dedicated tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Cb6n3buYyULFMq4AursDkF --- .../Utilities/TerminalUiTests.cs | 210 ++++++++++++++++++ .../Utilities/UiThreadTests.cs | 46 ++++ 2 files changed, 256 insertions(+) create mode 100644 Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs create mode 100644 Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs diff --git a/Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs b/Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs new file mode 100644 index 0000000..f55e64c --- /dev/null +++ b/Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs @@ -0,0 +1,210 @@ +using Moq; +using Opcilloscope.Utilities; + +namespace Opcilloscope.Tests.Utilities; + +/// +/// Tests for the helper that centralizes access to the +/// instance-based Terminal.Gui . The design contract is: +/// fire-and-forget members (Invoke, timers, clipboard, top-level queries) degrade +/// to no-ops when no application is running (as in headless tests), while the +/// interactive members (modal dialogs, message boxes) throw rather than silently +/// skip, since a hidden no-op there would mask a real bug. +/// +/// is process-global mutable state, so these tests +/// share the non-parallel "Tui" collection with the other Terminal.Gui tests and +/// reset after each test. +/// +[Collection("Tui")] +public class TerminalUiTests : IDisposable +{ + public TerminalUiTests() + { + // Start from a known headless state (no application running). + TerminalUi.App = null; + } + + public void Dispose() + { + // Never leak a mock instance into sibling tests in the collection. + TerminalUi.App = null; + } + + // ── Fire-and-forget members: no-op when no application is running ── + + [Fact] + public void Invoke_WithNoApp_DoesNotThrow() + { + var ran = false; + // The action is queued onto the (non-existent) main loop, so it does not run, + // but the call itself must be a safe no-op. + var ex = Record.Exception(() => TerminalUi.Invoke(() => ran = true)); + + Assert.Null(ex); + Assert.False(ran); + } + + [Fact] + public void AddTimeout_WithNoApp_ReturnsNullAndDoesNotThrow() + { + object? token = null; + var ex = Record.Exception(() => + token = TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(100), () => false)); + + Assert.Null(ex); + Assert.Null(token); + } + + [Fact] + public void RemoveTimeout_WithNoApp_DoesNotThrow() + { + var ex = Record.Exception(() => TerminalUi.RemoveTimeout(new object())); + + Assert.Null(ex); + } + + [Fact] + public void TrySetClipboardData_WithNoApp_ReturnsFalse() + { + Assert.False(TerminalUi.TrySetClipboardData("some text")); + } + + [Fact] + public void TopRunnableView_WithNoApp_IsNull() + { + Assert.Null(TerminalUi.TopRunnableView); + } + + [Fact] + public void IsTopRunnable_WithNoApp_IsFalse() + { + var runnable = new Mock().Object; + + Assert.False(TerminalUi.IsTopRunnable(runnable)); + } + + [Fact] + public void Driver_WithNoApp_IsNull() + { + Assert.Null(TerminalUi.Driver); + } + + [Fact] + public void KeyDownHandlers_WithNoApp_DoNotThrow() + { + EventHandler handler = (_, _) => { }; + + var ex = Record.Exception(() => + { + TerminalUi.AddKeyDownHandler(handler); + TerminalUi.RemoveKeyDownHandler(handler); + }); + + Assert.Null(ex); + } + + // ── Interactive members: throw when no application is running ── + + [Fact] + public void RunModal_WithNoApp_Throws() + { + var dialog = new Mock().Object; + + Assert.Throws(() => TerminalUi.RunModal(dialog)); + } + + [Fact] + public void RequestStop_WithNoApp_Throws() + { + Assert.Throws(() => TerminalUi.RequestStop()); + } + + [Fact] + public void Query_WithNoApp_Throws() + { + Assert.Throws(() => TerminalUi.Query("Title", "Message", "OK")); + } + + [Fact] + public void ErrorQuery_WithNoApp_Throws() + { + Assert.Throws(() => TerminalUi.ErrorQuery("Title", "Message", "OK")); + } + + // ── Delegation to the running application instance ── + + [Fact] + public void Invoke_WithApp_DelegatesToApplication() + { + var app = new Mock(); + TerminalUi.App = app.Object; + Action action = () => { }; + + TerminalUi.Invoke(action); + + app.Verify(a => a.Invoke(action), Times.Once); + } + + [Fact] + public void AddTimeout_WithApp_ReturnsApplicationToken() + { + var token = new object(); + var app = new Mock(); + app.Setup(a => a.AddTimeout(It.IsAny(), It.IsAny>())) + .Returns(token); + TerminalUi.App = app.Object; + + var result = TerminalUi.AddTimeout(TimeSpan.FromSeconds(1), () => false); + + Assert.Same(token, result); + } + + [Fact] + public void RemoveTimeout_WithApp_DelegatesToApplication() + { + var token = new object(); + var app = new Mock(); + TerminalUi.App = app.Object; + + TerminalUi.RemoveTimeout(token); + + app.Verify(a => a.RemoveTimeout(token), Times.Once); + } + + [Fact] + public void TrySetClipboardData_WithApp_DelegatesToClipboard() + { + var clipboard = new Mock(); + clipboard.Setup(c => c.TrySetClipboardData("payload")).Returns(true); + var app = new Mock(); + app.Setup(a => a.Clipboard).Returns(clipboard.Object); + TerminalUi.App = app.Object; + + Assert.True(TerminalUi.TrySetClipboardData("payload")); + clipboard.Verify(c => c.TrySetClipboardData("payload"), Times.Once); + } + + [Fact] + public void RequestStop_WithApp_DelegatesToApplication() + { + var app = new Mock(); + TerminalUi.App = app.Object; + + TerminalUi.RequestStop(); + + app.Verify(a => a.RequestStop(), Times.Once); + } + + [Fact] + public void IsTopRunnable_WithApp_ComparesAgainstTopRunnable() + { + var runnable = new Mock().Object; + var other = new Mock().Object; + var app = new Mock(); + app.Setup(a => a.TopRunnable).Returns(runnable); + TerminalUi.App = app.Object; + + Assert.True(TerminalUi.IsTopRunnable(runnable)); + Assert.False(TerminalUi.IsTopRunnable(other)); + } +} diff --git a/Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs b/Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs new file mode 100644 index 0000000..eb6e0e1 --- /dev/null +++ b/Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs @@ -0,0 +1,46 @@ +using Moq; +using Opcilloscope.Utilities; + +namespace Opcilloscope.Tests.Utilities; + +/// +/// Tests for the marshalling helper, which forwards to +/// . Like these touch +/// the process-global , so they share the non-parallel +/// "Tui" collection and reset it after each test. +/// +[Collection("Tui")] +public class UiThreadTests : IDisposable +{ + public UiThreadTests() + { + TerminalUi.App = null; + } + + public void Dispose() + { + TerminalUi.App = null; + } + + [Fact] + public void Run_WithNoApp_DoesNotThrow() + { + var ran = false; + var ex = Record.Exception(() => UiThread.Run(() => ran = true)); + + Assert.Null(ex); + Assert.False(ran); + } + + [Fact] + public void Run_WithApp_MarshalsThroughApplicationInvoke() + { + var app = new Mock(); + TerminalUi.App = app.Object; + Action action = () => { }; + + UiThread.Run(action); + + app.Verify(a => a.Invoke(action), Times.Once); + } +} From 8eaec8762efd535356e9f857ef67a7ebd3c5de4b Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Sat, 11 Jul 2026 14:48:34 +1000 Subject: [PATCH 8/8] Prepare repository for next release --- .gitattributes | 4 + .github/copilot-instructions.md | 61 +- .github/workflows/ci.yml | 54 +- .github/workflows/claude-code-review.yml | 5 +- .github/workflows/claude.yml | 9 +- .github/workflows/release.yml | 136 ++- App/Dialogs/SaveConfigDialog.cs | 11 +- App/Dialogs/WriteValueDialog.cs | 17 +- App/MainWindow.cs | 691 +++++++++++-- App/Themes/AppTheme.cs | 2 +- App/Views/AddressSpaceView.cs | 60 +- App/Views/MonitoredVariablesView.cs | 302 ++++-- App/Views/NodeDetailsView.cs | 33 +- App/Views/ScopeView.cs | 2 - CLAUDE.md | 81 +- CONTRIBUTING.md | 19 +- CommandLineOptions.cs | 85 ++ Configuration/ConfigurationService.cs | 20 +- Configuration/Models/OpcilloscopeConfig.cs | 14 +- OpcUa/ConnectionCredentials.cs | 41 +- OpcUa/ConnectionManager.cs | 941 ++++++++++++++++-- OpcUa/Models/BrowsedNode.cs | 5 + OpcUa/Models/MonitoredNode.cs | 56 +- OpcUa/NodeBrowser.cs | 474 +++++---- OpcUa/OpcUaClientWrapper.cs | 911 ++++++++++++----- OpcUa/SubscriptionManager.cs | 440 ++++++-- Opcilloscope.csproj | 15 +- Program.cs | 77 +- README.md | 108 +- THIRD-PARTY-NOTICES.md | 315 ++---- .../Opcilloscope.E2ETests.csproj | 23 + .../OpcilloscopeSession.cs | 175 ++++ Tests/Opcilloscope.E2ETests/Pty.cs | 462 +++++++++ .../PublishedBinaryFixture.cs | 151 +++ Tests/Opcilloscope.E2ETests/README.md | 33 + Tests/Opcilloscope.E2ETests/StartupTests.cs | 74 ++ Tests/Opcilloscope.E2ETests/VtScreen.cs | 338 +++++++ Tests/Opcilloscope.E2ETests/VtScreenTests.cs | 63 ++ .../Opcilloscope.TestServer.csproj | 2 +- Tests/Opcilloscope.TestServer/TestServer.cs | 26 +- .../App/MainWindowStateTests.cs | 109 ++ .../App/SaveConfigDialogTests.cs | 23 + .../App/Views/BrailleCanvasTests.cs | 8 +- .../App/WriteValueDialogTests.cs | 25 + .../CommandLineParserTests.cs | 73 ++ .../ConfigurationServiceTests.cs | 56 +- .../Infrastructure/TestModuleInitializer.cs | 19 + .../Infrastructure/TestServerFixture.cs | 40 +- .../AuthenticationIntegrationTests.cs | 148 ++- .../ConnectionManagerIntegrationTests.cs | 269 ++++- .../NodeBrowserIntegrationTests.cs | 29 + .../Integration/ReconnectIntegrationTests.cs | 40 +- .../SubscriptionManagerIntegrationTests.cs | 151 ++- .../OpcUa/ConnectionCredentialsTests.cs | 28 + .../OpcUa/Models/MonitoredNodeTests.cs | 55 +- .../OpcUa/OpcUaClientWrapperTests.cs | 452 +++++++++ .../Tui/MonitoredVariablesViewTests.cs | 99 +- .../Utilities/CsvRecordingManagerTests.cs | 146 +++ .../Utilities/TerminalUiTests.cs | 8 +- .../Utilities/UiThreadTests.cs | 65 +- Utilities/CsvRecordingManager.cs | 520 ++++++---- Utilities/TerminalUi.cs | 94 +- Utilities/UiThread.cs | 59 +- docs/TESTING.md | 39 +- docs/deep-repo-review-2026-07-07.md | 13 + global.json | 7 + install.ps1 | 192 +++- install.sh | 132 ++- licenses/MARKDIG-LICENSE.txt | 21 + licenses/NOTICE-SOURCES.md | 48 + licenses/ONIGWRAP-THIRD-PARTY-NOTICES.txt | 102 ++ licenses/OPC-FOUNDATION-LICENSE.txt | 24 + licenses/THIRD-PARTY-PACKAGES.tsv | 28 + packages.linux-arm64.lock.json | 214 ++++ packages.linux-x64.lock.json | 214 ++++ packages.osx-arm64.lock.json | 214 ++++ packages.osx-x64.lock.json | 214 ++++ packages.win-arm64.lock.json | 214 ++++ packages.win-x64.lock.json | 214 ++++ scripts/verify-third-party-inventory.sh | 67 ++ uninstall.ps1 | 131 ++- uninstall.sh | 109 +- 82 files changed, 9340 insertions(+), 1709 deletions(-) create mode 100644 .gitattributes create mode 100644 CommandLineOptions.cs create mode 100644 Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj create mode 100644 Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs create mode 100644 Tests/Opcilloscope.E2ETests/Pty.cs create mode 100644 Tests/Opcilloscope.E2ETests/PublishedBinaryFixture.cs create mode 100644 Tests/Opcilloscope.E2ETests/README.md create mode 100644 Tests/Opcilloscope.E2ETests/StartupTests.cs create mode 100644 Tests/Opcilloscope.E2ETests/VtScreen.cs create mode 100644 Tests/Opcilloscope.E2ETests/VtScreenTests.cs create mode 100644 Tests/Opcilloscope.Tests/App/MainWindowStateTests.cs create mode 100644 Tests/Opcilloscope.Tests/App/SaveConfigDialogTests.cs create mode 100644 Tests/Opcilloscope.Tests/App/WriteValueDialogTests.cs create mode 100644 Tests/Opcilloscope.Tests/CommandLineParserTests.cs create mode 100644 Tests/Opcilloscope.Tests/OpcUa/ConnectionCredentialsTests.cs create mode 100644 Tests/Opcilloscope.Tests/OpcUa/OpcUaClientWrapperTests.cs create mode 100644 global.json create mode 100644 licenses/MARKDIG-LICENSE.txt create mode 100644 licenses/NOTICE-SOURCES.md create mode 100644 licenses/ONIGWRAP-THIRD-PARTY-NOTICES.txt create mode 100644 licenses/OPC-FOUNDATION-LICENSE.txt create mode 100644 licenses/THIRD-PARTY-PACKAGES.tsv create mode 100644 packages.linux-arm64.lock.json create mode 100644 packages.linux-x64.lock.json create mode 100644 packages.osx-arm64.lock.json create mode 100644 packages.osx-x64.lock.json create mode 100644 packages.win-arm64.lock.json create mode 100644 packages.win-x64.lock.json create mode 100755 scripts/verify-third-party-inventory.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a030307 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# These package-supplied legal texts are verified byte-for-byte (apart from the +# documented OPC CRLF normalization) and intentionally retain trailing spaces. +licenses/ONIGWRAP-THIRD-PARTY-NOTICES.txt -whitespace +licenses/OPC-FOUNDATION-LICENSE.txt -whitespace diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9475338..bd96329 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,14 +15,21 @@ The project name is **opcilloscope** (lowercase "o") in all contexts except wher | User-facing text, CLI, URLs | `opcilloscope` | `opcilloscope --help` | | C# namespaces, classes, projects | `Opcilloscope` | `namespace Opcilloscope.App` | | File/folder names (code) | `Opcilloscope` | `Opcilloscope.csproj` | -| Config directories (all platforms) | `opcilloscope` | `~/.config/opcilloscope/` | +| Config/data directories | `opcilloscope` | Use the platform locations below | | Release artifacts | `opcilloscope` | `opcilloscope-linux-x64.tar.gz` | +Platform directories: +- Linux configuration: `${XDG_CONFIG_HOME:-$HOME/.config}/opcilloscope/` +- Linux application data: `${XDG_DATA_HOME:-$HOME/.local/share}/opcilloscope/` +- macOS configuration and application data: `~/Library/Application Support/opcilloscope/` +- Windows configuration: `%APPDATA%\opcilloscope\`; certificates: `%LOCALAPPDATA%\opcilloscope\pki\` + ## Build Commands ```bash -dotnet build # Build project -dotnet run # Run application -dotnet test # Run tests +dotnet build Opcilloscope.sln # Build app and tests +dotnet run --project Opcilloscope.csproj # Run application +dotnet test Opcilloscope.sln # Run unit/integration suite +dotnet test Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj # Linux real-PTY E2E ``` ## Project Architecture @@ -31,13 +38,14 @@ dotnet test # Run tests - `App/` - UI components (MainWindow, Views, Dialogs) - `OpcUa/` - OPC UA client logic (Session wrapper, Browser, SubscriptionManager) - `Utilities/` - Helper classes (Logger, UiThread) -- `tests/` - xUnit tests with in-process OPC UA test server +- `Tests/Opcilloscope.Tests/` - Cross-platform xUnit tests with the in-process OPC UA test server +- `Tests/Opcilloscope.E2ETests/` - Linux-only published-binary PTY tests; intentionally outside `Opcilloscope.sln` ### Key Classes - **MainWindow.cs** - Main UI layout with panels - **OpcUaClientWrapper.cs** - OPC Foundation Session wrapper - **NodeBrowser.cs** - Address space navigation -- **SubscriptionManager.cs** - OPC UA Subscription management with Publish/Subscribe +- **SubscriptionManager.cs** - OPC UA subscriptions and monitored-item notifications (not the OPC UA PubSub transport model) - **TestServer.cs** - In-process OPC UA server for testing ## Coding Guidelines @@ -61,8 +69,8 @@ SetNeedsLayout() // OR Update(), NOT SetNeedsDisplay() // ListView ObservableCollection // Required for ListView.SetSource() -// Thread marshalling -Application.Invoke(() => { +// Thread marshalling through the repository helper +UiThread.Run(() => { // UI updates here }); ``` @@ -70,10 +78,17 @@ Application.Invoke(() => { ### OPC Foundation SDK Patterns #### Endpoint Discovery +`DiscoveryClient.Create` and `Session.Create` are obsolete in the current SDK. +Existing wrapper call sites use narrowly scoped `CS0618` pragmas because the +replacement factories need additional telemetry setup. Prefer the repository +wrapper; do not introduce an unsuppressed call or a project-wide suppression. + ```csharp // DiscoveryClient.Create requires EndpointConfiguration, not ApplicationConfiguration var endpointConfig = EndpointConfiguration.Create(config); +#pragma warning disable CS0618 // Existing wrapper exception: async factory needs telemetry setup using var client = DiscoveryClient.Create(uri, endpointConfig); +#pragma warning restore CS0618 var endpoints = await client.GetEndpointsAsync(null); // Valid DiscoveryClient.Create overloads: @@ -94,6 +109,7 @@ await _server.StopAsync(); #### Session Creation ```csharp +#pragma warning disable CS0618 // Existing wrapper exception: async factory needs telemetry setup var session = await Session.Create( config, endpoint, @@ -103,6 +119,7 @@ var session = await Session.Create( new UserIdentity(new AnonymousIdentityToken()), null ); +#pragma warning restore CS0618 ``` #### Subscription with MonitoredItems @@ -113,7 +130,7 @@ var subscription = new Subscription(session.DefaultSubscription) { PublishingEnabled = true }; session.AddSubscription(subscription); -subscription.Create(); +await subscription.CreateAsync(); // Add monitored item var monitoredItem = new MonitoredItem(subscription.DefaultItem) { @@ -123,7 +140,7 @@ var monitoredItem = new MonitoredItem(subscription.DefaultItem) { }; monitoredItem.Notification += OnNotification; subscription.AddItem(monitoredItem); -subscription.ApplyChanges(); +await subscription.ApplyChangesAsync(); ``` #### NodeId Usage @@ -199,11 +216,11 @@ public class OtherTests ## Thread Safety -⚠️ **Critical:** OPC Foundation callbacks arrive on background threads. Always use `Application.Invoke()` for UI updates: +⚠️ **Critical:** OPC Foundation callbacks arrive on background threads. Always use the repository's `UiThread.Run` helper for UI updates; the legacy static `Application` API is obsolete: ```csharp monitoredItem.Notification += (item, e) => { - Application.Invoke(() => { + UiThread.Run(() => { // Safe to update UI here label.Text = newValue; }); @@ -221,17 +238,23 @@ Required packages: ## Common Pitfalls -1. **Tests fail with Xunit errors in main project** - Ensure `tests/**` is excluded in Opcilloscope.csproj -2. **UI thread exceptions** - Always use `Application.Invoke()` for UI updates from background threads +1. **Tests fail with Xunit errors in main project** - Ensure `Tests/**` is excluded in Opcilloscope.csproj +2. **UI thread exceptions** - Always use `UiThread.Run()` for UI updates from background threads 3. **Ambiguous NodeBrowser reference** - OPC Foundation has its own `Browser` class; use fully qualified names -4. **Certificate validation errors** - Set `AutoAcceptUntrustedCertificates = true` in SecurityConfiguration for development +4. **Certificate validation errors** - Fix or trust the server certificate using the path reported by the connection log, or bypass validation with `--insecure` for development only ## Security Notes -For development environments: -```csharp -config.SecurityConfiguration.AutoAcceptUntrustedCertificates = true; -``` +An automatic/omitted or partial security profile requires a `SignAndEncrypt` +endpoint and selects the strongest matching candidate. Explicit +`SecurityMode=Sign` opts into signed-but-unencrypted traffic. Explicit +anonymous `SecurityMode=None` opts into unsecured plaintext; username +credentials never permit `None`. + +Certificates that fail validation are rejected by default. +`opcilloscope --insecure` may be used for a development run only; it bypasses +certificate validation and never enables plaintext transport. Do not weaken +`SecurityConfiguration` in production code. ## Naming Conventions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b724fac..7200a52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: # MinVer needs the full commit history (and tags) to compute versions. fetch-depth: 0 @@ -20,27 +20,47 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: '10.0.x' + # Keep the SDK/runtime pack aligned with the reviewed RID locks and notices. + dotnet-version: '10.0.109' - name: Restore dependencies - run: dotnet restore + run: | + dotnet restore Opcilloscope.csproj --locked-mode + dotnet restore Tests/Opcilloscope.Tests/Opcilloscope.Tests.csproj + dotnet restore Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj + + - name: Validate third-party inventory + run: ./scripts/verify-third-party-inventory.sh + + - name: Verify formatting + run: | + dotnet format Opcilloscope.sln --verify-no-changes --no-restore + dotnet format Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj --verify-no-changes --no-restore + + - name: Build main solution + run: dotnet build Opcilloscope.sln --no-restore --configuration Release - - name: Build - run: dotnet build --no-restore --configuration Release + - name: Test unit, integration, and TUI components + run: dotnet test Tests/Opcilloscope.Tests/Opcilloscope.Tests.csproj --no-build --configuration Release --verbosity normal - - name: Test - run: dotnet test --no-build --configuration Release --verbosity normal + - name: Build Linux E2E harness + run: dotnet build Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj --no-restore --configuration Release - - name: Publish (smoke) - run: dotnet publish Opcilloscope.csproj -c Release -r linux-x64 -o ./publish + - name: Publish exact Linux artifact under test + run: dotnet publish Opcilloscope.csproj --no-restore -c Release -r linux-x64 -o ./publish -p:DebugType=none - - name: Smoke test published binary + - name: Verify single-file publish layout run: | chmod +x ./publish/opcilloscope - # Run --help under a pseudo-tty: this exercises Terminal.Gui's Application.Init - # (catching trim/startup regressions in the self-contained binary) and then exits - # via the --help path. `script -e` propagates the binary's exit code. - TERM=xterm script -qec "./publish/opcilloscope --help" /dev/null - code=$? - echo "Published binary exited with code $code" - test "$code" -eq 0 + test -f ./publish/opcilloscope + test ! -e ./publish/libonigwrap.so + test ! -e ./publish/opcilloscope.dll + test ! -e ./publish/opcilloscope.pdb + file_count="$(find ./publish -maxdepth 1 -type f | wc -l)" + test "$file_count" -eq 1 + ./publish/opcilloscope --help + + - name: Test published TUI over a real PTY + env: + OPCILLOSCOPE_BIN: ${{ github.workspace }}/publish/opcilloscope + run: dotnet test Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj --no-build --configuration Release --verbosity normal diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b248ecd..8baa288 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -19,13 +19,13 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read + pull-requests: write issues: read id-token: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 @@ -52,4 +52,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267..8b44cb3 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -19,14 +19,14 @@ jobs: (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: - contents: read - pull-requests: read - issues: read + contents: write + pull-requests: write + issues: write id-token: write actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 @@ -47,4 +47,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1ab783..9884c7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - 'v*' permissions: - contents: write + contents: read jobs: test: @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: # MinVer needs the full commit history (and tags) to compute versions. fetch-depth: 0 @@ -22,13 +22,19 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: '10.0.x' + # Keep the SDK/runtime pack aligned with the reviewed RID locks and notices. + dotnet-version: '10.0.109' - name: Restore dependencies - run: dotnet restore + run: | + dotnet restore Opcilloscope.csproj --locked-mode + dotnet restore Tests/Opcilloscope.Tests/Opcilloscope.Tests.csproj + + - name: Validate third-party inventory + run: ./scripts/verify-third-party-inventory.sh - name: Test - run: dotnet test -c Release --verbosity normal + run: dotnet test Tests/Opcilloscope.Tests/Opcilloscope.Tests.csproj --no-restore -c Release --verbosity normal build: needs: test @@ -62,7 +68,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: # MinVer needs the full commit history (and tags) to compute versions. fetch-depth: 0 @@ -70,27 +76,61 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: '10.0.x' + # Keep the SDK/runtime pack aligned with the reviewed RID locks and notices. + dotnet-version: '10.0.109' - name: Restore dependencies - run: dotnet restore + # Use an explicit MSBuild property so NuGet selects the matching + # packages..lock.json before project evaluation. + run: dotnet restore Opcilloscope.csproj -p:RuntimeIdentifier=${{ matrix.rid }} --locked-mode + + - name: Restore Linux E2E harness + if: matrix.rid == 'linux-x64' + run: dotnet restore Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj - name: Publish run: | - dotnet publish Opcilloscope.csproj -c Release -r ${{ matrix.rid }} -o ./publish -p:DebugType=none + dotnet publish Opcilloscope.csproj -c Release -r ${{ matrix.rid }} -o ./publish -p:DebugType=none --no-restore + + - name: Build Linux E2E harness + if: matrix.rid == 'linux-x64' + run: dotnet build Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj --no-restore -c Release + + - name: Test published Linux TUI over a real PTY + if: matrix.rid == 'linux-x64' + env: + OPCILLOSCOPE_BIN: ${{ github.workspace }}/publish/opcilloscope + run: dotnet test Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj --no-build -c Release --verbosity normal - - name: Smoke test published binary (Linux/macOS) + - name: Validate single-file layout (Linux/macOS) + if: runner.os != 'Windows' + run: | + shopt -s nullglob dotglob + entries=(./publish/*) + if [ "${#entries[@]}" -ne 1 ] || [ "$(basename "${entries[0]}")" != "opcilloscope" ]; then + echo "Expected exactly one published executable before adding notices" + ls -la ./publish + exit 1 + fi + + - name: Validate single-file layout (Windows) + if: runner.os == 'Windows' + run: | + $entries = @(Get-ChildItem -Path ./publish -Force) + if ($entries.Count -ne 1 -or $entries[0].Name -ne "opcilloscope.exe") { + Get-ChildItem -Path ./publish -Force + throw "Expected exactly one published executable before adding notices" + } + + # --help intentionally exits before Terminal.Gui initialization. This is a + # CLI/bundle-loading smoke; interactive TUI coverage belongs to the PTY suite. + - name: Smoke test published CLI (Linux/macOS) if: matrix.smoke && runner.os != 'Windows' run: | chmod +x ./publish/opcilloscope - # Run under a pseudo-tty so Terminal.Gui's Application.Init can initialize even on - # a headless runner; `script -e` propagates the binary's exit code. - TERM=xterm script -qec "./publish/opcilloscope --help" /dev/null - code=$? - echo "Published binary exited with code $code" - test "$code" -eq 0 - - - name: Smoke test published binary (Windows) + ./publish/opcilloscope --help + + - name: Smoke test published CLI (Windows) if: matrix.smoke && runner.os == 'Windows' run: | ./publish/opcilloscope.exe --help @@ -99,19 +139,65 @@ jobs: - name: Copy license notices (Linux/macOS) if: runner.os != 'Windows' run: | + mkdir -p ./publish/licenses cp LICENSE THIRD-PARTY-NOTICES.md ./publish/ + cp licenses/* ./publish/licenses/ + + runtime_package="Microsoft.NETCore.App.Runtime.${{ matrix.rid }}" + runtime_version=$(jq -er --arg package "$runtime_package" \ + '.project.frameworks["net10.0"].downloadDependencies[] | + select(.name == $package) | .version | ltrimstr("[") | split(",")[0]' \ + ./obj/project.assets.json) + runtime_package_lower=$(printf '%s' "$runtime_package" | tr '[:upper:]' '[:lower:]') + runtime_root="$HOME/.nuget/packages/${runtime_package_lower}/${runtime_version}" + test -f "$runtime_root/LICENSE.TXT" + test -f "$runtime_root/THIRD-PARTY-NOTICES.TXT" + cp "$runtime_root/LICENSE.TXT" "./publish/licenses/DOTNET-RUNTIME-${{ matrix.rid }}-LICENSE.txt" + cp "$runtime_root/THIRD-PARTY-NOTICES.TXT" "./publish/licenses/DOTNET-RUNTIME-${{ matrix.rid }}-THIRD-PARTY-NOTICES.txt" + + extensions_notice="$HOME/.nuget/packages/microsoft.extensions.dependencyinjection/10.0.8/THIRD-PARTY-NOTICES.TXT" + test -f "$extensions_notice" + cp "$extensions_notice" ./publish/licenses/MICROSOFT-EXTENSIONS-THIRD-PARTY-NOTICES.txt - name: Copy license notices (Windows) if: runner.os == 'Windows' run: | + New-Item -ItemType Directory -Path ./publish/licenses -Force | Out-Null Copy-Item -Path LICENSE, THIRD-PARTY-NOTICES.md -Destination ./publish/ + Copy-Item -Path ./licenses/* -Destination ./publish/licenses/ + + $runtimePackage = "Microsoft.NETCore.App.Runtime.${{ matrix.rid }}" + $assets = Get-Content ./obj/project.assets.json -Raw | ConvertFrom-Json + $runtimeDependency = $assets.project.frameworks.'net10.0'.downloadDependencies | + Where-Object { $_.name -eq $runtimePackage } | + Select-Object -First 1 + if (-not $runtimeDependency) { + throw "Could not resolve $runtimePackage from obj/project.assets.json" + } + $runtimeVersion = (($runtimeDependency.version -replace '^\[', '') -split ',')[0].Trim() + $runtimeRoot = Join-Path $env:USERPROFILE ".nuget\packages\$($runtimePackage.ToLowerInvariant())\$runtimeVersion" + $runtimeLicense = Join-Path $runtimeRoot "LICENSE.TXT" + $runtimeNotices = Join-Path $runtimeRoot "THIRD-PARTY-NOTICES.TXT" + if (-not (Test-Path $runtimeLicense) -or -not (Test-Path $runtimeNotices)) { + throw "Could not find .NET runtime license material at $runtimeRoot" + } + Copy-Item $runtimeLicense "./publish/licenses/DOTNET-RUNTIME-${{ matrix.rid }}-LICENSE.txt" + Copy-Item $runtimeNotices "./publish/licenses/DOTNET-RUNTIME-${{ matrix.rid }}-THIRD-PARTY-NOTICES.txt" + + $extensionsNotice = Join-Path $env:USERPROFILE ".nuget\packages\microsoft.extensions.dependencyinjection\10.0.8\THIRD-PARTY-NOTICES.TXT" + if (-not (Test-Path $extensionsNotice)) { + throw "Could not find Microsoft.Extensions notices at $extensionsNotice" + } + Copy-Item $extensionsNotice ./publish/licenses/MICROSOFT-EXTENSIONS-THIRD-PARTY-NOTICES.txt - name: Create archive (Linux/macOS) if: runner.os != 'Windows' run: | cd publish - chmod +x opcilloscope || true - tar -czvf ../${{ matrix.artifact }}.tar.gz * + find . -type d -exec chmod 0755 {} + + find . -type f ! -name opcilloscope -exec chmod 0644 {} + + chmod 0755 opcilloscope + tar -czf ../${{ matrix.artifact }}.tar.gz . - name: Create archive (Windows) if: runner.os == 'Windows' @@ -119,7 +205,7 @@ jobs: Compress-Archive -Path ./publish/* -DestinationPath ./${{ matrix.artifact }}.zip - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact }} path: | @@ -129,13 +215,15 @@ jobs: release: needs: build runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: artifacts @@ -155,7 +243,7 @@ jobs: cat SHA256SUMS - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: generate_release_notes: true files: release-assets/* diff --git a/App/Dialogs/SaveConfigDialog.cs b/App/Dialogs/SaveConfigDialog.cs index cffad25..31a6925 100644 --- a/App/Dialogs/SaveConfigDialog.cs +++ b/App/Dialogs/SaveConfigDialog.cs @@ -21,8 +21,10 @@ public class SaveConfigDialog : Dialog /// /// Gets the full path to save the file (directory + filename with .cfg extension). /// - public string FilePath => Path.Combine(_currentDirectory, - ConfigurationService.EnsureConfigExtension(_currentFilename)); + public string FilePath => GetNormalizedFilePath(_currentDirectory, _currentFilename); + + internal static string GetNormalizedFilePath(string directory, string filename) => + Path.Combine(directory.Trim(), ConfigurationService.EnsureConfigExtension(filename.Trim())); /// /// Gets whether the user confirmed the save operation. @@ -249,7 +251,10 @@ private bool ValidateSave() } // Check if file exists and prompt for overwrite - var fullPath = FilePath; + // Check the exact normalized path that FilePath will return. Previously + // this used the untrimmed backing fields and could miss an existing + // target such as "production.cfg " before returning "production.cfg". + var fullPath = GetNormalizedFilePath(directory, filename); if (File.Exists(fullPath)) { var result = TerminalUi.Query("Confirm Overwrite", diff --git a/App/Dialogs/WriteValueDialog.cs b/App/Dialogs/WriteValueDialog.cs index d8381e8..8207444 100644 --- a/App/Dialogs/WriteValueDialog.cs +++ b/App/Dialogs/WriteValueDialog.cs @@ -152,7 +152,7 @@ public WriteValueDialog(NodeId nodeId, string nodeName, BuiltInType dataType, st "Confirm Write", $"Write '{_valueField.Text}' to {nodeName}?", "Yes", "No"); - + if (confirmResult == 0) // Yes was selected { _confirmed = true; @@ -185,9 +185,10 @@ public WriteValueDialog(NodeId nodeId, string nodeName, BuiltInType dataType, st private void ValidateInput() { - var text = _valueField.Text?.Trim() ?? ""; + var text = NormalizeInput(_valueField.Text, _dataType); - if (string.IsNullOrEmpty(text)) + if (string.IsNullOrEmpty(text) + && _dataType is not BuiltInType.String and not BuiltInType.Variant) { _errorLabel.Text = ""; return; @@ -199,9 +200,10 @@ private void ValidateInput() private bool ValidateAndParse() { - var text = _valueField.Text?.Trim() ?? ""; + var text = NormalizeInput(_valueField.Text, _dataType); - if (string.IsNullOrEmpty(text)) + if (string.IsNullOrEmpty(text) + && _dataType is not BuiltInType.String and not BuiltInType.Variant) { _errorLabel.Text = "Value cannot be empty"; return false; @@ -225,4 +227,9 @@ private bool ValidateAndParse() _parsedValue = value; return true; } + + internal static string NormalizeInput(string? input, BuiltInType dataType) => + dataType is BuiltInType.String or BuiltInType.Variant + ? input ?? string.Empty + : input?.Trim() ?? string.Empty; } diff --git a/App/MainWindow.cs b/App/MainWindow.cs index 9accead..a56da03 100644 --- a/App/MainWindow.cs +++ b/App/MainWindow.cs @@ -44,6 +44,12 @@ public class MainWindow : Window, DefaultKeybindings.IKeybindingActions private int _connectingDotCount = 1; private bool _isConnecting; private bool _isConnected; + private volatile bool _isHydratingConfiguration; + private int _operationInProgress; + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly object _recordingStopLock = new(); + private Task? _recordingStopTask; + private int _quitInProgress; private string? _lastEndpoint; @@ -69,15 +75,27 @@ public MainWindow() _connectionManager.AutoReconnectTriggered += OnAutoReconnectTriggered; _connectionManager.VariableAdded += variable => { - UiThread.Run(() => _monitoredVariablesView?.AddVariable(variable)); - _configService.MarkDirty(); - UiThread.Run(UpdateWindowTitle); + if (!_connectionManager.IsConnectionGenerationActive(variable.ConnectionGeneration)) + return; + + UiThread.Run(() => + { + if (_connectionManager.IsConnectionGenerationActive(variable.ConnectionGeneration)) + _monitoredVariablesView?.AddVariable(variable); + }); + MarkConfigurationDirty(); }; - _connectionManager.VariableRemoved += handle => + _connectionManager.VariableRemoved += (handle, generation) => { - UiThread.Run(() => _monitoredVariablesView?.RemoveVariable(handle)); - _configService.MarkDirty(); - UiThread.Run(UpdateWindowTitle); + if (_connectionManager.ConnectionGeneration != generation) + return; + + UiThread.Run(() => + { + if (_connectionManager.ConnectionGeneration == generation) + _monitoredVariablesView?.RemoveVariable(handle); + }); + MarkConfigurationDirty(); }; // Wire up configuration service events @@ -261,8 +279,9 @@ private void RunStatusBarStartup() } else { - // Final state - show disconnected - UpdateConnectionStatus(isConnected: false); + // Final state reflects the live connection. A fast CLI-config + // connection may complete before the startup banner does. + UpdateConnectionStatus(_connectionManager.IsConnected); _startupStatusTimer = null; // Timer self-removes after returning false return false; // Stop } @@ -285,7 +304,7 @@ private MenuBar CreateMenuBar() null!, // Separator new MenuItem("Toggle Recording", "", ToggleRecording, Key.R.WithCtrl), null!, // Separator - new MenuItem("E_xit", "", () => RequestStop(), Key.Q.WithCtrl) + new MenuItem("E_xit", "", RequestQuit, Key.Q.WithCtrl) }), new MenuBarItem("_Connection", new MenuItem[] { @@ -397,6 +416,8 @@ private void ToggleTheme() private void ShowConnectDialog() { + if (RejectInteractiveMutationWhileBusy("connect")) return; + var currentInterval = _connectionManager.SubscriptionManager?.PublishingInterval ?? 250; var currentCredentials = _connectionManager.Credentials; using var dialog = new ConnectDialog( @@ -415,46 +436,103 @@ private void ShowConnectDialog() } } - private async Task ConnectAsync(string endpoint, int publishingInterval = 250, ConnectionCredentials? credentials = null) + private Task ConnectAsync(string endpoint, int publishingInterval = 250, ConnectionCredentials? credentials = null) + { + var operationGeneration = _connectionManager.RegisterExplicitLifecycleIntent(); + return RunExclusiveOperationAsync( + () => ConnectCoreAsync( + endpoint, + publishingInterval, + credentials, + operationGeneration)); + } + + private async Task ConnectCoreAsync( + string endpoint, + int publishingInterval, + ConnectionCredentials? credentials, + long operationGeneration) { // Disconnect if already connected - await DisconnectAsync(); + if (!await DisconnectCoreAsync(operationGeneration)) + return; - StartConnectingAnimation(); - ShowActivity("Connecting..."); + await UiThread.RunAsync(() => + { + StartConnectingAnimation(); + ShowActivity("Connecting..."); + }); try { - var success = await _connectionManager.ConnectAsync(endpoint, publishingInterval, credentials); + var success = await _connectionManager.ConnectWithIntentAsync( + endpoint, + publishingInterval, + credentials, + securityMode: null, + securityPolicy: null, + samplingInterval: 250, + queueSize: 10, + operationGeneration); if (success) { _lastEndpoint = endpoint; + MarkConfigurationDirty(); // The connect continuation may resume off the UI thread. UiThread.Run(() => _addressSpaceView.Initialize(_connectionManager.NodeBrowser)); } } finally { - StopConnectingAnimation(); - UiThread.Run(HideActivity); + UiThread.Run(() => + { + StopConnectingAnimation(); + HideActivity(); + }); } } - private async Task DisconnectAsync() + private Task DisconnectAsync() { - // Stop recording if active - if (_csvRecordingManager.IsRecording) + // Signal explicit user intent before waiting on the UI operation gate so an + // active automatic reconnect is cancelled immediately rather than allowed to + // publish Connected first. + var operationGeneration = _connectionManager.RegisterExplicitLifecycleIntent(); + return RunExclusiveOperationAsync( + () => DisconnectCoreAsync(operationGeneration)); + } + + private async Task DisconnectCoreAsync(long? operationGeneration = null) + { + var hadLiveState = _connectionManager.IsConnected + || _connectionManager.SubscriptionManager?.MonitoredVariables.Any() == true; + + // This method is also called from async config-load continuations, so + // marshal the pre-await recording/timer/dialog work as well as cleanup. + if (_csvRecordingManager.IsRecording || _csvRecordingManager.IsStopping) { - OnStopRecordingRequested(); + await StopRecordingAndReportAsync(); } // Async close avoids blocking the UI thread on the OPC UA round-trip. - await _connectionManager.DisconnectAsync(); + var disconnected = true; + if (operationGeneration.HasValue) + { + disconnected = await _connectionManager + .DisconnectWithIntentAsync(operationGeneration.Value); + } + else + { + await _connectionManager.DisconnectAsync(); + } + + if (!disconnected) + return false; // The disconnect continuation may resume off the UI thread, so marshal // the view updates back onto it. - UiThread.Run(() => + await UiThread.RunAsync(() => { _addressSpaceView.Clear(); _monitoredVariablesView.Clear(); @@ -462,9 +540,25 @@ private async Task DisconnectAsync() UpdateConnectionStatus(isConnected: false); }); + + if (hadLiveState) + { + MarkConfigurationDirty(); + } + + return true; } - private async Task ReconnectAsync() + private Task ReconnectAsync() + { + var operationGeneration = _connectionManager.RegisterExplicitLifecycleIntent(); + return RunExclusiveOperationAsync( + () => ReconnectCoreAsync(explicitOperationGeneration: operationGeneration)); + } + + private async Task ReconnectCoreAsync( + long? automaticIntentVersion = null, + long? explicitOperationGeneration = null) { if (string.IsNullOrEmpty(_lastEndpoint)) { @@ -472,17 +566,35 @@ private async Task ReconnectAsync() return; } - StartConnectingAnimation(); - ShowActivity("Reconnecting..."); + await UiThread.RunAsync(() => + { + StartConnectingAnimation(); + ShowActivity("Reconnecting..."); + }); try { - var success = await _connectionManager.ReconnectAsync(); + var success = automaticIntentVersion.HasValue + ? await _connectionManager.ReconnectAutomaticallyAsync(automaticIntentVersion.Value) + : explicitOperationGeneration.HasValue + && await _connectionManager.ReconnectWithIntentAsync( + explicitOperationGeneration.Value); if (success) { - // The reconnect continuation may resume off the UI thread. - UiThread.Run(() => _addressSpaceView.Initialize(_connectionManager.NodeBrowser)); + var monitoredVariables = _connectionManager.SubscriptionManager? + .MonitoredVariables + .ToList() + ?? new List(); + // Reconcile membership before releasing the UI operation gate. A + // subscribe/unsubscribe that committed while reconnect intent advanced + // intentionally had its stale event dropped; this authoritative snapshot + // repairs the table without losing scope/recording selections. + await UiThread.RunAsync(() => + { + _addressSpaceView.Initialize(_connectionManager.NodeBrowser); + _monitoredVariablesView.ReconcileVariables(monitoredVariables); + }); _logger.Info("Reconnected successfully - subscriptions restored"); } else @@ -492,13 +604,18 @@ private async Task ReconnectAsync() } finally { - StopConnectingAnimation(); - UiThread.Run(HideActivity); + UiThread.Run(() => + { + StopConnectingAnimation(); + HideActivity(); + }); } } private void RefreshTree() { + if (RejectInteractiveMutationWhileBusy("refresh the address space")) return; + if (_connectionManager.IsConnected) { _addressSpaceView.Refresh(); @@ -526,6 +643,8 @@ private void UnsubscribeSelected() private void WriteSelected() { + if (RejectInteractiveMutationWhileBusy("write a value")) return; + if (!_connectionManager.IsConnected) { _logger.Warning("Not connected"); @@ -560,6 +679,13 @@ private void WriteToMonitoredVariable(MonitoredNode variable) return; } + if (!variable.IsScalar) + { + _logger.Warning($"Array writes are not supported for node '{variable.DisplayName}'"); + TerminalUi.ErrorQuery("Write", "Array writes are not currently supported.", "OK"); + return; + } + if (!OpcValueConverter.IsWriteSupported(variable.DataType)) { _logger.Warning($"Write not supported for data type {variable.DataType}"); @@ -572,35 +698,52 @@ private void WriteToMonitoredVariable(MonitoredNode variable) variable.DisplayName, variable.DataType, variable.DataTypeName, - variable.Value); + variable.Value, + variable.ConnectionGeneration); } private async Task WriteToAddressSpaceNodeAsync(BrowsedNode node) { - byte accessLevel = 0; + var connectionGeneration = node.ConnectionGeneration; + byte userAccessLevel = 0; + // Fail closed until the server confirms the node is scalar. Treating a + // failed ValueRank read as scalar could offer an unsupported array write. + int valueRank = Opc.Ua.ValueRanks.Any; Opc.Ua.BuiltInType builtInType = Opc.Ua.BuiltInType.Variant; string dataTypeName = "Unknown"; string? currentValue = null; try { - var attrs = await _connectionManager.Client.ReadAttributesAsync( + var snapshot = await _connectionManager.ReadWriteSnapshotAsync( node.NodeId, - Opc.Ua.Attributes.AccessLevel, - Opc.Ua.Attributes.DataType); + connectionGeneration, + Opc.Ua.Attributes.UserAccessLevel, + Opc.Ua.Attributes.DataType, + Opc.Ua.Attributes.ValueRank); + if (!snapshot.HasValue) + { + _logger.Warning("Write cancelled because the connection changed"); + return; + } + + var attrs = snapshot.Value.Attributes; - if (attrs.Count >= 2) + if (attrs.Count >= 3) { if (Opc.Ua.StatusCode.IsGood(attrs[0].StatusCode) && attrs[0].Value is byte al) - accessLevel = al; + userAccessLevel = al; if (Opc.Ua.StatusCode.IsGood(attrs[1].StatusCode) && attrs[1].Value is Opc.Ua.NodeId dataTypeNodeId) { (builtInType, dataTypeName) = DataTypeResolver.Resolve(dataTypeNodeId); } + + if (Opc.Ua.StatusCode.IsGood(attrs[2].StatusCode) && attrs[2].Value is int rank) + valueRank = rank; } - var dv = await _connectionManager.Client.ReadValueAsync(node.NodeId); + var dv = snapshot.Value.Value; currentValue = dv?.Value?.ToString(); } catch (Exception ex) @@ -609,13 +752,20 @@ private async Task WriteToAddressSpaceNodeAsync(BrowsedNode node) return; } - if ((accessLevel & Opc.Ua.AccessLevels.CurrentWrite) == 0) + if ((userAccessLevel & Opc.Ua.AccessLevels.CurrentWrite) == 0) { _logger.Warning($"Node '{node.DisplayName}' is not writable"); UiThread.Run(() => TerminalUi.ErrorQuery("Write", $"Node '{node.DisplayName}' is not writable.", "OK")); return; } + if (valueRank != Opc.Ua.ValueRanks.Scalar) + { + _logger.Warning($"Array writes are not supported for node '{node.DisplayName}'"); + UiThread.Run(() => TerminalUi.ErrorQuery("Write", "Array writes are not currently supported.", "OK")); + return; + } + if (!OpcValueConverter.IsWriteSupported(builtInType)) { _logger.Warning($"Write not supported for data type {builtInType}"); @@ -623,10 +773,31 @@ private async Task WriteToAddressSpaceNodeAsync(BrowsedNode node) return; } - UiThread.Run(() => OpenWriteDialogAndWrite(node.NodeId, node.DisplayName, builtInType, dataTypeName, currentValue)); + UiThread.Run(() => + { + if (_connectionManager.ConnectionGeneration != connectionGeneration) + { + _logger.Warning("Write cancelled because the connection changed"); + return; + } + + OpenWriteDialogAndWrite( + node.NodeId, + node.DisplayName, + builtInType, + dataTypeName, + currentValue, + connectionGeneration); + }); } - private void OpenWriteDialogAndWrite(Opc.Ua.NodeId nodeId, string displayName, Opc.Ua.BuiltInType dataType, string dataTypeName, string? currentValue) + private void OpenWriteDialogAndWrite( + Opc.Ua.NodeId nodeId, + string displayName, + Opc.Ua.BuiltInType dataType, + string dataTypeName, + string? currentValue, + long connectionGeneration) { using var dialog = new WriteValueDialog(nodeId, displayName, dataType, dataTypeName, currentValue); TerminalUi.RunModal(dialog); @@ -634,12 +805,16 @@ private void OpenWriteDialogAndWrite(Opc.Ua.NodeId nodeId, string displayName, O if (!dialog.Confirmed || dialog.ParsedValue == null) return; var parsedValue = dialog.ParsedValue; - PerformWriteAsync(nodeId, displayName, parsedValue).FireAndForget(_logger); + PerformWriteAsync(nodeId, displayName, parsedValue, connectionGeneration).FireAndForget(_logger); } - private async Task PerformWriteAsync(Opc.Ua.NodeId nodeId, string displayName, object value) + private async Task PerformWriteAsync( + Opc.Ua.NodeId nodeId, + string displayName, + object value, + long connectionGeneration) { - var status = await _connectionManager.WriteValueAsync(nodeId, value); + var status = await _connectionManager.WriteValueAsync(nodeId, value, connectionGeneration); if (Opc.Ua.StatusCode.IsGood(status)) { _logger.Info($"Wrote {value} to {displayName}"); @@ -657,7 +832,9 @@ private void OnNodeSelected(BrowsedNode node) private void OnMonitoredVariableSelected(MonitoredNode node) { - _nodeDetailsView.ShowNodeByIdAsync(node.NodeId).FireAndForget(_logger); + _nodeDetailsView + .ShowNodeByIdAsync(node.NodeId, node.ConnectionGeneration) + .FireAndForget(_logger); } #region Focus Tracking and Context-Aware UI @@ -775,6 +952,14 @@ KeyCode.PageUp or KeyCode.PageDown /// protected override bool OnKeyDown(Key key) { + // A Window's default Esc handling requests application stop. Route it + // through the same unsaved-change guard as Ctrl+Q and the File menu. + if (IsQuitKey(key)) + { + RequestQuit(); + return true; + } + // Use the centralized keybinding manager for all key handling if (_keybindingManager.TryHandle(key)) { @@ -784,10 +969,14 @@ protected override bool OnKeyDown(Key key) return base.OnKeyDown(key); } + internal static bool IsQuitKey(Key key) => key.KeyCode == KeyCode.Esc; + #endregion private void OnSubscribeRequested(BrowsedNode node) { + if (RejectInteractiveMutationWhileBusy("change subscriptions")) return; + if (!_connectionManager.IsConnected) { _logger.Warning("Not connected"); @@ -800,29 +989,43 @@ private void OnSubscribeRequested(BrowsedNode node) return; } - _connectionManager.SubscribeAsync(node.NodeId, node.DisplayName).FireAndForget(_logger); + _connectionManager + .SubscribeAsync(node.NodeId, node.DisplayName, node.ConnectionGeneration) + .FireAndForget(_logger); } private void OnUnsubscribeRequested(MonitoredNode item) { - _connectionManager.UnsubscribeAsync(item.ClientHandle).FireAndForget(_logger); + if (RejectInteractiveMutationWhileBusy("change subscriptions")) return; + + _connectionManager + .UnsubscribeAsync(item.ClientHandle, item.ConnectionGeneration) + .FireAndForget(_logger); } private void OnValueChanged(MonitoredNode variable) { + if (!_connectionManager.IsConnectionGenerationActive(variable.ConnectionGeneration)) + return; + // Record to CSV if recording is active AND variable is selected for scope/recording - if (variable.IsSelectedForScope) + if (variable.IsSelectedForScope && !variable.IsSyntheticValue) { _csvRecordingManager.RecordValue(variable); } - UiThread.Run(() => _monitoredVariablesView.UpdateVariable(variable)); + UiThread.Run(() => + { + if (_connectionManager.IsConnectionGenerationActive(variable.ConnectionGeneration)) + _monitoredVariablesView.UpdateVariable(variable); + }); } private void OnConnectionStateChanged(ConnectionState state) { UiThread.Run(() => { + CancelStartupStatus(); var isConnected = state == ConnectionState.Connected; UpdateConnectionStatus(isConnected); @@ -841,19 +1044,37 @@ private void OnConnectionError(string message) }); } - private void OnAutoReconnectTriggered() + private void OnAutoReconnectTriggered(long intentVersion) { UiThread.Run(() => { _logger.Warning("Connection lost - attempting automatic reconnection..."); - ShowActivity("Reconnecting..."); - StartConnectingAnimation(); - - // Start reconnection asynchronously - ReconnectAsync().FireAndForget(_logger); + RunExclusiveOperationAsync(() => ReconnectCoreAsync(intentVersion)).FireAndForget(_logger); }); } + private void CancelStartupStatus() + { + if (_startupStatusTimer is null) + { + return; + } + + TerminalUi.RemoveTimeout(_startupStatusTimer); + _startupStatusTimer = null; + } + + private void MarkConfigurationDirty() + { + if (_isHydratingConfiguration) + { + return; + } + + _configService.MarkDirty(); + UiThread.Run(UpdateWindowTitle); + } + private void UpdateConnectionStatus(bool isConnected) { _isConnected = isConnected; @@ -894,10 +1115,19 @@ private void UpdateConnectionStatusLabelStyle(bool isConnected) private void ToggleRecording() { + if (RejectInteractiveMutationWhileBusy("change recording state")) return; + if (_csvRecordingManager.IsRecording) { OnStopRecordingRequested(); } + else if (_csvRecordingManager.IsStopping) + { + TerminalUi.Query( + "Recording", + "The previous recording is still flushing to storage. Please wait.", + "OK"); + } else { OnRecordRequested(); @@ -1025,16 +1255,73 @@ private void OnRecordRequested() } private void OnStopRecordingRequested() + => StopRecordingAndReportAsync().FireAndForget(_logger); + + private Task StopRecordingAndReportAsync() { - if (!_csvRecordingManager.IsRecording) + lock (_recordingStopLock) + { + if (_recordingStopTask is { IsCompleted: false }) + { + return _recordingStopTask; + } + + _recordingStopTask = StopRecordingAndReportCoreAsync(); + return _recordingStopTask; + } + } + + private async Task StopRecordingAndReportCoreAsync() + { + if (!_csvRecordingManager.IsRecording && !_csvRecordingManager.IsStopping) { return; } - StopRecordingStatusUpdates(); - _csvRecordingManager.StopRecording(); - _monitoredVariablesView.UpdateRecordingStatus("", false); - TerminalUi.Query("Recording", $"Recording saved.\n{_csvRecordingManager.RecordCount} records written.", "OK"); + await UiThread.RunAsync(() => + { + StopRecordingStatusUpdates(); + _monitoredVariablesView.UpdateRecordingStatus("Finishing...", true); + }); + + // Storage work is awaited asynchronously, so a slow disk never freezes + // the terminal UI. Do not claim success until the writer has closed. + var result = await _csvRecordingManager + .StopRecordingAsync(System.Threading.Timeout.InfiniteTimeSpan) + .ConfigureAwait(false); + + await UiThread.RunAsync(() => + { + _monitoredVariablesView.UpdateRecordingStatus("", false); + + if (!result.Completed) + { + TerminalUi.ErrorQuery( + "Recording Incomplete", + "The recording file is still open and has not finished writing.", + "OK"); + return; + } + + if (result.HasDataLoss) + { + var error = string.IsNullOrEmpty(result.ErrorMessage) + ? string.Empty + : $"\nStorage error: {result.ErrorMessage}"; + TerminalUi.ErrorQuery( + "Recording Incomplete", + $"{result.RecordCount} records were written.\n" + + $"{result.DroppedRecordCount} records were dropped because the queue was full.\n" + + $"{result.FailedRecordCount} records failed during writing.{error}", + "OK"); + return; + } + + TerminalUi.Query( + "Recording", + $"Recording saved.\n{result.RecordCount} records written.", + "OK"); + }); } private void StartRecordingStatusUpdates() @@ -1127,6 +1414,8 @@ private static string GetDisplayVersion() /// private void OpenConfig() { + if (RejectInteractiveMutationWhileBusy("open a configuration")) return; + if (_configService.HasUnsavedChanges && !ConfirmDiscardChanges()) return; @@ -1145,6 +1434,8 @@ private void OpenConfig() /// private void SaveConfig() { + if (RejectInteractiveMutationWhileBusy("save the configuration")) return; + if (string.IsNullOrEmpty(_configService.CurrentFilePath)) { SaveConfigAs(); @@ -1161,6 +1452,8 @@ private void SaveConfig() /// private void SaveConfigAs() { + if (RejectInteractiveMutationWhileBusy("save the configuration")) return; + // Get the default directory and generate a default filename var defaultDir = ConfigurationService.GetDefaultConfigDirectory(); var defaultFilename = ConfigurationService.GenerateDefaultFilename(_connectionManager.CurrentEndpoint); @@ -1178,11 +1471,23 @@ private void SaveConfigAs() /// /// Loads a configuration from the specified file path. /// - private async Task LoadConfigurationAsync(string filePath) + private Task LoadConfigurationAsync(string filePath) + { + var expectedIntentVersion = _connectionManager.ConnectionIntentVersion; + return RunExclusiveOperationAsync( + () => LoadConfigurationCoreAsync(filePath, expectedIntentVersion)); + } + + private async Task LoadConfigurationCoreAsync( + string filePath, + long expectedIntentVersion) { + long? registeredGeneration = null; + var sessionTeardownCompleted = false; + _isHydratingConfiguration = true; try { - ShowActivity("Loading configuration..."); + await UiThread.RunAsync(() => ShowActivity("Loading configuration...")); _logger.Info($"Loading configuration from {filePath}..."); var config = await _configService.LoadAsync(filePath); @@ -1227,18 +1532,33 @@ private async Task LoadConfigurationAsync(string filePath) // Tear down the current session first: stops any active recording and // clears the views, so the UI cannot keep showing dead rows from the // old server while (or after) the new connection is attempted. - await DisconnectAsync(); + if (!_connectionManager.TryRegisterExplicitLifecycleIntent( + expectedIntentVersion, + out var operationGeneration)) + { + AbortLoadedConfigurationForNewerConnectionIntent(); + return; + } + + registeredGeneration = operationGeneration; + if (!await DisconnectCoreAsync(operationGeneration)) + { + AbortLoadedConfigurationForNewerConnectionIntent(); + return; + } + sessionTeardownCompleted = true; // Honor the config's security and subscription settings (the connect dialog // has no UI for these, so the config file is their only source). - var connected = await _connectionManager.ConnectAsync( + var connected = await _connectionManager.ConnectWithIntentAsync( config.Server.EndpointUrl, config.Settings.PublishingIntervalMs, credentials, config.Server.SecurityMode, config.Server.SecurityPolicy, config.Settings.SamplingIntervalMs, - config.Settings.QueueSize); + config.Settings.QueueSize, + operationGeneration); if (connected) { @@ -1248,20 +1568,35 @@ private async Task LoadConfigurationAsync(string filePath) UiThread.Run(() => _addressSpaceView.Initialize(_connectionManager.NodeBrowser)); // Subscribe to saved nodes + var allSubscriptionsRestored = true; foreach (var node in config.MonitoredNodes.Where(n => n.Enabled)) { try { var nodeId = Opc.Ua.NodeId.Parse(node.NodeId); - await _connectionManager.SubscribeAsync(nodeId, node.DisplayName); + var restored = await _connectionManager.SubscribeAsync(nodeId, node.DisplayName); + if (restored is null) + { + allSubscriptionsRestored = false; + _logger.Warning($"Failed to subscribe to {node.DisplayName}"); + } } catch (Exception ex) { + allSubscriptionsRestored = false; _logger.Warning($"Failed to subscribe to {node.DisplayName}: {ex.Message}"); } } _recentFiles.Add(filePath); + if (allSubscriptionsRestored) + { + _configService.MarkClean(); + } + else + { + _configService.MarkDirty(); + } UiThread.Run(UpdateWindowTitle); var nodeCount = config.MonitoredNodes.Count(n => n.Enabled); @@ -1288,44 +1623,114 @@ private async Task LoadConfigurationAsync(string filePath) { // No endpoint URL: tear down any current session (stops recording, // clears views) and just adopt the loaded settings. - await DisconnectAsync(); + if (!_connectionManager.TryRegisterExplicitLifecycleIntent( + expectedIntentVersion, + out var operationGeneration)) + { + AbortLoadedConfigurationForNewerConnectionIntent(); + return; + } + + registeredGeneration = operationGeneration; + if (!await DisconnectCoreAsync(operationGeneration)) + { + AbortLoadedConfigurationForNewerConnectionIntent(); + return; + } + sessionTeardownCompleted = true; _currentMetadata = config.Metadata; _recentFiles.Add(filePath); + _configService.MarkClean(); UiThread.Run(UpdateWindowTitle); _logger.Info("Configuration loaded (no server connection)"); } } catch (Exception ex) { + if (registeredGeneration.HasValue && !sessionTeardownCompleted) + RestoreSessionAfterAbandonedLoad(registeredGeneration.Value); + _logger.Error($"Failed to load configuration: {ex.Message}"); UiThread.Run(() => TerminalUi.ErrorQuery("Error", $"Failed to load configuration:\n{ex.Message}", "OK")); } finally { - UiThread.Run(HideActivity); + _isHydratingConfiguration = false; + await UiThread.RunAsync(HideActivity); } } + private void AbortLoadedConfigurationForNewerConnectionIntent() + { + _configService.Reset(); + _currentMetadata = null; + UiThread.Run(UpdateWindowTitle); + _logger.Info("Configuration load abandoned because a newer connection operation was requested"); + } + + private void RestoreSessionAfterAbandonedLoad(long operationGeneration) + { + _connectionManager.RestoreSessionAfterAbandonedIntent(operationGeneration); + if (!_connectionManager.IsConnectionGenerationActive(operationGeneration)) + return; + + UiThread.Run(() => + { + if (!_connectionManager.IsConnectionGenerationActive(operationGeneration)) + return; + + _addressSpaceView.Initialize(_connectionManager.NodeBrowser); + _nodeDetailsView.Clear(); + }); + } + /// /// Saves the current configuration to the specified file path. /// - private async Task SaveConfigurationAsync(string filePath) + private Task SaveConfigurationAsync(string filePath) + => RunExclusiveOperationAsync( + () => SaveConfigurationCoreAsync(filePath)); + + private async Task SaveConfigurationCoreAsync(string filePath) { try { - ShowActivity("Saving configuration..."); + await UiThread.RunAsync(() => ShowActivity("Saving configuration...")); var monitoredVariables = _connectionManager.SubscriptionManager?.MonitoredVariables ?? Enumerable.Empty(); + // Persist the profile actually selected/applied by the active + // connection. Falling back to model defaults here previously wrote + // SecurityMode=None for a credentialed secure session, so reload + // could downgrade or reject the connection. + ServerConfig? activeServer = null; + SubscriptionSettings? activeSettings = null; + if (_connectionManager.IsConnected) + { + activeServer = new ServerConfig + { + SecurityMode = _connectionManager.CurrentSecurityMode?.ToString(), + SecurityPolicy = _connectionManager.CurrentSecurityPolicy + }; + activeSettings = new SubscriptionSettings + { + PublishingIntervalMs = _connectionManager.SubscriptionManager?.PublishingInterval ?? 250, + SamplingIntervalMs = _connectionManager.SamplingInterval, + QueueSize = _connectionManager.QueueSize + }; + } + var config = _configService.CaptureCurrentState( _connectionManager.CurrentEndpoint, _connectionManager.SubscriptionManager?.PublishingInterval ?? 250, monitoredVariables, _currentMetadata, - _connectionManager.Credentials + _connectionManager.Credentials, + existingServer: activeServer, + existingSettings: activeSettings ); // Update metadata name from filename if not set @@ -1350,7 +1755,7 @@ private async Task SaveConfigurationAsync(string filePath) } finally { - UiThread.Run(HideActivity); + await UiThread.RunAsync(HideActivity); } } @@ -1384,6 +1789,138 @@ private bool ConfirmDiscardChanges() return result == 0; // Discard } + internal static bool CanQuit(bool hasUnsavedChanges, Func confirmDiscard) + => !hasUnsavedChanges || confirmDiscard(); + + private async Task RunExclusiveOperationAsync(Func operation) + { + await _operationGate.WaitAsync(); + Volatile.Write(ref _operationInProgress, 1); + + try + { + await operation(); + } + finally + { + Volatile.Write(ref _operationInProgress, 0); + _operationGate.Release(); + } + } + + private bool RejectInteractiveMutationWhileBusy(string action) + { + if (Volatile.Read(ref _operationInProgress) == 0) + { + return false; + } + + _logger.Warning($"Cannot {action}: another connection or configuration operation is still running"); + TerminalUi.Query( + "Operation In Progress", + "Please wait for the current connection or configuration operation to finish.", + "OK"); + return true; + } + + private void RequestQuit() + { + if (!CanQuit(_configService.HasUnsavedChanges, ConfirmDiscardChanges)) + { + return; + } + + if (Interlocked.Exchange(ref _quitInProgress, 1) != 0) + return; + + RequestQuitCoreAsync().FireAndForget(_logger); + } + + private async Task RequestQuitCoreAsync() + { + try + { + if (_csvRecordingManager.IsRecording || _csvRecordingManager.IsStopping) + { + var stopTask = StopRecordingAndReportAsync(); + var canStopUi = await AwaitRecordingStopForQuitAsync( + stopTask, + TimeSpan.FromSeconds(10), + PromptForSlowRecordingShutdownAsync).ConfigureAwait(false); + if (!canStopUi) + { + Interlocked.Exchange(ref _quitInProgress, 0); + return; + } + } + + await UiThread.RunAsync(TerminalUi.RequestStop).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.Error($"Quit preparation failed: {ex.Message}"); + Interlocked.Exchange(ref _quitInProgress, 0); + await UiThread.RunAsync(() => TerminalUi.ErrorQuery( + "Unable to Quit", + "The application could not finish preparing to quit. Review the log and try again.", + "OK")).ConfigureAwait(false); + } + } + + internal enum SlowRecordingQuitDecision + { + KeepWaiting, + QuitAnyway, + Cancel + } + + internal static async Task AwaitRecordingStopForQuitAsync( + Task stopTask, + TimeSpan timeout, + Func> getTimeoutDecision) + { + ArgumentNullException.ThrowIfNull(stopTask); + ArgumentNullException.ThrowIfNull(getTimeoutDecision); + + while (!stopTask.IsCompleted) + { + var completed = await Task.WhenAny(stopTask, Task.Delay(timeout)).ConfigureAwait(false); + if (ReferenceEquals(completed, stopTask)) + break; + + var decision = await getTimeoutDecision().ConfigureAwait(false); + if (decision == SlowRecordingQuitDecision.QuitAnyway) + return true; + if (decision == SlowRecordingQuitDecision.Cancel) + return false; + } + + // Observe any exception before allowing the UI to stop. + await stopTask.ConfigureAwait(false); + return true; + } + + private async Task PromptForSlowRecordingShutdownAsync() + { + var decision = SlowRecordingQuitDecision.Cancel; + await UiThread.RunAsync(() => + { + var choice = TerminalUi.Query( + "Recording Still Finishing", + "The recording file is still being flushed. Quitting now may truncate it.", + "Keep Waiting", + "Quit Anyway", + "Cancel Quit"); + decision = choice switch + { + 0 => SlowRecordingQuitDecision.KeepWaiting, + 1 => SlowRecordingQuitDecision.QuitAnyway, + _ => SlowRecordingQuitDecision.Cancel + }; + }).ConfigureAwait(false); + return decision; + } + /// /// Loads a configuration file from the command line argument. /// @@ -1415,7 +1952,7 @@ void DefaultKeybindings.IKeybindingActions.ToggleScopeSelection() { /* Handled b void DefaultKeybindings.IKeybindingActions.ToggleRecording() => ToggleRecording(); void DefaultKeybindings.IKeybindingActions.Connect() => ShowConnectDialog(); void DefaultKeybindings.IKeybindingActions.Disconnect() => DisconnectAsync().FireAndForget(_logger); - void DefaultKeybindings.IKeybindingActions.Quit() => RequestStop(); + void DefaultKeybindings.IKeybindingActions.Quit() => RequestQuit(); #endregion @@ -1427,11 +1964,7 @@ protected override void Dispose(bool disposing) StopConnectingAnimation(); // Remove the startup status timer if it hasn't yet self-removed. - if (_startupStatusTimer != null) - { - TerminalUi.RemoveTimeout(_startupStatusTimer); - _startupStatusTimer = null; - } + CancelStartupStatus(); _csvRecordingManager.Dispose(); ThemeManager.ThemeChanged -= OnThemeChanged; diff --git a/App/Themes/AppTheme.cs b/App/Themes/AppTheme.cs index d7cdf2c..f4864ec 100644 --- a/App/Themes/AppTheme.cs +++ b/App/Themes/AppTheme.cs @@ -15,7 +15,7 @@ public abstract class AppTheme /// /// When true, the application restricts output to the 16 ANSI colors - /// (via ) so the terminal renders + /// (via the active driver's Force16Colors property) so the terminal renders /// the theme using its own configured ANSI palette. Themes setting this /// should define all colors using values. /// diff --git a/App/Views/AddressSpaceView.cs b/App/Views/AddressSpaceView.cs index db16124..de1ad1a 100644 --- a/App/Views/AddressSpaceView.cs +++ b/App/Views/AddressSpaceView.cs @@ -18,6 +18,7 @@ public class AddressSpaceView : FrameView private readonly Label _emptyStateLabel; private NodeBrowser? _nodeBrowser; private BrowsedNode? _rootNode; + private long _viewGeneration; public event Action? NodeSelected; public event Action? NodeSubscribeRequested; @@ -94,26 +95,30 @@ private void OnThemeChanged(AppTheme theme) public void Initialize(NodeBrowser nodeBrowser) { _nodeBrowser = nodeBrowser; + var viewGeneration = Interlocked.Increment(ref _viewGeneration); _emptyStateLabel.Visible = false; _treeView.Visible = true; - _ = RefreshAsync(); + _ = RefreshAsync(viewGeneration); } public void Refresh() { - _ = RefreshAsync(); + var viewGeneration = Interlocked.Increment(ref _viewGeneration); + _ = RefreshAsync(viewGeneration); } - private async Task RefreshAsync() + private async Task RefreshAsync(long viewGeneration) { - if (_nodeBrowser == null) return; + var nodeBrowser = _nodeBrowser; + if (nodeBrowser == null) return; - _rootNode = _nodeBrowser.GetRootNode(); + var connectionGeneration = nodeBrowser.ConnectionGeneration; + var rootNode = nodeBrowser.GetRootNode(); // Pre-load root children in background before updating UI try { - await _nodeBrowser.GetChildrenAsync(_rootNode); + await nodeBrowser.GetChildrenAsync(rootNode); } catch { @@ -123,17 +128,23 @@ private async Task RefreshAsync() // Update UI on main thread UiThread.Run(() => { + if (!IsCurrentView(nodeBrowser, viewGeneration, connectionGeneration)) + return; + + _rootNode = rootNode; _treeView.ClearObjects(); - _treeView.AddObject(_rootNode); - if (_rootNode.ChildrenLoaded) + _treeView.AddObject(rootNode); + if (rootNode.ChildrenLoaded) { - _treeView.Expand(_rootNode); + _treeView.Expand(rootNode); } }); } public void Clear() { + Interlocked.Increment(ref _viewGeneration); + _nodeBrowser = null; _treeView.ClearObjects(); _rootNode = null; _treeView.Visible = false; @@ -142,7 +153,9 @@ public void Clear() private IEnumerable GetChildrenForNode(BrowsedNode node) { - if (_nodeBrowser == null) + var nodeBrowser = _nodeBrowser; + if (nodeBrowser == null + || !nodeBrowser.IsConnectionGenerationActive(node.ConnectionGeneration)) return Enumerable.Empty(); if (node.ChildrenLoaded) @@ -150,21 +163,30 @@ private IEnumerable GetChildrenForNode(BrowsedNode node) // Load children asynchronously to avoid blocking UI // Return empty now, then refresh when loaded - _ = LoadChildrenAsync(node); + _ = LoadChildrenAsync( + nodeBrowser, + node, + Volatile.Read(ref _viewGeneration), + node.ConnectionGeneration); return Enumerable.Empty(); } - private async Task LoadChildrenAsync(BrowsedNode node) + private async Task LoadChildrenAsync( + NodeBrowser nodeBrowser, + BrowsedNode node, + long viewGeneration, + long connectionGeneration) { - if (_nodeBrowser == null) return; - try { - await _nodeBrowser.GetChildrenAsync(node); + await nodeBrowser.GetChildrenAsync(node); // Refresh the tree on UI thread after children are loaded UiThread.Run(() => { + if (!IsCurrentView(nodeBrowser, viewGeneration, connectionGeneration)) + return; + _treeView.RefreshObject(node); if (node.ChildrenLoaded && node.Children.Count > 0) { @@ -179,6 +201,14 @@ private async Task LoadChildrenAsync(BrowsedNode node) } } + private bool IsCurrentView( + NodeBrowser nodeBrowser, + long viewGeneration, + long connectionGeneration) + => ReferenceEquals(_nodeBrowser, nodeBrowser) + && Volatile.Read(ref _viewGeneration) == viewGeneration + && nodeBrowser.IsConnectionGenerationActive(connectionGeneration); + private bool HasChildrenForNode(BrowsedNode node) { return node.HasChildren; diff --git a/App/Views/MonitoredVariablesView.cs b/App/Views/MonitoredVariablesView.cs index a5bf77c..2325d5c 100644 --- a/App/Views/MonitoredVariablesView.cs +++ b/App/Views/MonitoredVariablesView.cs @@ -26,8 +26,12 @@ public class MonitoredVariablesView : FrameView // This reduces table redraws from potentially 100+/sec to max 20/sec. private const int UpdateBatchIntervalMs = 50; private readonly ConcurrentDictionary _pendingUpdates = new(); + private readonly Func, object?> _addTimeout; + private readonly Action _removeTimeout; private object? _updateTimer; private bool _updateTimerRunning; + private long _updateTimerEpoch; + private bool _disposed; private readonly object _timerLock = new(); private readonly TableView _tableView; @@ -93,7 +97,17 @@ public IReadOnlyList ScopeSelectedNodes public int ScopeSelectionCount => _cachedScopeSelectionCount; public MonitoredVariablesView() + : this(TerminalUi.AddTimeout, TerminalUi.RemoveTimeout) { + } + + internal MonitoredVariablesView( + Func, object?> addTimeout, + Action removeTimeout) + { + _addTimeout = addTimeout ?? throw new ArgumentNullException(nameof(addTimeout)); + _removeTimeout = removeTimeout ?? throw new ArgumentNullException(nameof(removeTimeout)); + Title = " Monitored Variables "; CanFocus = true; @@ -283,98 +297,204 @@ public void AddVariable(MonitoredNode variable) public void UpdateVariable(MonitoredNode variable) { - // Queue the update for batched processing - _pendingUpdates[variable.ClientHandle] = variable; - - // Start the update timer if not already running - EnsureUpdateTimerRunning(); - } - - /// - /// Ensures the batched update timer is running. - /// - private void EnsureUpdateTimerRunning() - { + long timerEpoch; lock (_timerLock) { + if (_disposed) + { + return; + } + + // Enqueue and inspect timer state under one lock. Clear() uses the + // same lock, so an update is linearized wholly before or after a + // session reset instead of leaking across it. + if (_rowsByHandle.TryGetValue(variable.ClientHandle, out var row) + && !HasSameUpdateSource(row, variable)) + { + // Client handles restart for each SubscriptionManager. An old + // notification can reach the UI after Clear() and collide with a + // new row's handle, so require matching connection provenance. + return; + } + + _pendingUpdates[variable.ClientHandle] = variable; if (_updateTimerRunning) + { return; + } _updateTimerRunning = true; - _updateTimer = TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(UpdateBatchIntervalMs), ProcessPendingUpdates); + timerEpoch = ++_updateTimerEpoch; } - } - /// - /// Processes all pending variable updates in a single batch. - /// - private bool ProcessPendingUpdates() - { - // Defensive check - handle case where disposal happens concurrently - if (_pendingUpdates.IsEmpty) + object? timer; + try + { + timer = _addTimeout( + TimeSpan.FromMilliseconds(UpdateBatchIntervalMs), + () => ProcessPendingUpdates(timerEpoch)); + } + catch { lock (_timerLock) { + if (_updateTimerEpoch == timerEpoch) + { + _updateTimerRunning = false; + _updateTimer = null; + } + } + throw; + } + + object? staleTimer = null; + lock (_timerLock) + { + // Clear()/Dispose() may invalidate this registration while the + // scheduler call is in progress. Never publish a stale token. + if (_disposed || _updateTimerEpoch != timerEpoch || !_updateTimerRunning) + { + staleTimer = timer; + } + else if (timer is null) + { + // Headless tests have no application timer. Leave the queued + // value available to the deterministic processing seam. _updateTimerRunning = false; } - return false; + else + { + _updateTimer = timer; + } } - // Snapshot and clear pending updates - var keys = _pendingUpdates.Keys.ToList(); - var updates = new List<(uint ClientHandle, MonitoredNode Variable)>(); + if (staleTimer is not null) + { + _removeTimeout(staleTimer); + } + } - foreach (var key in keys) + /// + /// Processes all pending variable updates in a single batch. + /// + private bool ProcessPendingUpdates(long timerEpoch) => + ProcessPendingUpdatesCore(timerEpoch); + + /// + /// Deterministic seam for exercising the transition between observing an + /// empty queue and retiring the update timer. + /// + internal bool ProcessPendingUpdatesForTest(Action? beforeIdleTransition = null) + { + long timerEpoch; + lock (_timerLock) { - if (_pendingUpdates.TryRemove(key, out var variable)) + if (!_updateTimerRunning) { - updates.Add((key, variable)); + _updateTimerRunning = true; + _updateTimerEpoch++; } + timerEpoch = _updateTimerEpoch; } - if (updates.Count == 0) + return ProcessPendingUpdatesCore(timerEpoch, beforeIdleTransition); + } + + internal int PendingUpdateCountForTest + { + get { - // No more updates - stop the timer lock (_timerLock) { - _updateTimerRunning = false; + return _pendingUpdates.Count; } - return false; } + } + + internal string? GetDisplayedValueForTest(uint clientHandle) => + _rowsByHandle.TryGetValue(clientHandle, out var row) + ? row["Value"] as string + : null; - // Apply all updates to DataTable rows - foreach (var (clientHandle, variable) in updates) + private bool ProcessPendingUpdatesCore( + long timerEpoch, + Action? beforeIdleTransition = null) + { + List<(uint ClientHandle, MonitoredNode Variable)> updates; + lock (_timerLock) { - if (_rowsByHandle.TryGetValue(clientHandle, out var row)) + if (_disposed || !_updateTimerRunning || _updateTimerEpoch != timerEpoch) { - row["Access"] = variable.AccessString; - row["Sel"] = variable.IsSelectedForScope ? CheckedBox : UncheckedBox; - row["Value"] = variable.Value; - row["Time"] = variable.TimestampString; - row["Status"] = FormatStatusWithIcon(variable); + return false; } - } - // Check if more updates arrived while we were processing (before redraw) - // This avoids a race condition where updates arriving between Update() and - // IsEmpty check would be orphaned until the next UpdateVariable() call - bool hasMoreUpdates = !_pendingUpdates.IsEmpty; + // Snapshot and clear pending updates while enqueue/Clear are + // excluded. This is the batch's linearization point. + updates = new List<(uint ClientHandle, MonitoredNode Variable)>(); + foreach (var key in _pendingUpdates.Keys.ToList()) + { + if (_pendingUpdates.TryRemove(key, out var variable)) + { + updates.Add((key, variable)); + } + } - // Single table redraw for all updates - _tableView.Update(); + // Apply the captured batch while Clear() is excluded. Timer + // callbacks and row mutations normally share the UI thread; the + // lock also makes the contract safe for deterministic tests. + foreach (var (clientHandle, variable) in updates) + { + if (_rowsByHandle.TryGetValue(clientHandle, out var row) + && HasSameUpdateSource(row, variable)) + { + row["Access"] = variable.AccessString; + row["Sel"] = variable.IsSelectedForScope ? CheckedBox : UncheckedBox; + row["Value"] = variable.Value; + row["Time"] = variable.TimestampString; + row["Status"] = FormatStatusWithIcon(variable); + } + } - if (!hasMoreUpdates) - { - lock (_timerLock) + if (updates.Count > 0) { - _updateTimerRunning = false; + // Single table redraw for all updates. + _tableView.Update(); } - return false; // Stop timer } - return true; // Continue timer for remaining updates + // Tests inject an update here to deterministically reproduce the race + // immediately before the timer attempts its idle transition. + beforeIdleTransition?.Invoke(); + + lock (_timerLock) + { + if (_disposed || _updateTimerEpoch != timerEpoch) + { + return false; + } + + // Re-check under the same lock used by UpdateVariable. If an update + // arrived after the batch snapshot, the current timer remains alive; + // otherwise retire it atomically so the next enqueue schedules one. + if (!_pendingUpdates.IsEmpty) + { + return true; + } + + _updateTimerRunning = false; + _updateTimer = null; + return false; + } } + private static bool HasSameUpdateSource(DataRow row, MonitoredNode variable) => + row["_VariableRef"] is MonitoredNode displayedVariable + && displayedVariable.ConnectionGeneration == variable.ConnectionGeneration + // A reconnect fallback can replace the SubscriptionManager without + // advancing the connection lifecycle again. In that case generations + // match, but the new row still owns a different model instance. + && ReferenceEquals(displayedVariable, variable); + public void RemoveVariable(uint clientHandle) { if (!_rowsByHandle.TryGetValue(clientHandle, out var row)) @@ -397,20 +517,60 @@ public void RemoveVariable(uint clientHandle) public void Clear() { - // Clear all scope selections before clearing table - foreach (DataRow row in _dataTable.Rows) + object? updateTimer; + lock (_timerLock) { - if (row["_VariableRef"] is MonitoredNode node) + // Invalidate callbacks before allowing client handles to be reused + // by a new connection. A registration still being created observes + // the epoch change and removes its own stale token. + _updateTimerEpoch++; + _updateTimerRunning = false; + updateTimer = _updateTimer; + _updateTimer = null; + _pendingUpdates.Clear(); + + // Clear all scope selections before clearing table. + foreach (DataRow row in _dataTable.Rows) { - node.IsSelectedForScope = false; + if (row["_VariableRef"] is MonitoredNode node) + { + node.IsSelectedForScope = false; + } } + + _cachedScopeSelectionCount = 0; + _dataTable.Rows.Clear(); + _rowsByHandle.Clear(); + _tableView.Update(); + UpdateEmptyState(); } - _cachedScopeSelectionCount = 0; - _dataTable.Rows.Clear(); - _rowsByHandle.Clear(); - _tableView.Update(); - UpdateEmptyState(); + if (updateTimer is not null) + { + _removeTimeout(updateTimer); + } + } + + /// + /// Rebuilds table membership from the authoritative subscription snapshot after + /// reconnect. This repairs add/remove events deliberately dropped while connection + /// intent was changing, while preserving scope/recording selections on retained + /// model instances. + /// + public void ReconcileVariables(IEnumerable variables) + { + var snapshot = variables + .Select(variable => (Variable: variable, variable.IsSelectedForScope)) + .ToList(); + + Clear(); + foreach (var (variable, wasSelected) in snapshot) + { + variable.IsSelectedForScope = wasSelected; + AddVariable(variable); + } + + ScopeSelectionChanged?.Invoke(_cachedScopeSelectionCount); } private string FormatStatusWithIcon(MonitoredNode item) @@ -585,17 +745,21 @@ protected override void Dispose(bool disposing) { if (disposing) { - // Stop the update timer + object? updateTimer; lock (_timerLock) { - if (_updateTimer != null) - { - TerminalUi.RemoveTimeout(_updateTimer); - _updateTimer = null; - } + _disposed = true; + _updateTimerEpoch++; _updateTimerRunning = false; + updateTimer = _updateTimer; + _updateTimer = null; + _pendingUpdates.Clear(); + } + + if (updateTimer is not null) + { + _removeTimeout(updateTimer); } - _pendingUpdates.Clear(); ThemeManager.ThemeChanged -= OnThemeChanged; _recordButton.Accepting -= OnRecordButtonClicked; diff --git a/App/Views/NodeDetailsView.cs b/App/Views/NodeDetailsView.cs index beca5a8..63c69cf 100644 --- a/App/Views/NodeDetailsView.cs +++ b/App/Views/NodeDetailsView.cs @@ -17,6 +17,7 @@ public class NodeDetailsView : FrameView private readonly Button _copyButton; private Opcilloscope.OpcUa.NodeBrowser? _nodeBrowser; private NodeId? _currentNodeId; + private long _currentConnectionGeneration; private Logger? _logger; private CancellationTokenSource? _copyOperationCts; @@ -96,11 +97,14 @@ public void Initialize(Opcilloscope.OpcUa.NodeBrowser nodeBrowser, Logger? logge _logger = logger; } - public async Task ShowNodeByIdAsync(NodeId? nodeId) + public async Task ShowNodeByIdAsync( + NodeId? nodeId, + long? expectedGeneration = null) { if (nodeId == null || _nodeBrowser == null) { _currentNodeId = null; + _currentConnectionGeneration = 0; UiThread.Run(() => { _detailsLabel.Text = "Select a node to view details"; @@ -111,13 +115,17 @@ public async Task ShowNodeByIdAsync(NodeId? nodeId) } _currentNodeId = nodeId; - var attrs = await _nodeBrowser.GetNodeAttributesAsync(nodeId); + var generation = expectedGeneration ?? _nodeBrowser.ConnectionGeneration; + _currentConnectionGeneration = generation; + var attrs = await _nodeBrowser.GetNodeAttributesAsync(nodeId, generation); UiThread.Run(() => { // Guard against stale responses: rapid selection changes can complete // out of order, so only apply this result if it is still the current node. - if (!Equals(_currentNodeId, nodeId)) + if (!Equals(_currentNodeId, nodeId) + || _currentConnectionGeneration != generation + || _nodeBrowser?.IsConnectionGenerationActive(generation) != true) return; if (attrs == null) @@ -156,6 +164,7 @@ public async Task ShowNodeAsync(BrowsedNode? node) if (node == null || _nodeBrowser == null) { _currentNodeId = null; + _currentConnectionGeneration = 0; UiThread.Run(() => { _detailsLabel.Text = ""; @@ -166,14 +175,18 @@ public async Task ShowNodeAsync(BrowsedNode? node) } var nodeId = node.NodeId; + var generation = node.ConnectionGeneration; _currentNodeId = nodeId; - var attrs = await _nodeBrowser.GetNodeAttributesAsync(nodeId); + _currentConnectionGeneration = generation; + var attrs = await _nodeBrowser.GetNodeAttributesAsync(nodeId, generation); UiThread.Run(() => { // Guard against stale responses: rapid selection changes can complete // out of order, so only apply this result if it is still the current node. - if (!Equals(_currentNodeId, nodeId)) + if (!Equals(_currentNodeId, nodeId) + || _currentConnectionGeneration != generation + || _nodeBrowser?.IsConnectionGenerationActive(generation) != true) return; if (attrs == null) @@ -212,6 +225,7 @@ public async Task ShowNodeAsync(BrowsedNode? node) public void Clear() { _currentNodeId = null; + _currentConnectionGeneration = 0; _detailsLabel.Text = "Not connected"; _copyButton.Enabled = false; SetMutedColor(); @@ -275,10 +289,15 @@ private async void OnCopyClicked(object? sender, CommandEventArgs e) _copyButton.Text = "..."; _copyButton.Enabled = false; - var attributes = await _nodeBrowser.ReadAllNodeAttributesAsync(_currentNodeId); + var nodeId = _currentNodeId; + var generation = _currentConnectionGeneration; + var attributes = await _nodeBrowser.ReadAllNodeAttributesAsync(nodeId, generation); // Check if operation was cancelled - if (cancellationToken.IsCancellationRequested) + if (cancellationToken.IsCancellationRequested + || !Equals(_currentNodeId, nodeId) + || _currentConnectionGeneration != generation + || !_nodeBrowser.IsConnectionGenerationActive(generation)) return; UiThread.Run(() => diff --git a/App/Views/ScopeView.cs b/App/Views/ScopeView.cs index dc2b0fa..ab81f6c 100644 --- a/App/Views/ScopeView.cs +++ b/App/Views/ScopeView.cs @@ -909,13 +909,11 @@ protected override bool OnKeyDown(Key key) TogglePause(); return true; - case KeyCode.D0 when key.IsShift: // + key case (KeyCode)'=': case (KeyCode)'+': IncreaseScale(); return true; - case KeyCode.D9 when key.IsShift: // ( key case (KeyCode)'-': DecreaseScale(); return true; diff --git a/CLAUDE.md b/CLAUDE.md index f99adc6..ee5bc93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,15 @@ The project name is **opcilloscope** (lowercase "o") in all contexts except wher | User-facing text, CLI, URLs | `opcilloscope` | `opcilloscope --help` | | C# namespaces, classes, projects | `Opcilloscope` | `namespace Opcilloscope.App` | | File/folder names (code) | `Opcilloscope` | `Opcilloscope.csproj` | -| Config directories (all platforms) | `opcilloscope` | `~/.config/opcilloscope/` | +| Config/data directories | `opcilloscope` | Use the platform locations below | | Release artifacts | `opcilloscope` | `opcilloscope-linux-x64.tar.gz` | +Platform directories: +- Linux configuration: `${XDG_CONFIG_HOME:-$HOME/.config}/opcilloscope/` +- Linux application data (certificates and installed notices): `${XDG_DATA_HOME:-$HOME/.local/share}/opcilloscope/` +- macOS configuration and application data: `~/Library/Application Support/opcilloscope/` +- Windows configuration: `%APPDATA%\opcilloscope\`; certificates: `%LOCALAPPDATA%\opcilloscope\pki\` + ## Environment Setup ### .NET SDK Installation @@ -34,17 +40,20 @@ export PATH="$HOME/.dotnet:$PATH" ```bash # Build -dotnet build +dotnet build Opcilloscope.sln # Run (from repo root) -dotnet run +dotnet run --project Opcilloscope.csproj # Run with a configuration file -dotnet run -- config.cfg -dotnet run -- --config config.cfg +dotnet run --project Opcilloscope.csproj -- config.cfg +dotnet run --project Opcilloscope.csproj -- --config config.cfg + +# Run the cross-platform unit, integration, and component suite +dotnet test Opcilloscope.sln -# Run tests -dotnet test +# Linux only: publish and exercise the real TUI through a PTY +dotnet test Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj ``` ## Command-Line Interface @@ -54,15 +63,20 @@ Usage: opcilloscope [options] [file] Options: -f, --config Load configuration file (.cfg, .opcilloscope, or .json) + -c, --connect Reserved; direct URL connection is not yet implemented --insecure Accept untrusted server certificates (development only) -h, --help Show help message Examples: opcilloscope Start with empty configuration - opcilloscope production.cfg Load configuration file + opcilloscope production.cfg Load configuration file opcilloscope --config config.json Load configuration file ``` +The Linux-only `Tests/Opcilloscope.E2ETests` project intentionally stays out +of `Opcilloscope.sln`; see [`docs/TESTING.md`](docs/TESTING.md) for all test +layers and exact-artifact usage. + ## Project Structure ``` @@ -196,8 +210,7 @@ Opcilloscope uses JSON-based configuration files with the `.cfg` extension: { "version": "1.0", "server": { - "endpointUrl": "opc.tcp://localhost:4840", - "securityMode": "None" + "endpointUrl": "opc.tcp://localhost:4840" }, "settings": { "publishingIntervalMs": 1000, @@ -219,11 +232,17 @@ Opcilloscope uses JSON-based configuration files with the `.cfg` extension: } ``` +An automatic/omitted or partial security profile requires a +`SignAndEncrypt` endpoint and selects the strongest matching candidate. +Explicit `securityMode: "Sign"` opts into signed-but-unencrypted traffic. +Explicit `securityMode: "None"` is the unsecured plaintext opt-in for an +anonymous connection; username authentication never permits `None`. + ### Theme System Three built-in themes with consistent styling: - **DarkTheme** (default): Dark background, high contrast for terminal use - **LightTheme**: Light background for bright environments -- **TerminalTheme**: Inherits the terminal's own ANSI color palette. Uses only the 16 named ANSI colors (`ColorName16`) and enables `Application.Force16Colors` so the driver emits standard SGR color codes instead of 24-bit RGB — the terminal renders them with its configured scheme. `ThemeManager.SetTheme` toggles `Force16Colors` automatically via `AppTheme.UseTerminalColors`. +- **TerminalTheme**: Inherits the terminal's own ANSI color palette. Uses only the 16 named ANSI colors (`ColorName16`) and enables `TerminalUi.Driver.Force16Colors` so the driver emits standard SGR color codes instead of 24-bit RGB — the terminal renders them with its configured scheme. `ThemeManager.SetTheme` toggles `Force16Colors` automatically via `AppTheme.UseTerminalColors`. Toggle themes via View menu (cycles Dark → Light → Terminal) or programmatically: ```csharp @@ -278,21 +297,30 @@ error, not a warning. The app uses the instance-based model (`Application.Create ### OPC Foundation SDK API - Uses `Opc.Ua.Client.Session` for connection management - Uses proper OPC UA Subscriptions with `Subscription` and `MonitoredItem` classes -- MonitoredItem notifications are pushed by the server (not polling) +- MonitoredItem data-change notifications are delivered through OPC UA subscriptions (not repeated reads and not the OPC UA PubSub transport model) - `NodeId` constructor: `new NodeId(uint identifier)` or `new NodeId(ushort namespaceIndex, uint identifier)` - Use `ExpandedNodeId.ToNodeId(expandedNodeId, session.NamespaceUris)` for conversion - Use `ObjectIds.RootFolder` for the root node (ns=0;i=84) - `StatusCode.Code` returns the uint value; check with `StatusCodes.Good`, `StatusCodes.BadUnexpectedError`, etc. - Use `Attributes.Value`, `Attributes.DataType`, etc. for attribute IDs -- Certificate validation: Set `AutoAcceptUntrustedCertificates = true` for development +- An automatic/omitted or partial security profile requires `SignAndEncrypt` and selects the strongest matching endpoint. Explicit `SecurityMode=Sign` opts into signed-but-unencrypted traffic. Explicit anonymous `SecurityMode=None` opts into unsecured plaintext; username credentials never permit `None`. +- Certificate validation rejects untrusted certificates by default. The `--insecure` CLI option may be used for a development run only; it changes certificate trust, not transport security. Production certificates belong in the trusted-peer store reported in the connection log. ### DiscoveryClient API The `DiscoveryClient.Create` method requires `EndpointConfiguration`, not `ApplicationConfiguration`: +`DiscoveryClient.Create` and `Session.Create` are obsolete in the current SDK. +The existing wrapper uses narrowly scoped `CS0618` pragmas because the async +factory replacements require additional telemetry setup. Do not copy these +calls into new code without the same documented justification, and never add +a project-wide suppression. + ```csharp // Correct usage - create EndpointConfiguration first var endpointConfig = EndpointConfiguration.Create(config); +#pragma warning disable CS0618 // Existing wrapper exception: async factory needs telemetry setup using var client = DiscoveryClient.Create(uri, endpointConfig); +#pragma warning restore CS0618 var endpoints = await client.GetEndpointsAsync(null); // Valid DiscoveryClient.Create overloads: @@ -322,7 +350,9 @@ await _server.StopAsync(); ### Key OPC Foundation Classes ```csharp // Session creation +#pragma warning disable CS0618 // Existing wrapper exception: async factory needs telemetry setup var session = await Session.Create(config, endpoint, false, "SessionName", 60000, new UserIdentity(new AnonymousIdentityToken()), null); +#pragma warning restore CS0618 // Subscription creation var subscription = new Subscription(session.DefaultSubscription) { @@ -330,7 +360,7 @@ var subscription = new Subscription(session.DefaultSubscription) { PublishingEnabled = true }; session.AddSubscription(subscription); -subscription.Create(); +await subscription.CreateAsync(); // MonitoredItem creation var monitoredItem = new MonitoredItem(subscription.DefaultItem) { @@ -340,7 +370,7 @@ var monitoredItem = new MonitoredItem(subscription.DefaultItem) { }; monitoredItem.Notification += OnNotification; subscription.AddItem(monitoredItem); -subscription.ApplyChanges(); +await subscription.ApplyChangesAsync(); ``` ### ConnectionManager Pattern @@ -360,7 +390,7 @@ await connectionManager.ConnectAsync("opc.tcp://localhost:4840"); await connectionManager.SubscribeAsync(nodeId, displayName); await connectionManager.UnsubscribeAsync(clientHandle); await connectionManager.ReconnectAsync(); -connectionManager.Disconnect(); +await connectionManager.DisconnectAsync(); ``` ### NuGet Packages @@ -455,30 +485,35 @@ _connectionManager.SubscribeAsync(nodeId, displayName).FireAndForget(_logger); The address space tree uses lazy loading - child nodes are only fetched when a parent is expanded, preventing memory issues with large address spaces. ### OPC UA Subscriptions -Uses proper OPC UA Publish/Subscribe with `MonitoredItem.Notification` events - values are pushed by the server, no polling required. +Uses OPC UA client/server `Subscription` and `MonitoredItem` services with `MonitoredItem.Notification` events, so values arrive as data-change notifications instead of repeated reads. This is not the separate OPC UA PubSub transport model. ### Error Handling - Connection errors display in the log panel without crashing - Automatic reconnection with exponential backoff (1s, 2s, 4s, 8s) - Graceful handling of bad node IDs and access denied errors -- CSV recording continues silently on individual write failures +- CSV recording logs write failures, counts failed/dropped records, and reports data loss when recording stops ## CI/CD Workflows ### CI Workflow (ci.yml) -Runs on push/PR to main: -- Checkout, setup .NET 10, restore, build (Release), test +Runs on push/PR to main and gates locked dependency restore, third-party +inventory validation, formatting, the Release solution build, cross-platform +tests, single-file layout, CLI smoke, and Linux real-PTY E2E tests against the +exact published artifact. ### Release Workflow (release.yml) -Automates release builds and publishing. +Runs the cross-platform suite, then builds six locked RIDs. The native Linux +artifact also passes the real-PTY E2E suite; native host artifacts pass CLI +smokes. Archives preserve executable permissions, licenses, exact runtime +notices, and are published with `SHA256SUMS` under least-privilege permissions. ## Common Issues 1. **`dotnet` command not found**: Install .NET SDK using the install script (see Environment Setup above) -2. **Tests fail with Xunit errors in main project**: Ensure `tests/**` is excluded in Opcilloscope.csproj +2. **Tests fail with Xunit errors in main project**: Ensure `Tests/**` is excluded in Opcilloscope.csproj 3. **UI thread exceptions**: Always use `UiThread.Run()` for UI updates from background threads (it marshals via the instance-based `IApplication.Invoke`; do not call the deprecated static `Application.Invoke()`) 4. **Ambiguous NodeBrowser reference**: OPC Foundation has its own `Browser` class - use fully qualified names if needed -5. **Certificate validation errors**: Set `AutoAcceptUntrustedCertificates = true` in SecurityConfiguration for development +5. **Certificate validation errors**: Trust the server certificate in the path reported by the connection log, or re-run with `--insecure` for development only. This does not reduce message security: automatic/partial profiles still require `SignAndEncrypt`; only explicit `Sign` or anonymous `None` opts down. 6. **Integration tests fail with "Unexpected error starting application"**: The OPC UA test server requires specific environment permissions - unit tests will still pass 7. **Theme not applying correctly**: Ensure `ApplyTheme()` is called after all controls are created diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ada76f1..7eb1f31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Thank you for your interest in contributing to Opcilloscope! 2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/opcilloscope.git` 3. Create a branch: `git checkout -b feature/your-feature-name` 4. Make your changes -5. Run tests: `dotnet test` +5. Run the applicable test layers described in [docs/TESTING.md](docs/TESTING.md) 6. Commit and push 7. Open a Pull Request @@ -22,9 +22,12 @@ Thank you for your interest in contributing to Opcilloscope! ### Building and Testing ```bash -dotnet restore -dotnet build -dotnet test +dotnet restore Opcilloscope.sln +dotnet build Opcilloscope.sln +dotnet test Opcilloscope.sln + +# Linux only: publish and test the real TUI through a PTY +dotnet test Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj ``` ## Code Style @@ -66,12 +69,14 @@ Opcilloscope/ ├── Utilities/ # Helpers (logging, threading, CSV) └── Tests/ # Unit and integration tests ├── Opcilloscope.TestServer/ # In-process OPC UA test server - └── Opcilloscope.Tests/ # xUnit tests + ├── Opcilloscope.Tests/ # Cross-platform xUnit tests + └── Opcilloscope.E2ETests/ # Linux published-binary PTY tests (outside the solution) ``` ### Key Patterns -- **Thread marshalling**: Use `Application.Invoke()` or `UiThread.Run()` for UI updates from background threads +- **Thread marshalling**: Use `UiThread.Run()` for UI updates from background threads; the legacy static `Application` API is obsolete - **Lazy loading**: Address space tree loads children on-demand -- **Subscriptions**: Uses OPC UA Publish/Subscribe (not polling) +- **Subscriptions**: Uses OPC UA client/server subscriptions and monitored items (not repeated reads and not the OPC UA PubSub transport model) - **Integration tests**: Run against an in-process OPC UA test server (no external dependencies needed) +- **Security profiles**: Automatic/omitted or partial profiles require the strongest matching `SignAndEncrypt` endpoint; explicit `Sign` opts into signed-but-unencrypted traffic, explicit anonymous `None` opts into unsecured plaintext, and `--insecure` bypasses certificate validation only diff --git a/CommandLineOptions.cs b/CommandLineOptions.cs new file mode 100644 index 0000000..acbcd67 --- /dev/null +++ b/CommandLineOptions.cs @@ -0,0 +1,85 @@ +namespace Opcilloscope; + +internal sealed record CommandLineOptions( + string? ConfigPath, + string? AutoConnectUrl, + bool AllowInsecureCertificates, + bool ShowHelp); + +internal static class CommandLineParser +{ + public static CommandLineOptions Parse(IReadOnlyList args) + { + // Help is an immediate, side-effect-free request. Keep it usable even + // when a shell alias appends stale/invalid arguments after --help. + if (args.Any(arg => arg is "--help" or "-h")) + { + return new CommandLineOptions(null, null, false, ShowHelp: true); + } + + string? configPath = null; + string? autoConnectUrl = null; + var allowInsecure = false; + + for (var i = 0; i < args.Count; i++) + { + var arg = args[i]; + switch (arg) + { + case "--config": + case "-f": + configPath = ReadOptionValue(args, ref i, arg); + break; + + case "--connect": + case "-c": + autoConnectUrl = ReadOptionValue(args, ref i, arg); + break; + + case "--insecure": + allowInsecure = true; + break; + + default: + if (arg.StartsWith('-')) + { + throw new ArgumentException($"Unknown option: {arg}"); + } + + if (arg.StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase)) + { + autoConnectUrl = arg; + } + else if (HasConfigExtension(arg)) + { + configPath = arg; + } + else + { + throw new ArgumentException($"Unexpected argument: {arg}"); + } + break; + } + } + + return new CommandLineOptions(configPath, autoConnectUrl, allowInsecure, ShowHelp: false); + } + + private static string ReadOptionValue(IReadOnlyList args, ref int index, string option) + { + if (index + 1 >= args.Count + || string.IsNullOrWhiteSpace(args[index + 1]) + || args[index + 1].StartsWith('-')) + { + throw new ArgumentException($"Option {option} requires a value."); + } + + index++; + return args[index]; + } + + private static bool HasConfigExtension(string path) => + path.EndsWith(".cfg", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".opcilloscope", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".json", StringComparison.OrdinalIgnoreCase); +} diff --git a/Configuration/ConfigurationService.cs b/Configuration/ConfigurationService.cs index eba004e..e8d7840 100644 --- a/Configuration/ConfigurationService.cs +++ b/Configuration/ConfigurationService.cs @@ -182,6 +182,20 @@ private void ValidateConfiguration(OpcilloscopeConfig config) throw new InvalidDataException("Configuration is missing the 'metadata' section."); } + var authentication = config.Server.Authentication + ?? throw new InvalidDataException("Configuration is missing the server authentication section."); + if (!Enum.TryParse(authentication.Type, ignoreCase: true, out var authType) + || authType is not AuthenticationType.Anonymous and not AuthenticationType.UserName) + { + throw new InvalidDataException( + $"Unsupported authentication type '{authentication.Type}'. Expected Anonymous or UserName."); + } + + if (authType == AuthenticationType.UserName && string.IsNullOrWhiteSpace(authentication.Username)) + { + throw new InvalidDataException("UserName authentication requires a non-empty username."); + } + // Validate publishing interval if (config.Settings.PublishingIntervalMs < 0) { @@ -377,7 +391,7 @@ private OpcilloscopeConfig MigrateIfNeeded(OpcilloscopeConfig config) /// Gets the default directory for configuration files. /// Uses cross-platform appropriate locations: /// - Windows: %APPDATA%/opcilloscope/configs/ - /// - macOS: ~/.config/opcilloscope/configs/ + /// - macOS: ~/Library/Application Support/opcilloscope/configs/ /// - Linux: ~/.config/opcilloscope/configs/ /// /// Path to the default configuration directory. @@ -394,8 +408,8 @@ public static string GetDefaultConfigDirectory() } else if (OperatingSystem.IsMacOS()) { - // macOS: ~/.config/opcilloscope/configs/ - // (.NET maps SpecialFolder.ApplicationData to ~/.config on macOS) + // macOS: ~/Library/Application Support/opcilloscope/configs/ + // (.NET maps SpecialFolder.ApplicationData to Application Support.) baseDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); appFolder = "opcilloscope"; } diff --git a/Configuration/Models/OpcilloscopeConfig.cs b/Configuration/Models/OpcilloscopeConfig.cs index 92c56f9..8e9dea5 100644 --- a/Configuration/Models/OpcilloscopeConfig.cs +++ b/Configuration/Models/OpcilloscopeConfig.cs @@ -24,16 +24,18 @@ public class ServerConfig /// /// Requested message security mode (for example: None, Sign, SignAndEncrypt). - /// Used during endpoint selection when connecting; when None, an unsecured - /// endpoint is selected. + /// Null/omitted means require the strongest SignAndEncrypt endpoint. + /// Sign explicitly opts into signed-but-unencrypted traffic; None explicitly + /// opts into an unsecured connection and is valid only with anonymous auth. /// - public string SecurityMode { get; set; } = "None"; + public string? SecurityMode { get; set; } /// /// Requested security policy URI or shorthand (for example: /// Basic256Sha256 or the full policy URI). /// Used during endpoint selection when connecting; honored when a - /// matching endpoint exists on the server. + /// matching endpoint exists on the server. SecurityPolicy=None alone does + /// not opt into plaintext; SecurityMode must explicitly be None as well. /// public string? SecurityPolicy { get; set; } @@ -51,7 +53,7 @@ public class ServerConfig public class AuthenticationConfig { /// - /// Authentication type: Anonymous, UserName, or Certificate. + /// Authentication type: Anonymous or UserName. /// public string Type { get; set; } = "Anonymous"; @@ -79,7 +81,7 @@ public class SubscriptionSettings /// Sampling interval (in milliseconds) applied to monitored variables. /// Controls how often the server samples the underlying value; 0 means /// "as fast as the server allows". - /// Valid range: 0-10000 ms (values outside this range will be clamped by SubscriptionManager). + /// Valid range: 0-60000 ms (values outside this range will be clamped by SubscriptionManager). /// public int SamplingIntervalMs { get; set; } = 250; diff --git a/OpcUa/ConnectionCredentials.cs b/OpcUa/ConnectionCredentials.cs index 642d8b7..5c9e7df 100644 --- a/OpcUa/ConnectionCredentials.cs +++ b/OpcUa/ConnectionCredentials.cs @@ -21,10 +21,41 @@ public record ConnectionCredentials( public static readonly ConnectionCredentials Anonymous = new(AuthenticationType.Anonymous); /// - /// Parses a string (from config) into an AuthenticationType, defaulting to Anonymous. + /// Verifies that the credential shape is valid before endpoint discovery or + /// session creation. Unknown enum values and blank usernames fail closed. /// - public static AuthenticationType ParseAuthType(string? value) => - string.Equals(value, nameof(AuthenticationType.UserName), StringComparison.OrdinalIgnoreCase) - ? AuthenticationType.UserName - : AuthenticationType.Anonymous; + public void Validate() + { + if (!Enum.IsDefined(Type)) + { + throw new ArgumentOutOfRangeException( + nameof(Type), + Type, + "Unsupported OPC UA authentication type."); + } + + if (Type == AuthenticationType.UserName && string.IsNullOrWhiteSpace(Username)) + { + throw new ArgumentException( + "A non-empty username is required for UserName authentication.", + nameof(Username)); + } + } + + /// + /// Parses a string from configuration into an AuthenticationType. Unknown or + /// missing values are rejected instead of silently downgrading to Anonymous. + /// + public static AuthenticationType ParseAuthType(string? value) + { + if (string.Equals(value, nameof(AuthenticationType.Anonymous), StringComparison.OrdinalIgnoreCase)) + return AuthenticationType.Anonymous; + + if (string.Equals(value, nameof(AuthenticationType.UserName), StringComparison.OrdinalIgnoreCase)) + return AuthenticationType.UserName; + + throw new FormatException( + $"Unsupported authentication type '{value ?? ""}'. " + + $"Expected '{nameof(AuthenticationType.Anonymous)}' or '{nameof(AuthenticationType.UserName)}'."); + } } diff --git a/OpcUa/ConnectionManager.cs b/OpcUa/ConnectionManager.cs index b694159..00af693 100644 --- a/OpcUa/ConnectionManager.cs +++ b/OpcUa/ConnectionManager.cs @@ -1,4 +1,5 @@ using Opcilloscope.Utilities; +using Opc.Ua; namespace Opcilloscope.OpcUa; @@ -14,8 +15,21 @@ public sealed class ConnectionManager : IDisposable private SubscriptionManager? _subscriptionManager; private string? _lastEndpoint; private ConnectionCredentials _credentials = ConnectionCredentials.Anonymous; - private bool _disposed; - private int _isReconnecting; + private string? _securityMode; + private string? _securityPolicy; + private int _disposed; + private readonly object _stateLock = new(); + private ConnectionState _state = ConnectionState.Disconnected; + private bool _sessionOperationsAllowed; + // Incremented as soon as a lifecycle intent is expressed, before it waits on + // the wrapper gate. Long-running reads can use this to reject stale results + // instead of applying server A data after the user has switched to server B. + private long _connectionGeneration; + // Distinguishes a queued automatic reconnect from later explicit user intent. + // Manual reconnect intentionally remains a fresh-connect operation after an + // explicit disconnect; automatic reconnect must never have that behaviour. + private long _connectionIntentVersion; + private CancellationTokenSource _connectionIntentCts = new(); // Subscription settings from the most recent ConnectAsync, so subscription // restoration after a reconnect does not silently fall back to the defaults. @@ -26,18 +40,37 @@ public sealed class ConnectionManager : IDisposable // Stored event handler references for proper unsubscription private Action? _valueChangedHandler; private Action? _variableAddedHandler; - private Action? _variableRemovedHandler; + private Action? _variableRemovedHandler; /// /// Gets whether there is an active connection. /// - public bool IsConnected => _client.IsConnected; + public bool IsConnected + { + get + { + lock (_stateLock) + { + return _sessionOperationsAllowed && _client.IsConnected; + } + } + } /// /// Gets the current endpoint URL, if connected. /// public string? CurrentEndpoint => _client.CurrentEndpoint; + /// + /// Gets the actual message security mode selected for the active session. + /// + public MessageSecurityMode? CurrentSecurityMode => _client.CurrentSecurityMode; + + /// + /// Gets the full security-policy URI selected for the active session. + /// + public string? CurrentSecurityPolicy => _client.CurrentSecurityPolicy; + /// /// Gets the last attempted endpoint URL. /// @@ -48,6 +81,49 @@ public sealed class ConnectionManager : IDisposable /// public ConnectionCredentials Credentials => _credentials; + /// + /// Gets the sampling interval from the active or most recently stored profile. + /// + public int SamplingInterval => _samplingInterval; + + /// + /// Gets the monitored-item queue size from the active or most recently stored profile. + /// + public uint QueueSize => _queueSize; + + /// + /// Gets the current connection generation. It changes immediately whenever a + /// connect, disconnect, reconnect, or detected connection loss invalidates work + /// started against the prior session. + /// + public long ConnectionGeneration => Volatile.Read(ref _connectionGeneration); + + internal long ConnectionIntentVersion + { + get + { + lock (_stateLock) + { + return _connectionIntentVersion; + } + } + } + + /// + /// Returns whether work stamped with may use the + /// active session. A raw SDK session can remain Connected after keep-alive loss; + /// only the manager's fully published Connected state is usable. + /// + public bool IsConnectionGenerationActive(long generation) + { + lock (_stateLock) + { + return _sessionOperationsAllowed + && _connectionGeneration == generation + && _client.IsConnected; + } + } + /// /// Gets the OPC UA client wrapper for direct session access. /// @@ -86,26 +162,30 @@ public sealed class ConnectionManager : IDisposable /// /// Raised when a monitored variable is removed. /// - public event Action? VariableRemoved; + public event Action? VariableRemoved; /// /// Raised when automatic reconnection is triggered due to connection loss. /// - public event Action? AutoReconnectTriggered; + public event Action? AutoReconnectTriggered; /// /// Creates a new connection manager. /// /// Logger for connection diagnostics. /// - /// When true, untrusted server certificates are auto-accepted (development only). + /// When true, server certificate validation failures are accepted (development only). /// When null (the default), is used. /// public ConnectionManager(Logger logger, bool? allowInsecure = null) { _logger = logger; _client = new OpcUaClientWrapper(logger, allowInsecure); - _nodeBrowser = new NodeBrowser(_client, logger); + _nodeBrowser = new NodeBrowser( + _client, + logger, + () => ConnectionGeneration, + IsConnectionGenerationActive); _client.Connected += OnClientConnected; _client.Disconnected += OnClientDisconnected; @@ -119,12 +199,12 @@ public ConnectionManager(Logger logger, bool? allowInsecure = null) /// The endpoint URL to connect to. /// Publishing interval in milliseconds for the subscription. /// Authentication credentials (defaults to anonymous). - /// Requested message security mode (e.g. None, Sign, SignAndEncrypt). When null/None, an unsecured endpoint is selected. - /// Requested security policy URI or shorthand (e.g. Basic256Sha256). Honored when a matching endpoint exists. + /// Requested message security mode (e.g. None, Sign, SignAndEncrypt). An explicit value must match; UserName authentication rejects None. + /// Requested security policy URI or shorthand (e.g. Basic256Sha256). An explicit value must match. /// Sampling interval in milliseconds for monitored items (0 = as fast as the server allows). /// Server-side notification queue size for monitored items. /// True if connection succeeded, false otherwise. - public async Task ConnectAsync( + public Task ConnectAsync( string endpoint, int publishingInterval = 250, ConnectionCredentials? credentials = null, @@ -133,40 +213,113 @@ public async Task ConnectAsync( int samplingInterval = 250, uint queueSize = 10) { - // Async teardown: the synchronous Disconnect() blocks on the OPC UA close - // round-trip (up to the transport timeout against a dead server), which froze - // the UI thread when reconnecting over an existing or dead connection. - await DisconnectAsync(); + if (Volatile.Read(ref _disposed) != 0) + return Task.FromResult(false); + + var operationGeneration = BeginExplicitLifecycleIntent(); + return ConnectWithIntentAsync( + endpoint, + publishingInterval, + credentials, + securityMode, + securityPolicy, + samplingInterval, + queueSize, + operationGeneration); + } + + internal Task ConnectWithIntentAsync( + string endpoint, + int publishingInterval, + ConnectionCredentials? credentials, + string? securityMode, + string? securityPolicy, + int samplingInterval, + uint queueSize, + long operationGeneration) + { + return _client.ExecuteLifecycleAsync( + () => ConnectCoreAsync( + endpoint, + publishingInterval, + credentials, + securityMode, + securityPolicy, + samplingInterval, + queueSize, + operationGeneration)); + } + + private async Task ConnectCoreAsync( + string endpoint, + int publishingInterval, + ConnectionCredentials? credentials, + string? securityMode, + string? securityPolicy, + int samplingInterval, + uint queueSize, + long operationGeneration) + { + if (Volatile.Read(ref _disposed) != 0 + || !IsCurrentConnectionIntent(operationGeneration)) + return false; + + // This method already owns the client's shared lifecycle gate, so teardown + // must use core methods rather than re-entering public lifecycle APIs. + await DisconnectCoreAsync().ConfigureAwait(false); + if (!IsCurrentConnectionIntent(operationGeneration)) + return false; _lastEndpoint = endpoint; _credentials = credentials ?? ConnectionCredentials.Anonymous; + _securityMode = securityMode; + _securityPolicy = securityPolicy; _publishingInterval = publishingInterval; _samplingInterval = samplingInterval; _queueSize = queueSize; - StateChanged?.Invoke(ConnectionState.Connecting); + if (!TrySetStateForIntent(operationGeneration, ConnectionState.Connecting)) + return false; try { - var success = await _client.ConnectAsync(endpoint, _credentials, securityMode, securityPolicy); + var success = await _client.ConnectCoreAsync( + endpoint, + _credentials, + _securityMode, + _securityPolicy).ConfigureAwait(false); if (success) { - if (!await InitializeSubscriptionAsync()) + if (!IsCurrentConnectionIntent(operationGeneration)) + { + await DisconnectCoreAsync().ConfigureAwait(false); + return false; + } + + // Preserve the actual selected profile for a later fresh reconnect. + _securityMode = _client.CurrentSecurityMode?.ToString(); + _securityPolicy = _client.CurrentSecurityPolicy; + + if (!await InitializeSubscriptionAsync(operationGeneration).ConfigureAwait(false)) { // Without a subscription the session is useless for monitoring; // fail the connect rather than reporting Connected. var msg = "Connected, but the server refused the monitoring subscription. Disconnecting."; _logger.Error(msg); ConnectionError?.Invoke(msg); - await DisconnectAsync(); + await DisconnectCoreAsync().ConfigureAwait(false); return false; } - StateChanged?.Invoke(ConnectionState.Connected); + if (!TryPublishConnected(operationGeneration)) + { + await DisconnectCoreAsync().ConfigureAwait(false); + return false; + } } else { - StateChanged?.Invoke(ConnectionState.Disconnected); + SetState(ConnectionState.Disconnected); } return success; @@ -174,7 +327,8 @@ public async Task ConnectAsync( catch (Exception ex) { _logger.Error($"Connection failed: {ex.Message}"); - StateChanged?.Invoke(ConnectionState.Disconnected); + await DisconnectCoreAsync().ConfigureAwait(false); + SetState(ConnectionState.Disconnected); return false; } } @@ -184,9 +338,7 @@ public async Task ConnectAsync( /// public void Disconnect() { - DisposeSubscription(); - _client.Disconnect(); - StateChanged?.Invoke(ConnectionState.Disconnected); + DisconnectAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } /// @@ -196,47 +348,196 @@ public void Disconnect() /// public async Task DisconnectAsync() { + var operationGeneration = BeginExplicitLifecycleIntent(); + await DisconnectWithIntentAsync(operationGeneration).ConfigureAwait(false); + } + + internal Task DisconnectWithIntentAsync(long operationGeneration) + { + return _client.ExecuteLifecycleAsync(async () => + { + if (!IsCurrentConnectionIntent(operationGeneration)) + return false; + + await DisconnectCoreAsync().ConfigureAwait(false); + return true; + }); + } + + private async Task DisconnectCoreAsync() + { + // Subscription cleanup must not prevent local session cleanup. Both helpers + // detach ownership first and absorb non-fatal teardown failures. DisposeSubscription(); - await _client.DisconnectAsync().ConfigureAwait(false); - StateChanged?.Invoke(ConnectionState.Disconnected); + await _client.DisconnectCoreAsync().ConfigureAwait(false); + SetState(ConnectionState.Disconnected); } /// - /// Attempts to reconnect to the last endpoint preserving subscriptions. - /// Uses OPC UA session reconnect/transfer to maintain monitored variables. + /// Attempts to reconnect to the last endpoint. An active failed session uses OPC UA + /// reconnect/transfer to preserve subscriptions; after an explicit disconnect this + /// performs a fresh connection with the stored endpoint, credentials, security, and + /// subscription profile. /// /// True if reconnection succeeded, false otherwise. - public async Task ReconnectAsync() + public Task ReconnectAsync() { + if (Volatile.Read(ref _disposed) != 0) + return Task.FromResult(false); + if (string.IsNullOrEmpty(_lastEndpoint)) { _logger.Warning("No previous connection to reconnect"); - return false; + return Task.FromResult(false); } - if (Interlocked.CompareExchange(ref _isReconnecting, 1, 0) != 0) + var operationGeneration = BeginExplicitLifecycleIntent(); + return ReconnectWithIntentAsync(operationGeneration); + } + + internal Task ReconnectWithIntentAsync(long operationGeneration) + { + if (!TryGetConnectionIntentCancellationToken( + operationGeneration, + out var cancellationToken)) { - _logger.Warning("Reconnection already in progress"); - return false; + return Task.FromResult(false); } - StateChanged?.Invoke(ConnectionState.Reconnecting); - // Mark all monitored variables as stale during reconnection - _subscriptionManager?.MarkAllAsStale(); + return _client.ExecuteLifecycleAsync( + () => ReconnectCoreAsync( + operationGeneration, + automaticIntentVersion: null, + cancellationToken: cancellationToken)); + } + + /// + /// Performs the automatic reconnect requested by + /// only while that request is still the latest connection intent. A later explicit + /// disconnect/connect/manual reconnect invalidates the token before waiting on the + /// lifecycle gate, so a delayed UI callback cannot resurrect a closed session. + /// + public Task ReconnectAutomaticallyAsync(long intentVersion) + { + if (Volatile.Read(ref _disposed) != 0) + return Task.FromResult(false); + + if (!TryGetAutomaticReconnectIntent( + intentVersion, + out var operationGeneration, + out var cancellationToken)) + { + _logger.Info("Skipped stale automatic reconnect request"); + return Task.FromResult(false); + } + + return _client.ExecuteLifecycleAsync(async () => + { + if (cancellationToken.IsCancellationRequested + || !IsCurrentAutomaticReconnectIntent(intentVersion)) + { + _logger.Info("Skipped stale automatic reconnect request"); + return false; + } + + return await ReconnectCoreAsync( + operationGeneration, + intentVersion, + cancellationToken).ConfigureAwait(false); + }); + } + + private async Task ReconnectCoreAsync( + long operationGeneration, + long? automaticIntentVersion = null, + CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _disposed) != 0 + || string.IsNullOrEmpty(_lastEndpoint) + || !IsCurrentConnectionIntent(operationGeneration)) + return false; + + if (!TrySetStateForIntent(operationGeneration, ConnectionState.Reconnecting)) + return false; + + // An explicit disconnect clears the wrapper's current endpoint and session, + // but this manager intentionally retains the full connection profile. In that + // case manual Reconnect is a fresh connect rather than a session transfer. + var requiresFreshConnect = _client.Session == null + || string.IsNullOrEmpty(_client.CurrentEndpoint); + + if (!requiresFreshConnect) + { + // Mark monitored variables stale only when preserving an existing + // subscription; explicit disconnect already disposed them. + _subscriptionManager?.MarkAllAsStale(); + } try { - var success = await _client.ReconnectAsync(); + if (cancellationToken.IsCancellationRequested) + return false; + + var success = requiresFreshConnect + ? await _client.ConnectCoreAsync( + _lastEndpoint, + _credentials, + _securityMode, + _securityPolicy).ConfigureAwait(false) + : await _client.ReconnectCoreAsync(cancellationToken).ConfigureAwait(false); if (success) { - // Try to restore subscriptions - await RestoreSubscriptionsAsync(); - StateChanged?.Invoke(ConnectionState.Connected); + if (cancellationToken.IsCancellationRequested + || !IsCurrentConnectionIntent(operationGeneration)) + { + await DisconnectCoreAsync().ConfigureAwait(false); + return false; + } + + _securityMode = _client.CurrentSecurityMode?.ToString(); + _securityPolicy = _client.CurrentSecurityPolicy; + + if (requiresFreshConnect) + { + if (!await InitializeSubscriptionAsync(operationGeneration).ConfigureAwait(false)) + { + var msg = "Reconnected, but the server refused the monitoring subscription. Disconnecting."; + _logger.Error(msg); + ConnectionError?.Invoke(msg); + await DisconnectCoreAsync().ConfigureAwait(false); + return false; + } + } + else + { + // Try to restore subscriptions from the prior session. + _subscriptionManager?.AdvanceConnectionGeneration(operationGeneration); + await RestoreSubscriptionsAsync(operationGeneration).ConfigureAwait(false); + } + + if (automaticIntentVersion.HasValue + && !TryPublishAutomaticReconnect( + automaticIntentVersion.Value, + operationGeneration)) + { + _logger.Info("Closing session created by a superseded automatic reconnect"); + await DisconnectCoreAsync().ConfigureAwait(false); + return false; + } + + if (!automaticIntentVersion.HasValue) + { + if (!TryPublishConnected(operationGeneration)) + { + await DisconnectCoreAsync().ConfigureAwait(false); + return false; + } + } } else { - StateChanged?.Invoke(ConnectionState.Disconnected); + SetState(ConnectionState.Disconnected); } return success; @@ -244,32 +545,35 @@ public async Task ReconnectAsync() catch (Exception ex) { _logger.Error($"Reconnection failed: {ex.Message}"); - StateChanged?.Invoke(ConnectionState.Disconnected); + await DisconnectCoreAsync().ConfigureAwait(false); + SetState(ConnectionState.Disconnected); return false; } - finally - { - Interlocked.Exchange(ref _isReconnecting, 0); - } } /// /// Restores subscriptions after successful reconnection. /// First checks if subscriptions were transferred, otherwise recreates them. /// - private async Task RestoreSubscriptionsAsync() + private async Task RestoreSubscriptionsAsync(long operationGeneration) { if (_subscriptionManager == null) { // No subscriptions existed - create fresh subscription manager - await InitializeSubscriptionAsync(); + if (!await InitializeSubscriptionAsync(operationGeneration).ConfigureAwait(false)) + { + throw new InvalidOperationException( + "Server refused subscription initialization after reconnect."); + } return; } // Try to reattach to transferred subscriptions if (_subscriptionManager.IsSubscriptionValid()) { - var reattached = await _subscriptionManager.ReattachAfterReconnectAsync(); + var reattached = await _subscriptionManager + .ReattachAfterReconnectAsync() + .ConfigureAwait(false); if (reattached) { _logger.Info("Subscriptions preserved successfully"); @@ -279,7 +583,9 @@ private async Task RestoreSubscriptionsAsync() // Subscription transfer failed - recreate them _logger.Info("Recreating subscriptions after reconnection..."); - var recreated = await _subscriptionManager.RecreateSubscriptionsAsync(); + var recreated = await _subscriptionManager + .RecreateSubscriptionsAsync() + .ConfigureAwait(false); if (!recreated) { @@ -292,11 +598,15 @@ private async Task RestoreSubscriptionsAsync() .ToList(); DisposeSubscription(); - await InitializeSubscriptionAsync(); + if (!await InitializeSubscriptionAsync(operationGeneration).ConfigureAwait(false)) + { + throw new InvalidOperationException( + "Server refused fresh subscription initialization after reconnect."); + } foreach (var handle in lostHandles) { - VariableRemoved?.Invoke(handle); + VariableRemoved?.Invoke(handle, operationGeneration); } } } @@ -304,77 +614,241 @@ private async Task RestoreSubscriptionsAsync() /// /// Subscribes to a node for value monitoring. /// - public Task SubscribeAsync(Opc.Ua.NodeId nodeId, string displayName) + public Task SubscribeAsync( + Opc.Ua.NodeId nodeId, + string displayName, + long? expectedGeneration = null) { - if (_subscriptionManager == null) - { - _logger.Warning("Cannot subscribe: not connected"); + if (Volatile.Read(ref _disposed) != 0) return Task.FromResult(null); - } - return _subscriptionManager.AddNodeAsync(nodeId, displayName); + var generation = expectedGeneration ?? ConnectionGeneration; + // Use the same outer gate as connect/disconnect/reconnect. The manager's + // mutation gate then serializes ApplyChangesAsync calls within one active + // subscription; the fixed lock order is lifecycle -> subscription. + return _client.ExecuteLifecycleAsync(async () => + { + if (Volatile.Read(ref _disposed) != 0 + || !IsConnectionGenerationActive(generation)) + return null; + + var subscriptionManager = _subscriptionManager; + if (subscriptionManager == null || !_client.IsConnected) + { + _logger.Warning("Cannot subscribe: connection changed or is not active"); + return null; + } + + return await subscriptionManager + .AddNodeAsync(nodeId, displayName) + .ConfigureAwait(false); + }); } /// /// Unsubscribes from a monitored variable. /// - public Task UnsubscribeAsync(uint clientHandle) + public Task UnsubscribeAsync( + uint clientHandle, + long? expectedGeneration = null) { - return _subscriptionManager?.RemoveNodeAsync(clientHandle) ?? Task.FromResult(false); + if (Volatile.Read(ref _disposed) != 0) + return Task.FromResult(false); + + var generation = expectedGeneration ?? ConnectionGeneration; + return _client.ExecuteLifecycleAsync(async () => + { + if (Volatile.Read(ref _disposed) != 0 + || !IsConnectionGenerationActive(generation)) + return false; + + var subscriptionManager = _subscriptionManager; + return subscriptionManager != null + && await subscriptionManager + .RemoveNodeAsync(clientHandle) + .ConfigureAwait(false); + }); } /// /// Writes a value to an OPC UA node's Value attribute. /// - public Task WriteValueAsync(Opc.Ua.NodeId nodeId, object value) + public Task WriteValueAsync( + Opc.Ua.NodeId nodeId, + object value, + long? expectedGeneration = null) { - if (!_client.IsConnected) - { - _logger.Warning("Cannot write: not connected"); + if (Volatile.Read(ref _disposed) != 0) return Task.FromResult((Opc.Ua.StatusCode)Opc.Ua.StatusCodes.BadNotConnected); - } - return _client.WriteValueAsync(nodeId, value); + var generation = expectedGeneration ?? ConnectionGeneration; + return _client.ExecuteLifecycleAsync(async () => + { + var session = _client.Session; + if (!IsConnectionGenerationActive(generation) + || session == null + || !session.Connected) + { + _logger.Warning("Cannot write: connection changed or is not active"); + return (Opc.Ua.StatusCode)Opc.Ua.StatusCodes.BadNotConnected; + } + + return await OpcUaClientWrapper + .WriteValueCoreAsync(session, nodeId, value) + .ConfigureAwait(false); + }); } - private async Task InitializeSubscriptionAsync() + /// + /// Reads the attributes and current value needed by the write dialog from one + /// pinned session. Returns null if connection intent changes at any point. + /// + public Task<(DataValueCollection Attributes, DataValue? Value)?> ReadWriteSnapshotAsync( + NodeId nodeId, + long expectedGeneration, + params uint[] attributeIds) { - _subscriptionManager = new SubscriptionManager(_client, _logger); - _subscriptionManager.PublishingInterval = _publishingInterval; - _subscriptionManager.SamplingInterval = _samplingInterval; - _subscriptionManager.QueueSize = _queueSize; - var initialized = await _subscriptionManager.InitializeAsync(); + if (Volatile.Read(ref _disposed) != 0) + return Task.FromResult<(DataValueCollection Attributes, DataValue? Value)?>(null); + + return _client.ExecuteLifecycleAsync<( + DataValueCollection Attributes, + DataValue? Value)?>(async () => + { + var session = _client.Session; + if (!IsConnectionGenerationActive(expectedGeneration) + || session == null + || !session.Connected) + { + return null; + } - // Store handler references for proper unsubscription - _valueChangedHandler = node => ValueChanged?.Invoke(node); - _variableAddedHandler = node => VariableAdded?.Invoke(node); - _variableRemovedHandler = handle => VariableRemoved?.Invoke(handle); + var attributes = await OpcUaClientWrapper + .ReadAttributesCoreAsync(session, nodeId, attributeIds) + .ConfigureAwait(false); + var value = await OpcUaClientWrapper + .ReadValueCoreAsync(session, nodeId) + .ConfigureAwait(false); - _subscriptionManager.ValueChanged += _valueChangedHandler; - _subscriptionManager.VariableAdded += _variableAddedHandler; - _subscriptionManager.VariableRemoved += _variableRemovedHandler; + if (!IsConnectionGenerationActive(expectedGeneration)) + return null; - return initialized; + return (Attributes: attributes, Value: value); + }); } - private void DisposeSubscription() + private async Task InitializeSubscriptionAsync(long connectionGeneration) { - if (_subscriptionManager != null) + var subscriptionManager = new SubscriptionManager( + _client, + _logger, + connectionGeneration) { - if (_valueChangedHandler != null) - _subscriptionManager.ValueChanged -= _valueChangedHandler; - if (_variableAddedHandler != null) - _subscriptionManager.VariableAdded -= _variableAddedHandler; - if (_variableRemovedHandler != null) - _subscriptionManager.VariableRemoved -= _variableRemovedHandler; + PublishingInterval = _publishingInterval, + SamplingInterval = _samplingInterval, + QueueSize = _queueSize + }; - _subscriptionManager.Dispose(); - _subscriptionManager = null; + var installed = false; + try + { + var initialized = await subscriptionManager.InitializeAsync().ConfigureAwait(false); + if (!initialized) + return false; + + // Store handler references for proper unsubscription. + // Capture the manager instance as provenance. A retired manager can + // dispatch an already-queued callback after a fallback manager reuses + // the same generation and client handles; generation alone cannot + // distinguish those sources. + _valueChangedHandler = node => + { + if (ReferenceEquals(_subscriptionManager, subscriptionManager)) + ValueChanged?.Invoke(node); + }; + _variableAddedHandler = node => + { + if (ReferenceEquals(_subscriptionManager, subscriptionManager)) + VariableAdded?.Invoke(node); + }; + _variableRemovedHandler = (handle, generation) => + { + if (ReferenceEquals(_subscriptionManager, subscriptionManager)) + VariableRemoved?.Invoke(handle, generation); + }; + + subscriptionManager.ValueChanged += _valueChangedHandler; + subscriptionManager.VariableAdded += _variableAddedHandler; + subscriptionManager.VariableRemoved += _variableRemovedHandler; + + _subscriptionManager = subscriptionManager; + installed = true; + return true; } + finally + { + // Initialization may create a server-side subscription before returning + // false or throwing. Always dispose that local instance unless ownership + // was successfully published. + if (!installed) + { + try + { + subscriptionManager.Dispose(); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.Warning($"Subscription cleanup error after failed initialization: {ex.Message}"); + } + _valueChangedHandler = null; + _variableAddedHandler = null; + _variableRemovedHandler = null; + } + } + } + + private void DisposeSubscription() + { + var subscriptionManager = _subscriptionManager; + var valueChangedHandler = _valueChangedHandler; + var variableAddedHandler = _variableAddedHandler; + var variableRemovedHandler = _variableRemovedHandler; + + // Detach ownership before fallible event removal/disposal so a later lifecycle + // operation cannot observe and dispose the same subscription manager twice. + _subscriptionManager = null; _valueChangedHandler = null; _variableAddedHandler = null; _variableRemovedHandler = null; + + if (subscriptionManager == null) + return; + + try + { + if (valueChangedHandler != null) + subscriptionManager.ValueChanged -= valueChangedHandler; + if (variableAddedHandler != null) + subscriptionManager.VariableAdded -= variableAddedHandler; + if (variableRemovedHandler != null) + subscriptionManager.VariableRemoved -= variableRemovedHandler; + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.Warning($"Subscription event cleanup error (non-critical): {ex.Message}"); + } + finally + { + try + { + subscriptionManager.Dispose(); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.Warning($"Subscription dispose error (non-critical): {ex.Message}"); + } + } } private void OnClientConnected() @@ -384,10 +858,7 @@ private void OnClientConnected() private void OnClientDisconnected() { - if (Interlocked.CompareExchange(ref _isReconnecting, 0, 0) == 0) - { - StateChanged?.Invoke(ConnectionState.Disconnected); - } + SetState(ConnectionState.Disconnected); } private void OnClientConnectionError(string message) @@ -395,30 +866,284 @@ private void OnClientConnectionError(string message) ConnectionError?.Invoke(message); } - private void OnReconnectRequired() + internal void OnReconnectRequired() { - if (Interlocked.CompareExchange(ref _isReconnecting, 0, 0) != 0) + var transitioned = false; + var intentVersion = 0L; + CancellationTokenSource? previousIntent = null; + lock (_stateLock) + { + // Keep-alive callbacks can repeat rapidly for one outage. Moving to + // Reconnecting here suppresses duplicate UI-triggered reconnect tasks. + if (_state == ConnectionState.Connected && _sessionOperationsAllowed) + { + _state = ConnectionState.Reconnecting; + _sessionOperationsAllowed = false; + intentVersion = ++_connectionIntentVersion; + _connectionGeneration++; + previousIntent = _connectionIntentCts; + _connectionIntentCts = new CancellationTokenSource(); + transitioned = true; + } + } + + if (!transitioned) return; + CancelIntent(previousIntent!); _logger.Warning("Connection lost - automatic reconnection triggered"); - AutoReconnectTriggered?.Invoke(); + StateChanged?.Invoke(ConnectionState.Reconnecting); + AutoReconnectTriggered?.Invoke(intentVersion); + + // The UI passes this token back to ReconnectAutomaticallyAsync after it has + // shown feedback. Reusing the manual API here would let a stale callback + // fresh-connect after an explicit disconnect. + } + + private long BeginExplicitLifecycleIntent() + { + CancellationTokenSource previousIntent; + long generation; + lock (_stateLock) + { + _connectionIntentVersion++; + generation = ++_connectionGeneration; + _sessionOperationsAllowed = false; + previousIntent = _connectionIntentCts; + _connectionIntentCts = new CancellationTokenSource(); + } + + CancelIntent(previousIntent); + return generation; + } + + internal long RegisterExplicitLifecycleIntent() + { + if (Volatile.Read(ref _disposed) != 0) + return ConnectionGeneration; + + var generation = BeginExplicitLifecycleIntent(); + return generation; + } + + internal bool TryRegisterExplicitLifecycleIntent( + long expectedIntentVersion, + out long connectionGeneration) + { + CancellationTokenSource previousIntent; + lock (_stateLock) + { + if (_connectionIntentVersion != expectedIntentVersion) + { + connectionGeneration = 0; + return false; + } + + _connectionIntentVersion++; + connectionGeneration = ++_connectionGeneration; + _sessionOperationsAllowed = false; + previousIntent = _connectionIntentCts; + _connectionIntentCts = new CancellationTokenSource(); + } + + CancelIntent(previousIntent); + return true; + } + + /// + /// Restores usability when a UI operation registered explicit connection intent + /// but was cancelled before it changed the session (for example, a cancelled + /// password prompt). A newer intent always wins. + /// + internal void RestoreSessionAfterAbandonedIntent(long operationGeneration) + { + var restored = false; + lock (_stateLock) + { + if (_connectionGeneration == operationGeneration + && _state == ConnectionState.Connected + && _client.IsConnected) + { + _sessionOperationsAllowed = true; + restored = true; + } + } + + if (restored) + _subscriptionManager?.AdvanceConnectionGeneration(operationGeneration); + } + + private bool IsCurrentConnectionIntent(long generation) + { + lock (_stateLock) + { + return _connectionGeneration == generation; + } + } + + private bool IsCurrentAutomaticReconnectIntent(long intentVersion) + { + lock (_stateLock) + { + return _connectionIntentVersion == intentVersion + && _state == ConnectionState.Reconnecting; + } + } + + private bool TryGetAutomaticReconnectIntent( + long intentVersion, + out long connectionGeneration, + out CancellationToken cancellationToken) + { + lock (_stateLock) + { + if (_connectionIntentVersion != intentVersion + || _state != ConnectionState.Reconnecting) + { + connectionGeneration = 0; + cancellationToken = default; + return false; + } + + connectionGeneration = _connectionGeneration; + cancellationToken = _connectionIntentCts.Token; + return true; + } + } + + private bool TryGetConnectionIntentCancellationToken( + long connectionGeneration, + out CancellationToken cancellationToken) + { + lock (_stateLock) + { + if (_connectionGeneration != connectionGeneration) + { + cancellationToken = default; + return false; + } + + cancellationToken = _connectionIntentCts.Token; + return true; + } + } + + private static void CancelIntent(CancellationTokenSource cancellation) + { + try + { + cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + } + } + + private bool TryPublishAutomaticReconnect( + long intentVersion, + long connectionGeneration) + { + lock (_stateLock) + { + if (_connectionIntentVersion != intentVersion + || _connectionGeneration != connectionGeneration + || _state != ConnectionState.Reconnecting) + { + return false; + } + + _state = ConnectionState.Connected; + _sessionOperationsAllowed = true; + } - // Note: The UI layer should call ReconnectAsync() when it receives AutoReconnectTriggered - // This allows the UI to show appropriate feedback during reconnection + StateChanged?.Invoke(ConnectionState.Connected); + return true; + } + + private bool TryPublishConnected(long connectionGeneration) + { + lock (_stateLock) + { + if (_connectionGeneration != connectionGeneration) + return false; + + _state = ConnectionState.Connected; + _sessionOperationsAllowed = true; + } + + StateChanged?.Invoke(ConnectionState.Connected); + return true; + } + + private bool TrySetStateForIntent( + long connectionGeneration, + ConnectionState state) + { + var changed = false; + lock (_stateLock) + { + if (_connectionGeneration != connectionGeneration) + return false; + + changed = _state != state; + _state = state; + _sessionOperationsAllowed = state == ConnectionState.Connected; + } + + if (changed) + StateChanged?.Invoke(state); + return true; + } + + private void SetState(ConnectionState state) + { + lock (_stateLock) + { + if (_state == state) + { + _sessionOperationsAllowed = state == ConnectionState.Connected; + return; + } + + _state = state; + _sessionOperationsAllowed = state == ConnectionState.Connected; + } + + StateChanged?.Invoke(state); } public void Dispose() { - if (_disposed) return; - _disposed = true; + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; - _client.Connected -= OnClientConnected; - _client.Disconnected -= OnClientDisconnected; - _client.ConnectionError -= OnClientConnectionError; - _client.ReconnectRequired -= OnReconnectRequired; + _client.CancelPendingReconnect(); + try + { + // Serialize disposal behind any in-flight lifecycle transaction. The + // bounded wait preserves the existing shutdown behavior; the queued task + // still owns cleanup if the timeout is reached. + Task.Run(DisconnectAsync).Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException ex) + { + _logger.Warning($"Connection manager disposal warning: {ex.InnerException?.Message ?? ex.Message}"); + } + catch (Exception ex) + { + _logger.Warning($"Connection manager disposal warning: {ex.Message}"); + } + finally + { + _client.Connected -= OnClientConnected; + _client.Disconnected -= OnClientDisconnected; + _client.ConnectionError -= OnClientConnectionError; + _client.ReconnectRequired -= OnReconnectRequired; - DisposeSubscription(); - _client.Dispose(); + _client.Dispose(); + CancelIntent(_connectionIntentCts); + _connectionIntentCts.Dispose(); + } } } diff --git a/OpcUa/Models/BrowsedNode.cs b/OpcUa/Models/BrowsedNode.cs index 54502fe..003fd3a 100644 --- a/OpcUa/Models/BrowsedNode.cs +++ b/OpcUa/Models/BrowsedNode.cs @@ -12,6 +12,11 @@ public class BrowsedNode public string DisplayName { get; init; } = string.Empty; public NodeClass NodeClass { get; init; } = NodeClass.Unspecified; public NodeId? DataType { get; init; } + /// + /// Connection generation that produced this node. NodeIds are only meaningful + /// within that server/session context and must not be reused after it changes. + /// + public long ConnectionGeneration { get; init; } public string? DataTypeName { get; set; } public bool HasChildren { get; set; } = true; // Assume true until proven otherwise public bool ChildrenLoaded { get; set; } = false; diff --git a/OpcUa/Models/MonitoredNode.cs b/OpcUa/Models/MonitoredNode.cs index 4832a9c..d9816e2 100644 --- a/OpcUa/Models/MonitoredNode.cs +++ b/OpcUa/Models/MonitoredNode.cs @@ -7,7 +7,19 @@ namespace Opcilloscope.OpcUa.Models; /// public class MonitoredNode { + private long _connectionGeneration; + public uint ClientHandle { get; init; } + /// + /// Connection lifecycle generation currently owning this monitored node. + /// Retained nodes advance with a successful reconnect; client handles may be + /// reused by a later generation. + /// + public long ConnectionGeneration + { + get => Volatile.Read(ref _connectionGeneration); + internal set => Volatile.Write(ref _connectionGeneration, value); + } public NodeId NodeId { get; init; } = ObjectIds.RootFolder; public string DisplayName { get; init; } = string.Empty; public string Value { get; set; } = string.Empty; @@ -38,6 +50,18 @@ public class MonitoredNode /// public byte AccessLevel { get; set; } = AccessLevels.CurrentRead; + /// + /// Effective access for the connected user. This, rather than the node's + /// general AccessLevel, controls whether write affordances are shown. + /// + public byte UserAccessLevel { get; set; } = AccessLevels.CurrentRead; + + /// + /// OPC UA ValueRank. -1 is scalar; zero or greater is an array shape that + /// the current scalar write dialog does not support. + /// + public int ValueRank { get; set; } = ValueRanks.Any; + /// /// The built-in data type of the node value. /// @@ -51,12 +75,16 @@ public class MonitoredNode /// /// Whether the node supports reading (has CurrentRead in AccessLevel). /// - public bool IsReadable => (AccessLevel & AccessLevels.CurrentRead) != 0; + public bool IsReadable => (UserAccessLevel & AccessLevels.CurrentRead) != 0; /// /// Whether the node supports writing (has CurrentWrite in AccessLevel). /// - public bool IsWritable => (AccessLevel & AccessLevels.CurrentWrite) != 0; + public bool IsWritable => (UserAccessLevel & AccessLevels.CurrentWrite) != 0; + + public bool IsScalar => ValueRank == ValueRanks.Scalar; + + public bool CanWrite => IsWritable && IsScalar; /// /// Access string for display: "R", "W", "RW", or "-". @@ -70,6 +98,13 @@ public class MonitoredNode /// public bool IsSelectedForScope { get; set; } + /// + /// True when the current value is UI-only connection state rather than a + /// value delivered/read from the OPC UA server. Synthetic values must not + /// be written to CSV recordings. + /// + public bool IsSyntheticValue { get; set; } + public string StatusString { get @@ -81,5 +116,20 @@ public string StatusString } } - public string TimestampString => Timestamp?.ToString("HH:mm:ss") ?? "-"; + public string TimestampString => Timestamp is { } timestamp + ? ToLocalDisplayTime(timestamp).ToString("HH:mm:ss") + : "-"; + + private static DateTime ToLocalDisplayTime(DateTime timestamp) + { + // OPC UA source timestamps are UTC. Some SDK/server paths surface them + // with Kind=Unspecified, so attach the protocol-defined kind before + // converting rather than interpreting them as local wall-clock time. + if (timestamp.Kind == DateTimeKind.Unspecified) + { + timestamp = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc); + } + + return timestamp.ToLocalTime(); + } } diff --git a/OpcUa/NodeBrowser.cs b/OpcUa/NodeBrowser.cs index a8e104d..bc65c80 100644 --- a/OpcUa/NodeBrowser.cs +++ b/OpcUa/NodeBrowser.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Opc.Ua; +using Opc.Ua.Client; using Opcilloscope.OpcUa.Models; using Opcilloscope.Utilities; @@ -12,17 +13,31 @@ public class NodeBrowser { private readonly OpcUaClientWrapper _client; private readonly Logger _logger; + private readonly Func _getConnectionGeneration; + private readonly Func _isConnectionGenerationActive; // Keyed by the data type's NodeId (not the variable's) so variables sharing a type // reuse the same lookup. ConcurrentDictionary because GetChildrenAsync resolves // data type names for multiple variables in parallel. private readonly ConcurrentDictionary _dataTypeCache = new(); - public NodeBrowser(OpcUaClientWrapper client, Logger logger) + public NodeBrowser( + OpcUaClientWrapper client, + Logger logger, + Func? getConnectionGeneration = null, + Func? isConnectionGenerationActive = null) { _client = client; _logger = logger; + _getConnectionGeneration = getConnectionGeneration ?? (() => 0); + _isConnectionGenerationActive = isConnectionGenerationActive + ?? (generation => generation == _getConnectionGeneration()); } + public long ConnectionGeneration => _getConnectionGeneration(); + + public bool IsConnectionGenerationActive(long generation) + => _isConnectionGenerationActive(generation); + public BrowsedNode GetRootNode() { return new BrowsedNode @@ -31,97 +46,144 @@ public BrowsedNode GetRootNode() BrowseName = "Root", DisplayName = "Root", NodeClass = NodeClass.Object, + ConnectionGeneration = ConnectionGeneration, HasChildren = true }; } public async Task> GetChildrenAsync(BrowsedNode parent) { + var generation = ConnectionGeneration; + if (parent.ConnectionGeneration != generation + || !IsConnectionGenerationActive(generation)) + return new List(); + if (!_client.IsConnected) return new List(); try { - var refs = await _client.BrowseAsync(parent.NodeId); - var children = new List(); + return await _client.ExecuteSessionOperationAsync( + session => GetChildrenCoreAsync(parent, session, generation)); + } + catch (Exception ex) + { + _logger.Error($"Browse failed for {parent.NodeId}: {ex.Message}"); + return new List(); + } + } - // First pass: create all child nodes without async operations - foreach (var r in refs) - { - // Convert ExpandedNodeId to NodeId - var targetNodeId = ExpandedNodeId.ToNodeId(r.NodeId, _client.Session?.NamespaceUris); - if (targetNodeId == null) - continue; - - // Get TypeDefinition NodeId - NodeId? typeDefNodeId = null; - if (r.TypeDefinition != null && !r.TypeDefinition.IsNull) - { - typeDefNodeId = ExpandedNodeId.ToNodeId(r.TypeDefinition, _client.Session?.NamespaceUris); - } + private async Task> GetChildrenCoreAsync( + BrowsedNode parent, + ISession session, + long generation) + { + if (!IsConnectionGenerationActive(generation)) + return new List(); - var child = new BrowsedNode - { - NodeId = targetNodeId, - BrowseName = r.BrowseName?.Name ?? string.Empty, - DisplayName = r.DisplayName?.Text ?? r.BrowseName?.Name ?? "Unknown", - NodeClass = r.NodeClass, - DataType = typeDefNodeId, - Parent = parent, - // Optimistically assume Objects and Variables may have children to avoid N browse calls. - // This creates false positives (expand arrows on leaf nodes) but eliminates the - // performance cost of checking every node during initial tree expansion. - // The actual child check happens lazily on first expansion. - // Trade-off: Other node classes (Methods, ObjectTypes, etc.) won't show expand arrows - // even if they have children, but this is rare in typical OPC UA address spaces. - HasChildren = r.NodeClass == NodeClass.Object || r.NodeClass == NodeClass.Variable - }; - - children.Add(child); - } + var refs = await OpcUaClientWrapper.BrowseCoreAsync(session, parent.NodeId) + .ConfigureAwait(false); + var children = new List(); - // Second pass: fetch data type names for variables in parallel - // This is the only async operation we still need - HasChildren is now lazy - var variableNodes = children.Where(c => c.NodeClass == NodeClass.Variable).ToList(); - if (variableNodes.Count > 0) + // First pass: create all child nodes without async operations + foreach (var r in refs) + { + // Convert ExpandedNodeId to NodeId + var targetNodeId = ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris); + if (targetNodeId == null) + continue; + + // Get TypeDefinition NodeId + NodeId? typeDefNodeId = null; + if (r.TypeDefinition != null && !r.TypeDefinition.IsNull) { - var dataTypeTasks = variableNodes.Select(async child => - { - child.DataTypeName = await GetDataTypeNameAsync(child.NodeId); - }); - await Task.WhenAll(dataTypeTasks); + typeDefNodeId = ExpandedNodeId.ToNodeId(r.TypeDefinition, session.NamespaceUris); } - parent.ChildrenLoaded = true; - parent.Children.Clear(); - parent.Children.AddRange(children); + var child = new BrowsedNode + { + NodeId = targetNodeId, + BrowseName = r.BrowseName?.Name ?? string.Empty, + DisplayName = r.DisplayName?.Text ?? r.BrowseName?.Name ?? "Unknown", + NodeClass = r.NodeClass, + DataType = typeDefNodeId, + ConnectionGeneration = generation, + Parent = parent, + // Optimistically assume hierarchical node classes may have children to avoid N browse calls. + // This creates false positives (expand arrows on leaf nodes) but eliminates the + // performance cost of checking every node during initial tree expansion. + // The actual child check happens lazily on first expansion. + // The first expansion corrects this optimistic value when a browse is empty. + HasChildren = r.NodeClass != NodeClass.Method + }; - return children; + children.Add(child); } - catch (Exception ex) + + // Second pass: fetch data type names for variables in parallel + // This is the only async operation we still need - HasChildren is now lazy + var variableNodes = children.Where(c => c.NodeClass == NodeClass.Variable).ToList(); + if (variableNodes.Count > 0) { - _logger.Error($"Browse failed for {parent.NodeId}: {ex.Message}"); - return new List(); + using var concurrency = new SemaphoreSlim(8); + var dataTypeTasks = variableNodes.Select(async child => + { + await concurrency.WaitAsync(); + try + { + child.DataTypeName = await GetDataTypeNameAsync( + session, + child.NodeId, + generation).ConfigureAwait(false); + } + finally + { + concurrency.Release(); + } + }); + await Task.WhenAll(dataTypeTasks).ConfigureAwait(false); } + + if (!IsConnectionGenerationActive(generation)) + return new List(); + + parent.ChildrenLoaded = true; + parent.HasChildren = children.Count > 0; + parent.Children.Clear(); + parent.Children.AddRange(children); + + return children; } - private async Task GetDataTypeNameAsync(NodeId nodeId) + private async Task GetDataTypeNameAsync( + ISession session, + NodeId nodeId, + long generation) { try { - var attrs = await _client.ReadAttributesAsync(nodeId, Attributes.DataType); + var attrs = await OpcUaClientWrapper.ReadAttributesCoreAsync( + session, + nodeId, + Attributes.DataType).ConfigureAwait(false); if (attrs.Count > 0 && attrs[0].Value is NodeId dataTypeId) { // Check built-in types first - resolved without a network call, no caching needed if (DataTypeResolver.TryGetBuiltInName(dataTypeId, out var builtIn)) return builtIn; - var key = dataTypeId.ToString(); + // Namespace indexes are session-specific. Scope custom-type + // names to the endpoint so connecting this browser to another + // server cannot reuse a same-looking NodeId from the old one. + var key = $"{generation}|{_client.CurrentEndpoint}|{dataTypeId}"; if (_dataTypeCache.TryGetValue(key, out var cached)) return cached; // If not built-in, browse for the type name - var typeAttrs = await _client.ReadAttributesAsync(dataTypeId, Attributes.DisplayName); + var typeAttrs = await OpcUaClientWrapper.ReadAttributesCoreAsync( + session, + dataTypeId, + Attributes.DisplayName).ConfigureAwait(false); if (typeAttrs.Count > 0 && typeAttrs[0].Value is LocalizedText lt && lt.Text is string name) { _dataTypeCache[key] = name; @@ -138,14 +200,39 @@ public async Task> GetChildrenAsync(BrowsedNode parent) return null; } - public async Task GetNodeAttributesAsync(NodeId nodeId) + public async Task GetNodeAttributesAsync( + NodeId nodeId, + long? expectedGeneration = null) { + var generation = expectedGeneration ?? ConnectionGeneration; + if (!IsConnectionGenerationActive(generation)) + return null; + if (!_client.IsConnected) return null; try { - var attrs = await _client.ReadAttributesAsync( + return await _client.ExecuteSessionOperationAsync( + session => GetNodeAttributesCoreAsync(session, nodeId, generation)); + } + catch (Exception ex) + { + _logger.Error($"Failed to read attributes: {ex.Message}"); + return null; + } + } + + private async Task GetNodeAttributesCoreAsync( + ISession session, + NodeId nodeId, + long generation) + { + if (!IsConnectionGenerationActive(generation)) + return null; + + var attrs = await OpcUaClientWrapper.ReadAttributesCoreAsync( + session, nodeId, Attributes.NodeId, Attributes.NodeClass, @@ -156,160 +243,178 @@ public async Task> GetChildrenAsync(BrowsedNode parent) Attributes.ValueRank, Attributes.AccessLevel, Attributes.UserAccessLevel, - Attributes.Value - ); + Attributes.Value).ConfigureAwait(false); - // Check if the node exists by verifying the NodeId attribute read was successful - if (attrs.Count == 0 || StatusCode.IsBad(attrs[0].StatusCode)) - { - return null; - } + // Check if the node exists by verifying the NodeId attribute read was successful + if (attrs.Count == 0 || StatusCode.IsBad(attrs[0].StatusCode)) + return null; - return new NodeAttributes - { - NodeId = nodeId, - NodeClass = attrs.Count > 1 && attrs[1].Value is int nc ? (NodeClass)nc : NodeClass.Unspecified, - BrowseName = attrs.Count > 2 && attrs[2].Value is QualifiedName qn ? qn.Name : null, - DisplayName = attrs.Count > 3 && attrs[3].Value is LocalizedText lt ? lt.Text : null, - Description = attrs.Count > 4 && attrs[4].Value is LocalizedText desc ? desc.Text : null, - DataType = attrs.Count > 5 && attrs[5].Value is NodeId dt ? await GetDataTypeNameByIdAsync(dt) : null, - ValueRank = attrs.Count > 6 && attrs[6].Value is int vr ? vr : null, - AccessLevel = attrs.Count > 7 && attrs[7].Value is byte al ? al : null, - UserAccessLevel = attrs.Count > 8 && attrs[8].Value is byte ual ? ual : null, - Value = attrs.Count > 9 && StatusCode.IsGood(attrs[9].StatusCode) ? FormatValue(attrs[9].Value) : null - }; - } - catch (Exception ex) - { - _logger.Error($"Failed to read attributes: {ex.Message}"); + var dataType = attrs.Count > 5 && attrs[5].Value is NodeId dt + ? await GetDataTypeNameByIdAsync(session, dt).ConfigureAwait(false) + : null; + if (!IsConnectionGenerationActive(generation)) return null; - } + + return new NodeAttributes + { + NodeId = nodeId, + NodeClass = attrs.Count > 1 && attrs[1].Value is int nc ? (NodeClass)nc : NodeClass.Unspecified, + BrowseName = attrs.Count > 2 && attrs[2].Value is QualifiedName qn ? qn.Name : null, + DisplayName = attrs.Count > 3 && attrs[3].Value is LocalizedText lt ? lt.Text : null, + Description = attrs.Count > 4 && attrs[4].Value is LocalizedText desc ? desc.Text : null, + DataType = dataType, + ValueRank = attrs.Count > 6 && attrs[6].Value is int vr ? vr : null, + AccessLevel = attrs.Count > 7 && attrs[7].Value is byte al ? al : null, + UserAccessLevel = attrs.Count > 8 && attrs[8].Value is byte ual ? ual : null, + Value = attrs.Count > 9 && StatusCode.IsGood(attrs[9].StatusCode) ? FormatValue(attrs[9].Value) : null + }; } /// /// Reads all OPC UA node attributes based on the node's class. /// Returns a dictionary of attribute name to value for formatting. /// - public async Task?> ReadAllNodeAttributesAsync(NodeId nodeId) + public async Task?> ReadAllNodeAttributesAsync( + NodeId nodeId, + long? expectedGeneration = null) { + var generation = expectedGeneration ?? ConnectionGeneration; + if (!IsConnectionGenerationActive(generation)) + return null; + if (!_client.IsConnected) return null; try { - // First, read NodeClass to determine which attributes to fetch - var nodeClassResult = await _client.ReadAttributesAsync(nodeId, Attributes.NodeClass); - if (nodeClassResult.Count == 0 || StatusCode.IsBad(nodeClassResult[0].StatusCode)) - { - return null; - } + return await _client.ExecuteSessionOperationAsync( + session => ReadAllNodeAttributesCoreAsync(session, nodeId, generation)); + } + catch (Exception ex) + { + _logger.Error($"Failed to read all node attributes: {ex.Message}"); + return null; + } + } + + private async Task?> ReadAllNodeAttributesCoreAsync( + ISession session, + NodeId nodeId, + long generation) + { + if (!IsConnectionGenerationActive(generation)) + return null; - var nodeClass = nodeClassResult[0].Value is int nc ? (NodeClass)nc : NodeClass.Unspecified; + // First, read NodeClass to determine which attributes to fetch + var nodeClassResult = await OpcUaClientWrapper.ReadAttributesCoreAsync( + session, + nodeId, + Attributes.NodeClass).ConfigureAwait(false); + if (nodeClassResult.Count == 0 || StatusCode.IsBad(nodeClassResult[0].StatusCode)) + return null; - // Base attributes for all nodes - var baseAttributes = new uint[] - { - Attributes.NodeId, - Attributes.NodeClass, - Attributes.BrowseName, - Attributes.DisplayName, - Attributes.Description, - Attributes.WriteMask, - Attributes.UserWriteMask, - }; + var nodeClass = nodeClassResult[0].Value is int nc ? (NodeClass)nc : NodeClass.Unspecified; - // Class-specific attributes - var classAttributes = nodeClass switch - { - NodeClass.Variable => new uint[] - { - Attributes.Value, - Attributes.DataType, - Attributes.ValueRank, - Attributes.ArrayDimensions, - Attributes.AccessLevel, - Attributes.UserAccessLevel, - Attributes.MinimumSamplingInterval, - Attributes.Historizing, - Attributes.AccessLevelEx, - }, - NodeClass.Object => new uint[] { Attributes.EventNotifier }, - NodeClass.Method => new uint[] { Attributes.Executable, Attributes.UserExecutable }, - NodeClass.ObjectType or NodeClass.VariableType => new uint[] { Attributes.IsAbstract }, - NodeClass.DataType => new uint[] { Attributes.IsAbstract, Attributes.DataTypeDefinition }, - NodeClass.ReferenceType => new uint[] - { - Attributes.IsAbstract, - Attributes.Symmetric, - Attributes.InverseName - }, - NodeClass.View => new uint[] { Attributes.ContainsNoLoops, Attributes.EventNotifier }, - _ => Array.Empty() - }; + // Base attributes for all nodes + var baseAttributes = new uint[] + { + Attributes.NodeId, + Attributes.NodeClass, + Attributes.BrowseName, + Attributes.DisplayName, + Attributes.Description, + Attributes.WriteMask, + Attributes.UserWriteMask, + }; - // Optional attributes (may not exist on older servers) - var optionalAttributes = new uint[] + // Class-specific attributes + var classAttributes = nodeClass switch + { + NodeClass.Variable => new uint[] { - Attributes.RolePermissions, - Attributes.UserRolePermissions, - Attributes.AccessRestrictions, - }; + Attributes.Value, + Attributes.DataType, + Attributes.ValueRank, + Attributes.ArrayDimensions, + Attributes.AccessLevel, + Attributes.UserAccessLevel, + Attributes.MinimumSamplingInterval, + Attributes.Historizing, + Attributes.AccessLevelEx, + }, + NodeClass.Object => new uint[] { Attributes.EventNotifier }, + NodeClass.Method => new uint[] { Attributes.Executable, Attributes.UserExecutable }, + NodeClass.ObjectType or NodeClass.VariableType => new uint[] { Attributes.IsAbstract }, + NodeClass.DataType => new uint[] { Attributes.IsAbstract, Attributes.DataTypeDefinition }, + NodeClass.ReferenceType => new uint[] + { + Attributes.IsAbstract, + Attributes.Symmetric, + Attributes.InverseName + }, + NodeClass.View => new uint[] { Attributes.ContainsNoLoops, Attributes.EventNotifier }, + _ => Array.Empty() + }; - // Combine all attributes - var allAttributes = baseAttributes - .Concat(classAttributes) - .Concat(optionalAttributes) - .Distinct() - .ToArray(); + // Optional attributes (may not exist on older servers) + var optionalAttributes = new uint[] + { + Attributes.RolePermissions, + Attributes.UserRolePermissions, + Attributes.AccessRestrictions, + }; - // Read all attributes in a single call - var results = await _client.ReadAttributesAsync(nodeId, allAttributes); + // Combine all attributes + var allAttributes = baseAttributes + .Concat(classAttributes) + .Concat(optionalAttributes) + .Distinct() + .ToArray(); - // Build the result dictionary - var result = new Dictionary(); - var attributeNames = GetAttributeNames(); + // Read all attributes in a single call + var results = await OpcUaClientWrapper.ReadAttributesCoreAsync( + session, + nodeId, + allAttributes).ConfigureAwait(false); - for (int i = 0; i < allAttributes.Length && i < results.Count; i++) - { - var attrId = allAttributes[i]; - var dataValue = results[i]; + // Build the result dictionary + var result = new Dictionary(); + var attributeNames = GetAttributeNames(); - // Skip attributes that don't exist or had errors (except BadAttributeIdInvalid which is expected) - if (StatusCode.IsBad(dataValue.StatusCode)) - { - continue; - } + for (int i = 0; i < allAttributes.Length && i < results.Count; i++) + { + var attrId = allAttributes[i]; + var dataValue = results[i]; - if (attributeNames.TryGetValue(attrId, out var name)) - { - var value = dataValue.Value; + // Skip attributes that don't exist or had errors. + if (StatusCode.IsBad(dataValue.StatusCode)) + continue; - // Special handling for DataType - resolve to display name - if (attrId == Attributes.DataType && value is NodeId dtNodeId) + if (attributeNames.TryGetValue(attrId, out var name)) + { + var value = dataValue.Value; + + // Special handling for DataType - resolve to display name + if (attrId == Attributes.DataType && value is NodeId dtNodeId) + { + try { - try - { - var dtName = await GetDataTypeNameByIdAsync(dtNodeId); - value = $"{dtNodeId} ({dtName ?? "Unknown"})"; - } - catch - { - // If resolution fails, just use the NodeId string - value = dtNodeId.ToString(); - } + var dtName = await GetDataTypeNameByIdAsync(session, dtNodeId) + .ConfigureAwait(false); + value = $"{dtNodeId} ({dtName ?? "Unknown"})"; + } + catch + { + // If resolution fails, just use the NodeId string + value = dtNodeId.ToString(); } - - result[name] = value; } - } - return result; - } - catch (Exception ex) - { - _logger.Error($"Failed to read all node attributes: {ex.Message}"); - return null; + result[name] = value; + } } + + return IsConnectionGenerationActive(generation) ? result : null; } /// @@ -357,14 +462,19 @@ private static string FormatValue(object? value) return value.ToString() ?? "null"; } - private async Task GetDataTypeNameByIdAsync(NodeId dataTypeId) + private async Task GetDataTypeNameByIdAsync( + ISession session, + NodeId dataTypeId) { if (DataTypeResolver.TryGetBuiltInName(dataTypeId, out var builtInName)) return builtInName; try { - var attrs = await _client.ReadAttributesAsync(dataTypeId, Attributes.DisplayName); + var attrs = await OpcUaClientWrapper.ReadAttributesCoreAsync( + session, + dataTypeId, + Attributes.DisplayName).ConfigureAwait(false); if (attrs.Count > 0 && attrs[0].Value is LocalizedText lt) return lt.Text; } diff --git a/OpcUa/OpcUaClientWrapper.cs b/OpcUa/OpcUaClientWrapper.cs index af77369..9ac3549 100644 --- a/OpcUa/OpcUaClientWrapper.cs +++ b/OpcUa/OpcUaClientWrapper.cs @@ -14,28 +14,55 @@ public class OpcUaClientWrapper : IDisposable private ISession? _session; private readonly Logger _logger; private string? _currentEndpoint; + private readonly SemaphoreSlim _lifecycleGate = new(1, 1); private CancellationTokenSource? _reconnectCts; private readonly object _reconnectCtsLock = new(); - private bool _disposed; + private int _disposed; private ApplicationConfiguration? _appConfig; private ConfiguredEndpoint? _lastConfiguredEndpoint; private ConnectionCredentials _credentials = ConnectionCredentials.Anonymous; private readonly bool _allowInsecure; + private readonly string _pkiRootPath; private string? _securityMode; private string? _securityPolicy; + private int _currentSecurityMode = -1; + private string? _currentSecurityPolicy; /// - /// Process-wide default for whether untrusted server certificates are auto-accepted. + /// Process-wide default for whether server certificate validation failures are accepted. /// Set once at startup from the --insecure CLI flag (see Program.cs). /// Wrapper instances created without an explicit allowInsecure argument inherit /// this value. Defaults to false (secure-by-default). /// public static bool AllowInsecureByDefault { get; set; } + // Assembly-wide test seam so integration tests never create certificates in + // the interactive user's real PKI store. Production code leaves this null. + internal static string? PkiRootPathOverrideForTests { get; set; } + public bool IsConnected => _session?.Connected ?? false; public string? CurrentEndpoint => _currentEndpoint; public ISession? Session => _session; + /// + /// Gets the message security mode of the endpoint used by the active session. + /// This is the actual discovered selection, not merely the requested mode. + /// + public MessageSecurityMode? CurrentSecurityMode + { + get + { + var value = Volatile.Read(ref _currentSecurityMode); + return value < 0 ? null : (MessageSecurityMode)value; + } + } + + /// + /// Gets the full security-policy URI of the endpoint used by the active session. + /// This is the actual discovered selection, not merely the requested policy. + /// + public string? CurrentSecurityPolicy => Volatile.Read(ref _currentSecurityPolicy); + /// /// Raised when connection is established (initial or after reconnect). /// @@ -61,15 +88,33 @@ public class OpcUaClientWrapper : IDisposable /// /// Optional logger. /// - /// When true, untrusted server certificates are auto-accepted (development only). + /// When true, server certificate validation failures are accepted (development only). /// When null (the default), the value of is used. /// public OpcUaClientWrapper(Logger? logger = null, bool? allowInsecure = null) + : this(logger, allowInsecure, GetDefaultPkiRootPath()) { + } + + // Test seam for keeping generated certificates and trust decisions out of the + // interactive user's real PKI directories. + internal OpcUaClientWrapper(Logger? logger, bool? allowInsecure, string pkiRootPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pkiRootPath); + _logger = logger ?? new Logger(); _allowInsecure = allowInsecure ?? AllowInsecureByDefault; + _pkiRootPath = Path.GetFullPath(pkiRootPath); } + private static string GetDefaultPkiRootPath() + => PkiRootPathOverrideForTests is { Length: > 0 } overridePath + ? Path.GetFullPath(overridePath) + : Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "opcilloscope", + "pki"); + private async Task GetApplicationConfigAsync() { if (_appConfig != null) @@ -86,31 +131,23 @@ private async Task GetApplicationConfigAsync() ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "opcilloscope", "pki", "own"), + StorePath = Path.Combine(_pkiRootPath, "own"), SubjectName = "CN=Opcilloscope, O=Opcilloscope, DC=localhost" }, TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "opcilloscope", "pki", "issuer") + StorePath = Path.Combine(_pkiRootPath, "issuer") }, TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "opcilloscope", "pki", "trusted") + StorePath = Path.Combine(_pkiRootPath, "trusted") }, RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "opcilloscope", "pki", "rejected") + StorePath = Path.Combine(_pkiRootPath, "rejected") }, // Secure-by-default: never blanket-accept. Acceptance is decided per // certificate by OnCertificateValidation, gated on the --insecure flag. @@ -137,11 +174,11 @@ private async Task GetApplicationConfigAsync() if (_allowInsecure) { - _logger.Warning("Insecure mode enabled (--insecure): untrusted server certificates will be auto-accepted. Not recommended for production."); + _logger.Warning("Insecure mode enabled (--insecure): server certificate validation is disabled. Not recommended for production."); } else { - _logger.Info("Certificate validation enabled. Untrusted server certificates will be rejected (re-run with --insecure to override)."); + _logger.Info("Certificate validation enabled. Invalid server certificates will be rejected (re-run with --insecure to override)."); } return _appConfig; @@ -149,7 +186,7 @@ private async Task GetApplicationConfigAsync() /// /// Decides whether to accept a server certificate that failed validation. - /// Without --insecure, untrusted certificates are rejected with a clear, + /// Without --insecure, certificate validation failures are rejected with a clear, /// actionable log message. With --insecure, they are accepted (development only). /// private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e) @@ -160,7 +197,7 @@ private void OnCertificateValidation(CertificateValidator sender, CertificateVal if (_allowInsecure) { - _logger.Warning($"Accepting untrusted server certificate (--insecure): '{e.Certificate?.Subject}' [{e.Error.StatusCode}]"); + _logger.Warning($"Bypassing server certificate validation (--insecure): '{e.Certificate?.Subject}' [{e.Error.StatusCode}]"); e.AcceptAll = true; e.Accept = true; return; @@ -169,55 +206,146 @@ private void OnCertificateValidation(CertificateValidator sender, CertificateVal var trustedStorePath = _appConfig?.SecurityConfiguration?.TrustedPeerCertificates?.StorePath; _logger.Error( $"Server certificate rejected ({e.Error.StatusCode}): '{e.Certificate?.Subject}'. Connection refused. " + - "Re-run with --insecure to accept untrusted certificates (development only), " + + "Re-run with --insecure to bypass server certificate validation (development only), " + $"or add the trusted certificate to the PKI store at: {trustedStorePath}"); e.Accept = false; } - public async Task ConnectAsync( + public Task ConnectAsync( string endpointUrl, ConnectionCredentials? credentials = null, string? securityMode = null, string? securityPolicy = null) { + CancelPendingReconnect(); + return ExecuteLifecycleAsync( + () => ConnectCoreAsync(endpointUrl, credentials, securityMode, securityPolicy)); + } + + /// + /// Runs a complete lifecycle transaction under the wrapper's single async gate. + /// ConnectionManager uses this same gate so session and subscription state change + /// as one serialized operation rather than through two independently locked layers. + /// + internal async Task ExecuteLifecycleAsync(Func> operation) + { + await _lifecycleGate.WaitAsync().ConfigureAwait(false); + try + { + return await operation().ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + } + + internal async Task ExecuteLifecycleAsync(Func operation) + { + await _lifecycleGate.WaitAsync().ConfigureAwait(false); try { - await DisconnectAsync(); + await operation().ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + } + + /// + /// Runs a complete application operation against one pinned session while holding + /// the lifecycle gate. Connection changes therefore wait for the operation, and an + /// operation queued behind a connection change cannot accidentally capture the old + /// session and continue through the new one. + /// + internal Task ExecuteSessionOperationAsync(Func> operation) + => ExecuteLifecycleAsync(async () => + { + var session = _session; + if (session == null || !session.Connected) + throw new InvalidOperationException("Not connected"); + + return await operation(session).ConfigureAwait(false); + }); + + /// + /// Connect implementation for callers that already own . + /// Never call a public lifecycle method from this method: doing so would wait on + /// the non-reentrant gate and deadlock. + /// + internal async Task ConnectCoreAsync( + string endpointUrl, + ConnectionCredentials? credentials = null, + string? securityMode = null, + string? securityPolicy = null) + { + try + { + if (Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(OpcUaClientWrapper)); + + await DisconnectCoreAsync().ConfigureAwait(false); _credentials = credentials ?? ConnectionCredentials.Anonymous; - _securityMode = securityMode; - _securityPolicy = securityPolicy; + _credentials.Validate(); + _securityMode = NormalizeSecuritySetting(securityMode); + _securityPolicy = NormalizeSecuritySetting(securityPolicy); _logger.Info($"Connecting to {endpointUrl}..."); - var config = await GetApplicationConfigAsync(); + var config = await GetApplicationConfigAsync().ConfigureAwait(false); // Select the strongest endpoint matching the requested security settings. - var selectedEndpoint = await DiscoverAndSelectEndpointAsync(config, endpointUrl, _securityMode, _securityPolicy); + var selectedEndpoint = await DiscoverAndSelectEndpointAsync( + config, + endpointUrl, + _securityMode, + _securityPolicy).ConfigureAwait(false); // Create session var endpointConfig = EndpointConfiguration.Create(config); var endpoint = new ConfiguredEndpoint(null, selectedEndpoint, endpointConfig); - _lastConfiguredEndpoint = endpoint; #pragma warning disable CS0618 // Session.Create is obsolete but ISessionFactory.CreateAsync requires additional setup - _session = await Opc.Ua.Client.Session.Create( + var newSession = await Opc.Ua.Client.Session.Create( config, endpoint, false, "Opcilloscope Session", 60000, CreateUserIdentity(), - null - ); + null).ConfigureAwait(false); #pragma warning restore CS0618 - // Configure session for subscription preservation during reconnection - _session.DeleteSubscriptionsOnClose = false; - _session.TransferSubscriptionsOnReconnect = true; - - _session.KeepAlive += Session_KeepAlive; + var published = false; + try + { + // Configure session for subscription preservation during reconnection. + newSession.DeleteSubscriptionsOnClose = false; + newSession.TransferSubscriptionsOnReconnect = true; + newSession.KeepAlive += Session_KeepAlive; + + _session = newSession; + published = true; + _lastConfiguredEndpoint = endpoint; + _currentEndpoint = endpointUrl; + SetCurrentSecurityProfile(selectedEndpoint); + } + catch + { + // Session.Create succeeded, so this local session must be retired even + // if subsequent setup fails before ownership transfers to _session. + if (!published) + { + try { newSession.Dispose(); } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.Warning($"Session cleanup error after failed setup: {ex.Message}"); + } + } + throw; + } - _currentEndpoint = endpointUrl; _logger.Info($"Connected to {endpointUrl}"); Connected?.Invoke(); return true; @@ -231,21 +359,26 @@ public async Task ConnectAsync( ? "Authentication failed: invalid username or password." : "Authentication rejected by server: " + sre.Message; _logger.Error(msg); + await DisconnectCoreAsync().ConfigureAwait(false); ConnectionError?.Invoke(msg); - await DisconnectAsync(); return false; } catch (Exception ex) { _logger.Error($"Connection failed: {ex.Message}"); + await DisconnectCoreAsync().ConfigureAwait(false); ConnectionError?.Invoke(ex.Message); - await DisconnectAsync(); return false; } } private void Session_KeepAlive(ISession session, KeepAliveEventArgs e) { + // Keep-alive callbacks can already be queued when a session is detached. + // Never let stale session A mark a newly installed session B unhealthy. + if (!IsCurrentSessionCallback(session)) + return; + // A bad keep-alive status indicates the connection is unhealthy. On a transient // TCP drop the SDK can still report session.Connected == true, so do NOT gate on // it - that previously prevented auto-reconnect from ever firing for network @@ -257,6 +390,9 @@ private void Session_KeepAlive(ISession session, KeepAliveEventArgs e) } } + internal bool IsCurrentSessionCallback(ISession session) + => Volatile.Read(ref _disposed) == 0 && ReferenceEquals(session, _session); + /// /// Synchronously disconnects the current session. Kept for back-compat with /// existing synchronous callers; UI callers should prefer @@ -264,136 +400,148 @@ private void Session_KeepAlive(ISession session, KeepAliveEventArgs e) /// public void Disconnect() { - DisposeReconnectCts(); - - var session = _session; - if (session != null) - { - _session = null; - _currentEndpoint = null; - try - { - session.KeepAlive -= Session_KeepAlive; - session.CloseAsync().GetAwaiter().GetResult(); - session.Dispose(); - } - catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) - { - // Session cleanup errors are expected during network issues - _logger.Warning($"Session cleanup error (non-critical): {ex.Message}"); - } - Disconnected?.Invoke(); - } + CancelPendingReconnect(); + DisconnectAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } /// - /// Cancels and disposes the reconnect cancellation token source, if any. - /// Safe to race with : the field swap happens - /// under a lock, so two callers can never dispose the same instance twice. + /// Cancels an in-flight reconnect before a connect or disconnect waits for the + /// lifecycle gate. The reconnect operation remains the sole owner responsible + /// for disposing its token source. /// - private void DisposeReconnectCts() + internal void CancelPendingReconnect() { CancellationTokenSource? cts; lock (_reconnectCtsLock) { cts = _reconnectCts; - _reconnectCts = null; } if (cts != null) { try { cts.Cancel(); } catch (ObjectDisposedException) { } - cts.Dispose(); } } + private void CompleteReconnect(CancellationTokenSource cts) + { + lock (_reconnectCtsLock) + { + if (ReferenceEquals(_reconnectCts, cts)) + _reconnectCts = null; + } + + cts.Dispose(); + } + /// /// Attempts to reconnect preserving the existing session and subscriptions. /// Uses OPC UA Session.Reconnect first, then falls back to recreating the session /// with subscription transfer. /// /// True if reconnection succeeded, false otherwise. - public async Task ReconnectAsync() + public Task ReconnectAsync() => ExecuteLifecycleAsync(() => ReconnectCoreAsync()); + + /// + /// Reconnect implementation for callers that already own the lifecycle gate. + /// + internal async Task ReconnectCoreAsync( + CancellationToken cancellationToken = default) { + if (Volatile.Read(ref _disposed) != 0) + return false; + if (_session == null && string.IsNullOrEmpty(_currentEndpoint)) { _logger.Warning("No session or endpoint to reconnect"); return false; } - DisposeReconnectCts(); - - // Work with a local reference throughout: a concurrent Disconnect() can null - // and dispose the field at any time, so re-reading it mid-loop would NRE or - // touch a disposed CTS. - var cts = new CancellationTokenSource(); + var cts = cancellationToken.CanBeCanceled + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : new CancellationTokenSource(); lock (_reconnectCtsLock) { _reconnectCts = cts; } var token = cts.Token; - // Exponential backoff: 1s, 2s, 4s, 8s - int[] delays = { 1000, 2000, 4000, 8000 }; - - for (int attempt = 0; attempt < delays.Length; attempt++) + try { - if (token.IsCancellationRequested) - return false; - - _logger.Info($"Reconnection attempt {attempt + 1}/{delays.Length}..."); + // Exponential backoff: 1s, 2s, 4s, 8s + int[] delays = { 1000, 2000, 4000, 8000 }; - try + for (int attempt = 0; attempt < delays.Length; attempt++) { - // Strategy 1: Try to reconnect the existing session (preserves subscriptions automatically) - if (_session != null) + if (token.IsCancellationRequested) + return false; + + _logger.Info($"Reconnection attempt {attempt + 1}/{delays.Length}..."); + + try { - var reconnectResult = await TrySessionReconnectAsync(token); - if (reconnectResult && !token.IsCancellationRequested) + // Strategy 1: Try to reconnect the existing session (preserves subscriptions automatically) + if (_session != null) { - _logger.Info("Session reconnected successfully (subscriptions preserved)"); - Connected?.Invoke(); - return true; + var reconnectResult = await TrySessionReconnectAsync(token).ConfigureAwait(false); + if (reconnectResult && !token.IsCancellationRequested) + { + _logger.Info("Session reconnected successfully (subscriptions preserved)"); + Connected?.Invoke(); + return true; + } } - } - // Strategy 2: Recreate session and transfer subscriptions - var recreateResult = await TryRecreateSessionAsync(token); - if (recreateResult) - { if (token.IsCancellationRequested) - { - // The user disconnected while the session was being recreated; - // don't resurrect a connection they asked to close. - _logger.Info("Reconnect cancelled after session recreation - closing the new session"); - await DisconnectAsync(); return false; + + // Strategy 2: Recreate session and transfer subscriptions + var recreateResult = await TryRecreateSessionAsync(token).ConfigureAwait(false); + if (recreateResult) + { + if (token.IsCancellationRequested) + { + // The user disconnected while the session was being recreated; + // don't resurrect a connection they asked to close. Call the core + // method because this operation already owns the lifecycle gate. + _logger.Info("Reconnect cancelled after session recreation - closing the new session"); + await DisconnectCoreAsync().ConfigureAwait(false); + return false; + } + + _logger.Info("Session recreated successfully (subscriptions transferred)"); + Connected?.Invoke(); + return true; } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return false; + } + catch (Exception ex) + { + _logger.Warning($"Reconnection attempt {attempt + 1} failed: {ex.Message}"); + } - _logger.Info("Session recreated successfully (subscriptions transferred)"); - Connected?.Invoke(); - return true; + // Wait before next attempt + try + { + await Task.Delay(delays[attempt], token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return false; } - } - catch (Exception ex) - { - _logger.Warning($"Reconnection attempt {attempt + 1} failed: {ex.Message}"); } - // Wait before next attempt - try - { - await Task.Delay(delays[attempt], token); - } - catch (OperationCanceledException) - { - return false; - } + _logger.Error("Reconnection failed after all attempts"); + Disconnected?.Invoke(); + return false; + } + finally + { + CompleteReconnect(cts); } - - _logger.Error("Reconnection failed after all attempts"); - Disconnected?.Invoke(); - return false; } /// @@ -402,14 +550,15 @@ public async Task ReconnectAsync() /// private async Task TrySessionReconnectAsync(CancellationToken cancellationToken) { - if (_session == null) + var session = _session; + if (session == null) return false; try { _logger.Info("Attempting session reconnect..."); - await _session.ReconnectAsync(cancellationToken); - return _session.Connected; + await session.ReconnectAsync(cancellationToken).ConfigureAwait(false); + return ReferenceEquals(session, _session) && session.Connected; } catch (ServiceResultException ex) { @@ -424,16 +573,21 @@ private async Task TrySessionReconnectAsync(CancellationToken cancellation /// private async Task TryRecreateSessionAsync(CancellationToken cancellationToken) { - if (_lastConfiguredEndpoint == null || string.IsNullOrEmpty(_currentEndpoint)) + cancellationToken.ThrowIfCancellationRequested(); + var endpointUrl = _currentEndpoint; + if (_lastConfiguredEndpoint == null || string.IsNullOrEmpty(endpointUrl)) return false; + var oldSession = _session; + ISession? newSession = null; + var installed = false; + try { _logger.Info("Recreating session..."); - var config = await GetApplicationConfigAsync(); - - var oldSession = _session; + var config = await GetApplicationConfigAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); // Capture existing subscriptions before replacing the session SubscriptionCollection? subscriptionsToTransfer = null; @@ -444,71 +598,79 @@ private async Task TryRecreateSessionAsync(CancellationToken cancellationT } // Rediscover endpoint in case server configuration changed - var selectedEndpoint = await DiscoverAndSelectEndpointAsync(config, _currentEndpoint, _securityMode, _securityPolicy); + var selectedEndpoint = await DiscoverAndSelectEndpointAsync( + config, + endpointUrl, + _securityMode, + _securityPolicy).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); var endpointConfig = EndpointConfiguration.Create(config); var endpoint = new ConfiguredEndpoint(null, selectedEndpoint, endpointConfig); - _lastConfiguredEndpoint = endpoint; + cancellationToken.ThrowIfCancellationRequested(); // Create the new session before touching the old one, so a failure here // leaves the existing state unchanged for the next retry attempt. #pragma warning disable CS0618 - var newSession = await Opc.Ua.Client.Session.Create( + newSession = await Opc.Ua.Client.Session.Create( config, endpoint, false, "Opcilloscope Session", 60000, CreateUserIdentity(), - null - ); + null).ConfigureAwait(false); #pragma warning restore CS0618 + cancellationToken.ThrowIfCancellationRequested(); newSession.DeleteSubscriptionsOnClose = false; newSession.TransferSubscriptionsOnReconnect = true; newSession.KeepAlive += Session_KeepAlive; _session = newSession; + installed = true; + _lastConfiguredEndpoint = endpoint; + SetCurrentSecurityProfile(selectedEndpoint); // Transfer subscriptions while the old session is still alive. The client-side // half of TransferSubscriptionsAsync detaches each subscription from its previous // session; if that session is already disposed this throws, the SDK swallows it // and reports failure - after the server-side transfer already succeeded - leaving // orphaned subscriptions on the server and forcing a duplicate recreate. - try - { - if (subscriptionsToTransfer != null && subscriptionsToTransfer.Count > 0) - { - var transferred = await TransferSubscriptionsAsync(subscriptionsToTransfer, cancellationToken); - _logger.Info($"Transferred {transferred} of {subscriptionsToTransfer.Count} subscription(s)"); - } - } - finally + if (subscriptionsToTransfer != null && subscriptionsToTransfer.Count > 0) { - // Retire the old session even if the transfer throws - once _session - // points at the new session the old one would otherwise leak. Dispose - // without CloseAsync() - sending CloseSession would delete any - // server-side subscriptions that were not transferred, and the - // transport is typically already dead on this path. - if (oldSession != null) - { - oldSession.KeepAlive -= Session_KeepAlive; - try - { - oldSession.Dispose(); - } - catch (Exception ex) - { - _logger.Warning($"Session cleanup error during reconnection: {ex.Message}"); - } - } + var transferred = await TransferSubscriptionsAsync( + subscriptionsToTransfer, + cancellationToken).ConfigureAwait(false); + _logger.Info($"Transferred {transferred} of {subscriptionsToTransfer.Count} subscription(s)"); } return newSession.Connected; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return false; + } catch (Exception ex) { _logger.Error($"Session recreation failed: {ex.Message}"); return false; } + finally + { + if (installed) + { + // Once the new session is installed, always retire the old local + // session even if transfer or status inspection throws. Do not send + // CloseSession here: it could delete server-side subscriptions that + // were just transferred. + if (oldSession != null && !ReferenceEquals(oldSession, newSession)) + DisposeSessionWithoutClose(oldSession, "during reconnection"); + } + else if (newSession != null) + { + // Session.Create completed but ownership was never published. + DisposeSessionWithoutClose(newSession, "after failed reconnection setup"); + } + } } /// @@ -516,7 +678,8 @@ private async Task TryRecreateSessionAsync(CancellationToken cancellationT /// private async Task TransferSubscriptionsAsync(SubscriptionCollection subscriptions, CancellationToken cancellationToken) { - if (_session == null || subscriptions == null) + var session = _session; + if (session == null || subscriptions == null) return 0; int transferred = 0; @@ -524,10 +687,10 @@ private async Task TransferSubscriptionsAsync(SubscriptionCollection subscr try { // Use the OPC UA TransferSubscriptions service - var success = await _session.TransferSubscriptionsAsync( + var success = await session.TransferSubscriptionsAsync( subscriptions, sendInitialValues: true, - cancellationToken); + cancellationToken).ConfigureAwait(false); if (success) { @@ -556,36 +719,43 @@ private async Task TransferSubscriptionsAsync(SubscriptionCollection subscr return transferred; } - public async Task BrowseAsync(NodeId nodeId) - { - if (_session == null) - throw new InvalidOperationException("Not connected"); + public Task BrowseAsync(NodeId nodeId) + => ExecuteSessionOperationAsync(session => BrowseCoreAsync(session, nodeId)); - var browser = new Browser(_session) + internal static async Task BrowseCoreAsync( + ISession session, + NodeId nodeId) + { + var browser = new Browser(session) { BrowseDirection = BrowseDirection.Forward, - NodeClassMask = (int)NodeClass.Object | (int)NodeClass.Variable | (int)NodeClass.Method, + // A zero mask requests all node classes. Restricting this to Object, + // Variable, and Method hid ObjectType, VariableType, ReferenceType, + // DataType, and View nodes from standard hierarchy browsing. + NodeClassMask = 0, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, ResultMask = (uint)BrowseResultMask.All }; - return await browser.BrowseAsync(nodeId); + return await browser.BrowseAsync(nodeId).ConfigureAwait(false); } - public async Task ReadValueAsync(NodeId nodeId) - { - if (_session == null) - throw new InvalidOperationException("Not connected"); + public Task ReadValueAsync(NodeId nodeId) + => ExecuteSessionOperationAsync(session => ReadValueCoreAsync(session, nodeId)); - return await _session.ReadValueAsync(nodeId); - } + internal static async Task ReadValueCoreAsync(ISession session, NodeId nodeId) + => await session.ReadValueAsync(nodeId).ConfigureAwait(false); - public async Task ReadAttributesAsync(NodeId nodeId, params uint[] attributeIds) - { - if (_session == null) - throw new InvalidOperationException("Not connected"); + public Task ReadAttributesAsync(NodeId nodeId, params uint[] attributeIds) + => ExecuteSessionOperationAsync( + session => ReadAttributesCoreAsync(session, nodeId, attributeIds)); + internal static async Task ReadAttributesCoreAsync( + ISession session, + NodeId nodeId, + params uint[] attributeIds) + { var nodesToRead = new ReadValueIdCollection(); foreach (var attrId in attributeIds) { @@ -596,7 +766,7 @@ public async Task ReadAttributesAsync(NodeId nodeId, params }); } - var response = await _session.ReadAsync( + var response = await session.ReadAsync( null, 0, TimestampsToReturn.Both, @@ -607,11 +777,15 @@ public async Task ReadAttributesAsync(NodeId nodeId, params return response.Results; } - public async Task WriteValueAsync(NodeId nodeId, object value) - { - if (_session == null) - throw new InvalidOperationException("Not connected"); + public Task WriteValueAsync(NodeId nodeId, object value) + => ExecuteSessionOperationAsync( + session => WriteValueCoreAsync(session, nodeId, value)); + internal static async Task WriteValueCoreAsync( + ISession session, + NodeId nodeId, + object value) + { var nodesToWrite = new WriteValueCollection { new WriteValue @@ -622,7 +796,7 @@ public async Task WriteValueAsync(NodeId nodeId, object value) } }; - var response = await _session.WriteAsync( + var response = await session.WriteAsync( null, nodesToWrite, CancellationToken.None @@ -635,48 +809,103 @@ public async Task WriteValueAsync(NodeId nodeId, object value) /// Asynchronously disconnects the current session without blocking the calling thread /// on the OPC UA close round-trip. Preferred over for UI callers. /// - public async Task DisconnectAsync() + public Task DisconnectAsync() { - DisposeReconnectCts(); + CancelPendingReconnect(); + return ExecuteLifecycleAsync(DisconnectCoreAsync); + } + /// + /// Disconnect implementation for callers that already own the lifecycle gate. + /// The session is detached before any fallible cleanup, and Dispose always runs + /// even when event removal or CloseAsync throws. + /// + internal async Task DisconnectCoreAsync() + { var session = _session; - if (session != null) + _session = null; + _currentEndpoint = null; + ClearCurrentSecurityProfile(); + + if (session == null) + return; + + try { - _session = null; - _currentEndpoint = null; try { session.KeepAlive -= Session_KeepAlive; - await session.CloseAsync().ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.Warning($"Session event cleanup error (non-critical): {ex.Message}"); + } + + await session.CloseAsync().ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + // Session close errors are expected during network issues. + _logger.Warning($"Session close error (non-critical): {ex.Message}"); + } + finally + { + try + { session.Dispose(); } catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { - // Session cleanup errors are expected during network issues - _logger.Warning($"Session cleanup error (non-critical): {ex.Message}"); + _logger.Warning($"Session dispose error (non-critical): {ex.Message}"); } - Disconnected?.Invoke(); } + + Disconnected?.Invoke(); } public void Dispose() { - if (!_disposed) + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + CancelPendingReconnect(); + try + { + // Bounded synchronous wait to avoid deadlocks when disposed from a + // synchronization context (mirrors SubscriptionManager.Dispose). Use + // the core method because the disposed flag intentionally blocks connect. + Task.Run(() => ExecuteLifecycleAsync(DisconnectCoreAsync)) + .Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException ex) + { + _logger.Warning($"Disposal warning: {ex.InnerException?.Message ?? ex.Message}"); + } + catch (Exception ex) + { + _logger.Warning($"Disposal warning: {ex.Message}"); + } + } + + private void DisposeSessionWithoutClose(ISession session, string context) + { + try + { + session.KeepAlive -= Session_KeepAlive; + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.Warning($"Session event cleanup error {context}: {ex.Message}"); + } + finally { - _disposed = true; try { - // Bounded synchronous wait to avoid deadlocks when disposed from a - // synchronization context (mirrors SubscriptionManager.Dispose). - Task.Run(async () => await DisconnectAsync()).Wait(TimeSpan.FromSeconds(5)); - } - catch (AggregateException ex) - { - _logger.Warning($"Disposal warning: {ex.InnerException?.Message ?? ex.Message}"); + session.Dispose(); } - catch (Exception ex) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { - _logger.Warning($"Disposal warning: {ex.Message}"); + _logger.Warning($"Session dispose error {context}: {ex.Message}"); } } } @@ -699,6 +928,21 @@ private async Task DiscoverAndSelectEndpointAsync( string? securityMode, string? securityPolicy) { + var hasRequestedMode = !string.IsNullOrEmpty(securityMode); + + if (_credentials.Type == AuthenticationType.UserName + && hasRequestedMode + && string.Equals( + securityMode, + nameof(MessageSecurityMode.None), + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Username credentials require an encrypted or signed OPC UA endpoint; " + + "SecurityMode=None is not permitted. The --insecure option only controls " + + "certificate trust and never permits plaintext credentials."); + } + // Discover endpoints from the server var uri = new Uri(endpointUrl); _logger.Info($"Discovering endpoints at {uri}..."); @@ -711,7 +955,7 @@ private async Task DiscoverAndSelectEndpointAsync( EndpointDescriptionCollection endpoints; try { - endpoints = await client.GetEndpointsAsync(null); + endpoints = await client.GetEndpointsAsync(null).ConfigureAwait(false); _logger.Info($"Found {endpoints.Count} endpoints"); } catch (Exception ex) @@ -728,34 +972,12 @@ private async Task DiscoverAndSelectEndpointAsync( $"No endpoints offered by server at {endpointUrl}"); } - // Security is requested either by an explicit, non-"None" SecurityMode, or whenever - // credentials are supplied: username/password tokens must never be sent over an - // unencrypted channel. SelectEndpoint falls back to a None endpoint only if the server - // offers no secure endpoint, so this never hard-fails a None-only server. - bool securityRequested = !string.IsNullOrEmpty(securityMode) - && !string.Equals(securityMode, nameof(MessageSecurityMode.None), StringComparison.OrdinalIgnoreCase); - bool useSecurity = securityRequested || _credentials.Type != AuthenticationType.Anonymous; - - // If a specific SecurityMode/SecurityPolicy was requested, honor it by narrowing the - // candidate set to exact matches; fall back to all endpoints if none match. - var candidates = endpoints; - if (useSecurity && (!string.IsNullOrEmpty(securityMode) || !string.IsNullOrEmpty(securityPolicy))) - { - var matches = endpoints - .Where(ep => MatchesSecurityMode(ep, securityMode) && MatchesSecurityPolicy(ep, securityPolicy)) - .ToList(); - - if (matches.Count > 0) - { - candidates = new EndpointDescriptionCollection(matches); - } - else - { - _logger.Warning( - $"No endpoint matched requested SecurityMode='{securityMode}' / SecurityPolicy='{securityPolicy}'. " + - "Selecting the strongest available endpoint instead."); - } - } + var (candidates, useSecurity) = FilterEndpointCandidates( + endpoints, + _credentials.Type, + securityMode, + securityPolicy, + endpointUrl); // CoreClientUtils.SelectEndpoint sorts by SecurityLevel and returns the strongest // endpoint matching the security preference (instead of the first match). @@ -773,9 +995,9 @@ private async Task DiscoverAndSelectEndpointAsync( if (_credentials.Type != AuthenticationType.Anonymous && selectedEndpoint.SecurityMode == MessageSecurityMode.None) { - _logger.Warning( - "Credentials will be sent over an UNENCRYPTED channel: the selected endpoint uses SecurityMode=None. " + - "Anyone on the network can read the username and password. Prefer a server endpoint with Sign or SignAndEncrypt."); + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "Endpoint selection refused SecurityMode=None for username credentials."); } // Update the endpoint URL to use the requested host if different @@ -793,6 +1015,151 @@ private async Task DiscoverAndSelectEndpointAsync( return selectedEndpoint; } + internal static (EndpointDescriptionCollection Candidates, bool UseSecurity) FilterEndpointCandidates( + EndpointDescriptionCollection endpoints, + AuthenticationType authenticationType, + string? securityMode, + string? securityPolicy, + string endpointUrl) + { + var hasRequestedMode = !string.IsNullOrEmpty(securityMode); + var hasRequestedPolicy = !string.IsNullOrEmpty(securityPolicy); + var hasExplicitSecurityProfile = hasRequestedMode || hasRequestedPolicy; + var explicitlyAllowsNone = hasRequestedMode + && string.Equals( + securityMode, + nameof(MessageSecurityMode.None), + StringComparison.OrdinalIgnoreCase); + var explicitlyAllowsSignOnly = hasRequestedMode + && string.Equals( + securityMode, + nameof(MessageSecurityMode.Sign), + StringComparison.OrdinalIgnoreCase); + + if (hasRequestedPolicy + && IsNoneSecurityPolicy(securityPolicy!) + && !explicitlyAllowsNone) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "SecurityPolicy=None does not opt into plaintext transport by itself; " + + "set SecurityMode=None explicitly as well."); + } + + // An explicit mode or policy is a contract, not a preference. Narrow to exact + // matches and fail closed instead of silently choosing a different profile. + var candidates = endpoints; + if (hasExplicitSecurityProfile) + { + var matches = endpoints + .Where(ep => MatchesSecurityMode(ep, securityMode) && MatchesSecurityPolicy(ep, securityPolicy)) + .ToList(); + + if (matches.Count == 0) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + $"No endpoint matched requested SecurityMode='{securityMode ?? ""}' / " + + $"SecurityPolicy='{securityPolicy ?? ""}' at {endpointUrl}."); + } + + candidates = new EndpointDescriptionCollection(matches); + } + + // Unless the mode explicitly opts into Sign-only or None, require encryption. + // This prevents an omitted/partial profile from silently exposing browse, + // read, or write payloads on a Sign-only channel. UserName credentials always + // require at least signing, even if a caller requests None. + var requireEncryption = !explicitlyAllowsSignOnly && !explicitlyAllowsNone; + var requireSecuredEndpoint = authenticationType == AuthenticationType.UserName + || !explicitlyAllowsNone; + if (requireEncryption) + { + var encryptedCandidates = candidates + .Where(ep => ep.SecurityMode == MessageSecurityMode.SignAndEncrypt) + .ToList(); + + if (encryptedCandidates.Count == 0) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "The server offered no SignAndEncrypt OPC UA endpoint. " + + "To deliberately allow signed-but-unencrypted traffic, set SecurityMode=Sign; " + + "for unauthenticated plaintext, set SecurityMode=None with anonymous authentication."); + } + + candidates = new EndpointDescriptionCollection(encryptedCandidates); + } + else if (requireSecuredEndpoint) + { + var signedCandidates = candidates + .Where(ep => ep.SecurityMode is MessageSecurityMode.Sign or MessageSecurityMode.SignAndEncrypt) + .ToList(); + if (signedCandidates.Count == 0) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "Username credentials require an encrypted or signed OPC UA endpoint."); + } + + candidates = new EndpointDescriptionCollection(signedCandidates); + } + + // Endpoint security and user-token security are separate OPC UA contracts. + // Keep only endpoints that advertise the requested identity type, and only + // expose compatible policies to Session.Create so it cannot select an + // incompatible first policy from a mixed collection. In particular, a + // username token with SecurityPolicy=None is invalid on a Sign-only channel: + // the password would not be encrypted. + var identityCandidates = new EndpointDescriptionCollection(); + foreach (var endpoint in candidates) + { + var compatiblePolicies = new UserTokenPolicyCollection(); + if (endpoint.UserIdentityTokens != null) + { + foreach (var policy in endpoint.UserIdentityTokens) + { + if (IsCompatibleUserTokenPolicy(endpoint, policy, authenticationType)) + compatiblePolicies.Add((UserTokenPolicy)policy.Clone()); + } + } + + if (compatiblePolicies.Count == 0) + continue; + + var compatibleEndpoint = (EndpointDescription)endpoint.Clone(); + compatibleEndpoint.UserIdentityTokens = compatiblePolicies; + identityCandidates.Add(compatibleEndpoint); + } + + if (identityCandidates.Count == 0) + { + var identityType = authenticationType == AuthenticationType.UserName + ? nameof(AuthenticationType.UserName) + : nameof(AuthenticationType.Anonymous); + var userNameSecurityRequirement = authenticationType == AuthenticationType.UserName + ? " UserName policies with SecurityPolicy=None are compatible only with " + + "SignAndEncrypt endpoints; Sign endpoints require an encrypted user-token policy." + : string.Empty; + + throw new ServiceResultException( + StatusCodes.BadIdentityTokenRejected, + $"No endpoint at {endpointUrl} offered a compatible {identityType} user-token policy." + + userNameSecurityRequirement); + } + + candidates = identityCandidates; + + var requestedSecureMode = hasRequestedMode + && !string.Equals( + securityMode, + nameof(MessageSecurityMode.None), + StringComparison.OrdinalIgnoreCase); + var requestedSecurePolicy = hasRequestedPolicy && !IsNoneSecurityPolicy(securityPolicy!); + var useSecurity = requireSecuredEndpoint || requestedSecureMode || requestedSecurePolicy; + return (candidates, useSecurity); + } + private static bool MatchesSecurityMode(EndpointDescription endpoint, string? securityMode) => string.IsNullOrEmpty(securityMode) || string.Equals(endpoint.SecurityMode.ToString(), securityMode, StringComparison.OrdinalIgnoreCase); @@ -802,4 +1169,82 @@ private static bool MatchesSecurityPolicy(EndpointDescription endpoint, string? || string.Equals(endpoint.SecurityPolicyUri, securityPolicy, StringComparison.OrdinalIgnoreCase) || endpoint.SecurityPolicyUri?.EndsWith("#" + securityPolicy, StringComparison.OrdinalIgnoreCase) == true || endpoint.SecurityPolicyUri?.EndsWith("/" + securityPolicy, StringComparison.OrdinalIgnoreCase) == true; + + private static bool IsCompatibleUserTokenPolicy( + EndpointDescription endpoint, + UserTokenPolicy policy, + AuthenticationType authenticationType) + { + var requiredTokenType = authenticationType switch + { + AuthenticationType.Anonymous => UserTokenType.Anonymous, + AuthenticationType.UserName => UserTokenType.UserName, + _ => throw new ArgumentOutOfRangeException( + nameof(authenticationType), + authenticationType, + "Unsupported OPC UA authentication type.") + }; + + if (policy.TokenType != requiredTokenType) + return false; + + if (authenticationType == AuthenticationType.Anonymous) + return true; + + // A null/empty user-token policy inherits the endpoint SecurityPolicy. + // A Username/None token is still confidential on SignAndEncrypt because + // the SecureChannel encrypts the ActivateSession request. It is invalid on + // Sign, where the password would otherwise be transmitted in clear text. + var tokenSecurityPolicy = string.IsNullOrWhiteSpace(policy.SecurityPolicyUri) + ? endpoint.SecurityPolicyUri + : policy.SecurityPolicyUri; + if (string.IsNullOrWhiteSpace(tokenSecurityPolicy)) + return false; + + // Discovery data must use the canonical URI because the SDK later uses + // this value verbatim to choose encryption algorithms. Do not accept the + // shorthand forms allowed for human-entered configuration. + if (string.Equals(tokenSecurityPolicy, SecurityPolicies.None, StringComparison.Ordinal)) + { + return endpoint.SecurityMode == MessageSecurityMode.SignAndEncrypt + && !string.IsNullOrWhiteSpace(endpoint.SecurityPolicyUri) + && !string.Equals( + endpoint.SecurityPolicyUri, + SecurityPolicies.None, + StringComparison.Ordinal); + } + + // Non-None user-token encryption requires the server certificate carried + // by the EndpointDescription. Full trust and algorithm compatibility are + // validated by the OPC UA stack during session creation; reject a missing + // certificate here before selecting an unusable endpoint. + return SecurityPolicies.IsValidSecurityPolicyUri(tokenSecurityPolicy) + && endpoint.ServerCertificate is { Length: > 0 }; + } + + private static bool IsNoneSecurityPolicy(string securityPolicy) + => string.Equals(securityPolicy, SecurityPolicies.None, StringComparison.OrdinalIgnoreCase) + || string.Equals(securityPolicy, "None", StringComparison.OrdinalIgnoreCase) + || securityPolicy.EndsWith("#None", StringComparison.OrdinalIgnoreCase) + || securityPolicy.EndsWith("/None", StringComparison.OrdinalIgnoreCase); + + private static string? NormalizeSecuritySetting(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private void SetCurrentSecurityProfile(EndpointDescription endpoint) + { + Volatile.Write(ref _currentSecurityMode, (int)endpoint.SecurityMode); + Volatile.Write(ref _currentSecurityPolicy, endpoint.SecurityPolicyUri); + + // Pin subsequent session recreation to the profile that was actually + // negotiated, even when the original request left mode or policy open. + _securityMode = endpoint.SecurityMode.ToString(); + _securityPolicy = endpoint.SecurityPolicyUri; + } + + private void ClearCurrentSecurityProfile() + { + Volatile.Write(ref _currentSecurityMode, -1); + Volatile.Write(ref _currentSecurityPolicy, null); + } } diff --git a/OpcUa/SubscriptionManager.cs b/OpcUa/SubscriptionManager.cs index 86bc77f..d202d4e 100644 --- a/OpcUa/SubscriptionManager.cs +++ b/OpcUa/SubscriptionManager.cs @@ -14,6 +14,7 @@ public class SubscriptionManager : IDisposable, IAsyncDisposable { private readonly OpcUaClientWrapper _clientWrapper; private readonly Logger _logger; + private long _connectionGeneration; private Subscription? _subscription; private readonly Dictionary _monitoredVariables = new(); private readonly Dictionary _opcMonitoredItems = new(); @@ -25,6 +26,11 @@ public class SubscriptionManager : IDisposable, IAsyncDisposable private uint _queueSize = 10; private bool _isInitialized; private readonly object _lock = new(); + // OPC Foundation Subscription mutations are not safe to overlap. This gate + // covers the complete local/server transaction (including ApplyChangesAsync) + // and is also acquired by reconnect cleanup and disposal. + private readonly SemaphoreSlim _mutationGate = new(1, 1); + private int _disposeStarted; /// /// Raised when a monitored variable value changes. @@ -39,7 +45,7 @@ public class SubscriptionManager : IDisposable, IAsyncDisposable /// /// Raised when a monitored variable is removed. /// - public event Action? VariableRemoved; + public event Action? VariableRemoved; public int PublishingInterval { @@ -77,13 +83,49 @@ public IReadOnlyCollection MonitoredVariables } } - public SubscriptionManager(OpcUaClientWrapper clientWrapper, Logger logger) + public SubscriptionManager( + OpcUaClientWrapper clientWrapper, + Logger logger, + long connectionGeneration = 0) { _clientWrapper = clientWrapper; _logger = logger; + _connectionGeneration = connectionGeneration; + } + + /// + /// Advances the provenance of nodes retained across a successful reconnect. + /// New SubscriptionManager instances receive their generation in the + /// constructor; transferred/recreated subscriptions keep their models and + /// therefore need those models advanced in place before values resume. + /// + internal void AdvanceConnectionGeneration(long connectionGeneration) + { + lock (_lock) + { + _connectionGeneration = connectionGeneration; + foreach (var variable in _monitoredVariables.Values) + { + variable.ConnectionGeneration = connectionGeneration; + } + } } public async Task InitializeAsync() + { + await _mutationGate.WaitAsync().ConfigureAwait(false); + try + { + return Volatile.Read(ref _disposeStarted) == 0 + && await InitializeCoreAsync().ConfigureAwait(false); + } + finally + { + _mutationGate.Release(); + } + } + + private async Task InitializeCoreAsync() { if (_clientWrapper.Session == null || !_clientWrapper.IsConnected) { @@ -121,6 +163,22 @@ public async Task InitializeAsync() } public async Task AddNodeAsync(NodeId nodeId, string displayName) + { + await _mutationGate.WaitAsync().ConfigureAwait(false); + try + { + if (Volatile.Read(ref _disposeStarted) != 0) + return null; + + return await AddNodeCoreAsync(nodeId, displayName).ConfigureAwait(false); + } + finally + { + _mutationGate.Release(); + } + } + + private async Task AddNodeCoreAsync(NodeId nodeId, string displayName) { if (!_isInitialized || _subscription == null || _clientWrapper.Session == null) { @@ -143,11 +201,13 @@ public async Task InitializeAsync() clientHandle = _nextClientHandle++; } + var subscription = _subscription; + MonitoredItem? monitoredItem = null; + var publishedLocally = false; try { - // Create OPC UA monitored item - var monitoredItem = new MonitoredItem(_subscription.DefaultItem) + monitoredItem = new MonitoredItem(subscription.DefaultItem) { DisplayName = displayName, StartNodeId = nodeId, @@ -160,14 +220,29 @@ public async Task InitializeAsync() monitoredItem.Notification += MonitoredItem_Notification; // Add to subscription and create the monitored item on the server - _subscription.AddItem(monitoredItem); - await _subscription.ApplyChangesAsync(); + subscription.AddItem(monitoredItem); + await subscription.ApplyChangesAsync().ConfigureAwait(false); + + if (!monitoredItem.Status.Created || ServiceResult.IsBad(monitoredItem.Status.Error)) + { + var status = monitoredItem.Status.Error?.ToString() + ?? "the server did not create the monitored item"; + _logger.Error($"Failed to create monitored item for {displayName}: {status}"); + await RollbackMonitoredItemsCoreAsync( + subscription, + [monitoredItem], + $"failed add for {displayName}").ConfigureAwait(false); + return null; + } - if (ServiceResult.IsBad(monitoredItem.Status.Error)) + // DisposeAsync marks disposal before waiting for this gate. Do not + // publish a successful item after teardown has already been requested. + if (Volatile.Read(ref _disposeStarted) != 0) { - _logger.Error($"Failed to create monitored item for {displayName}: {monitoredItem.Status.Error}"); - monitoredItem.Notification -= MonitoredItem_Notification; - _subscription.RemoveItem(monitoredItem); + await RollbackMonitoredItemsCoreAsync( + subscription, + [monitoredItem], + $"cancelled add for {displayName}").ConfigureAwait(false); return null; } @@ -182,47 +257,100 @@ public async Task InitializeAsync() StatusCode = 0 // Good }; - // Re-check membership after the await to close the TOCTOU window: a concurrent - // AddNodeAsync may have added the same node while we awaited ApplyChangesAsync. - bool duplicate; lock (_lock) { - duplicate = _monitoredVariables.Values.Any(m => m.NodeId.EqualsNodeId(nodeId)); - if (!duplicate) - { - _monitoredVariables[clientHandle] = variable; - _opcMonitoredItems[clientHandle] = monitoredItem; - _opcHandleToClientHandle[monitoredItem.ClientHandle] = clientHandle; - } + // Read the generation at publication time while sharing the + // same lock as AdvanceConnectionGeneration. An add that overlaps + // a reconnect can therefore never publish the prior generation. + variable.ConnectionGeneration = _connectionGeneration; + _monitoredVariables[clientHandle] = variable; + _opcMonitoredItems[clientHandle] = monitoredItem; + _opcHandleToClientHandle[monitoredItem.ClientHandle] = clientHandle; + publishedLocally = true; } - if (duplicate) + _logger.Info($"Subscribed to {displayName}"); + try { - _logger.Warning($"Node {displayName} is already being monitored"); - monitoredItem.Notification -= MonitoredItem_Notification; - _subscription.RemoveItem(monitoredItem); - await _subscription.ApplyChangesAsync(); - return null; + VariableAdded?.Invoke(variable); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + // Observer failures must not turn a committed item into a null + // result while leaving it present in the dictionaries. + _logger.Warning($"Variable-added handler failed for {displayName}: {ex.Message}"); } - - _logger.Info($"Subscribed to {displayName}"); - VariableAdded?.Invoke(variable); // Read initial value and node attributes (AccessLevel, DataType) in parallel await Task.WhenAll( ReadInitialValueAsync(variable), ReadNodeAttributesAsync(variable) - ); + ).ConfigureAwait(false); return variable; } catch (Exception ex) { _logger.Error($"Failed to add monitored variable: {ex.Message}"); + if (monitoredItem != null) + { + if (publishedLocally) + { + lock (_lock) + { + _monitoredVariables.Remove(clientHandle); + _opcMonitoredItems.Remove(clientHandle); + _opcHandleToClientHandle.Remove(monitoredItem.ClientHandle); + } + } + + await RollbackMonitoredItemsCoreAsync( + subscription, + [monitoredItem], + $"exception while adding {displayName}").ConfigureAwait(false); + } return null; } } + /// + /// Detaches and removes locally-added monitored items, then best-effort applies + /// the removal in case a preceding create reached the server before failing. + /// The caller must hold . + /// + private async Task RollbackMonitoredItemsCoreAsync( + Subscription subscription, + IEnumerable monitoredItems, + string context) + { + var removedAny = false; + foreach (var item in monitoredItems) + { + item.Notification -= MonitoredItem_Notification; + try + { + subscription.RemoveItem(item); + removedAny = true; + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.Warning($"Failed to remove local monitored item during {context}: {ex.Message}"); + } + } + + if (!removedAny || !subscription.Created) + return; + + try + { + await subscription.ApplyChangesAsync().ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.Warning($"Failed to apply monitored-item rollback during {context}: {ex.Message}"); + } + } + private void MonitoredItem_Notification(MonitoredItem monitoredItem, MonitoredItemNotificationEventArgs e) { try @@ -255,13 +383,20 @@ private async Task ReadInitialValueAsync(MonitoredNode item) { try { - var value = await _clientWrapper.ReadValueAsync(item.NodeId); + var session = _clientWrapper.Session; + if (session == null) + return; + + var value = await OpcUaClientWrapper + .ReadValueCoreAsync(session, item.NodeId) + .ConfigureAwait(false); if (value != null) { item.Value = FormatValue(value.Value); item.RawValue = FormatRawValue(value.Value); item.Timestamp = value.SourceTimestamp; item.StatusCode = (uint)value.StatusCode.Code; + item.IsSyntheticValue = false; ValueChanged?.Invoke(item); } } @@ -275,13 +410,22 @@ private async Task ReadNodeAttributesAsync(MonitoredNode item) { try { - // Read AccessLevel and DataType attributes - var results = await _clientWrapper.ReadAttributesAsync( - item.NodeId, - Attributes.AccessLevel, - Attributes.DataType); - - if (results.Count >= 2) + var session = _clientWrapper.Session; + if (session == null) + return; + + // Read both server-wide and per-user access plus value shape. The + // write dialog currently supports scalar values only. + var results = await OpcUaClientWrapper.ReadAttributesCoreAsync( + session, + item.NodeId, + Attributes.AccessLevel, + Attributes.UserAccessLevel, + Attributes.DataType, + Attributes.ValueRank) + .ConfigureAwait(false); + + if (results.Count >= 4) { // AccessLevel if (StatusCode.IsGood(results[0].StatusCode) && results[0].Value is byte accessLevel) @@ -289,13 +433,25 @@ private async Task ReadNodeAttributesAsync(MonitoredNode item) item.AccessLevel = accessLevel; } + // UserAccessLevel is the permission that applies to the active + // identity and must drive write affordances. + if (StatusCode.IsGood(results[1].StatusCode) && results[1].Value is byte userAccessLevel) + { + item.UserAccessLevel = userAccessLevel; + } + // DataType - this is a NodeId that we need to resolve - if (StatusCode.IsGood(results[1].StatusCode) && results[1].Value is NodeId dataTypeNodeId) + if (StatusCode.IsGood(results[2].StatusCode) && results[2].Value is NodeId dataTypeNodeId) { var (builtInType, typeName) = DataTypeResolver.Resolve(dataTypeNodeId); item.DataType = builtInType; item.DataTypeName = typeName; } + + if (StatusCode.IsGood(results[3].StatusCode) && results[3].Value is int valueRank) + { + item.ValueRank = valueRank; + } } // Notify UI about updated attributes @@ -308,6 +464,22 @@ private async Task ReadNodeAttributesAsync(MonitoredNode item) } public async Task RemoveNodeAsync(uint clientHandle) + { + await _mutationGate.WaitAsync().ConfigureAwait(false); + try + { + if (Volatile.Read(ref _disposeStarted) != 0) + return false; + + return await RemoveNodeCoreAsync(clientHandle).ConfigureAwait(false); + } + finally + { + _mutationGate.Release(); + } + } + + private async Task RemoveNodeCoreAsync(uint clientHandle) { MonitoredNode? variable; MonitoredItem? opcItem; @@ -335,7 +507,7 @@ public async Task RemoveNodeAsync(uint clientHandle) { opcItem.Notification -= MonitoredItem_Notification; _subscription.RemoveItem(opcItem); - await _subscription.ApplyChangesAsync(); + await _subscription.ApplyChangesAsync().ConfigureAwait(false); } catch (Exception ex) { @@ -344,7 +516,14 @@ public async Task RemoveNodeAsync(uint clientHandle) } _logger.Info($"Unsubscribed from {variable.DisplayName}"); - VariableRemoved?.Invoke(clientHandle); + try + { + VariableRemoved?.Invoke(clientHandle, _connectionGeneration); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.Warning($"Variable-removed handler failed for {variable.DisplayName}: {ex.Message}"); + } return true; } @@ -357,6 +536,7 @@ private void ProcessValueChange(MonitoredNode variable, DataValue dataValue) variable.RawValue = FormatRawValue(dataValue.Value); variable.Timestamp = dataValue.SourceTimestamp; variable.StatusCode = (uint)dataValue.StatusCode.Code; + variable.IsSyntheticValue = false; if (oldValue != newValue) { @@ -421,6 +601,22 @@ internal static string FormatRawValue(object? value) } public async Task ClearAsync() + { + await _mutationGate.WaitAsync().ConfigureAwait(false); + try + { + if (Volatile.Read(ref _disposeStarted) != 0) + return; + + await ClearCoreAsync().ConfigureAwait(false); + } + finally + { + _mutationGate.Release(); + } + } + + private async Task ClearCoreAsync() { List handles; lock (_lock) @@ -429,7 +625,7 @@ public async Task ClearAsync() } foreach (var handle in handles) { - await RemoveNodeAsync(handle); + await RemoveNodeCoreAsync(handle).ConfigureAwait(false); } } @@ -470,6 +666,20 @@ public bool IsSubscriptionValid() /// Call this when Session.Reconnect() or TransferSubscriptions succeeded. /// public async Task ReattachAfterReconnectAsync() + { + await _mutationGate.WaitAsync().ConfigureAwait(false); + try + { + return Volatile.Read(ref _disposeStarted) == 0 + && await ReattachAfterReconnectCoreAsync().ConfigureAwait(false); + } + finally + { + _mutationGate.Release(); + } + } + + private async Task ReattachAfterReconnectCoreAsync() { if (_clientWrapper.Session == null) { @@ -495,7 +705,7 @@ public async Task ReattachAfterReconnectAsync() } // Read current values to update UI - await RefreshAllValuesAsync(); + await RefreshAllValuesAsync().ConfigureAwait(false); return true; } @@ -510,6 +720,20 @@ public async Task ReattachAfterReconnectAsync() /// Preserves the MonitoredNode models and recreates the OPC UA subscription. /// public async Task RecreateSubscriptionsAsync() + { + await _mutationGate.WaitAsync().ConfigureAwait(false); + try + { + return Volatile.Read(ref _disposeStarted) == 0 + && await RecreateSubscriptionsCoreAsync().ConfigureAwait(false); + } + finally + { + _mutationGate.Release(); + } + } + + private async Task RecreateSubscriptionsCoreAsync() { if (_clientWrapper.Session == null || !_clientWrapper.IsConnected) { @@ -526,32 +750,37 @@ public async Task RecreateSubscriptionsAsync() .ToList(); } + // Replace the old OPC subscription even when it contains no monitored + // nodes; otherwise recreation would leak a second empty subscription. + await CleanupOpcSubscriptionCoreAsync().ConfigureAwait(false); + if (nodesToRestore.Count == 0) { _logger.Info("No subscriptions to recreate"); // Still need to create empty subscription for future use - return await InitializeAsync(); + return await InitializeCoreAsync().ConfigureAwait(false); } _logger.Info($"Recreating {nodesToRestore.Count} subscription(s)..."); - // Clean up old OPC UA objects (but keep our MonitoredNode models) - await CleanupOpcSubscriptionAsync(); - // Create new subscription - if (!await InitializeAsync()) + if (!await InitializeCoreAsync().ConfigureAwait(false)) { _logger.Error("Failed to create new subscription"); return false; } - // Recreate monitored items - int restored = 0; + // Build all replacement items first, but do not publish their handle maps + // until the server has accepted every item. The boolean method contract is + // intentionally all-or-nothing so ConnectionManager can run its loss fallback. + var subscription = _subscription!; + var pendingItems = new List<(uint ClientHandle, string DisplayName, MonitoredItem Item)>(); + var constructionFailed = false; foreach (var (clientHandle, nodeId, displayName) in nodesToRestore) { try { - var monitoredItem = new MonitoredItem(_subscription!.DefaultItem) + var monitoredItem = new MonitoredItem(subscription.DefaultItem) { DisplayName = displayName, StartNodeId = nodeId, @@ -562,39 +791,71 @@ public async Task RecreateSubscriptionsAsync() }; monitoredItem.Notification += MonitoredItem_Notification; - _subscription.AddItem(monitoredItem); - - lock (_lock) - { - _opcMonitoredItems[clientHandle] = monitoredItem; - _opcHandleToClientHandle[monitoredItem.ClientHandle] = clientHandle; - } - - restored++; + subscription.AddItem(monitoredItem); + pendingItems.Add((clientHandle, displayName, monitoredItem)); } catch (Exception ex) { + constructionFailed = true; _logger.Warning($"Failed to recreate monitored item for {displayName}: {ex.Message}"); } } // Apply all changes at once - if (restored > 0) + if (pendingItems.Count > 0) { try { - await _subscription!.ApplyChangesAsync(); - _logger.Info($"Restored {restored} of {nodesToRestore.Count} subscription(s)"); + await subscription.ApplyChangesAsync().ConfigureAwait(false); } catch (Exception ex) { _logger.Error($"Failed to apply subscription changes: {ex.Message}"); + await RollbackMonitoredItemsCoreAsync( + subscription, + pendingItems.Select(p => p.Item), + "failed subscription recreation").ConfigureAwait(false); return false; } } + var failedItems = pendingItems + .Where(p => !p.Item.Status.Created || ServiceResult.IsBad(p.Item.Status.Error)) + .ToList(); + var restored = pendingItems.Count - failedItems.Count; + + foreach (var failed in failedItems) + { + var status = failed.Item.Status.Error?.ToString() + ?? "the server did not create the monitored item"; + _logger.Warning($"Failed to recreate monitored item for {failed.DisplayName}: {status}"); + } + + if (constructionFailed || failedItems.Count > 0 || pendingItems.Count != nodesToRestore.Count) + { + await RollbackMonitoredItemsCoreAsync( + subscription, + pendingItems.Select(p => p.Item), + "partial subscription recreation").ConfigureAwait(false); + _logger.Error( + $"Restored {restored} of {nodesToRestore.Count} subscription(s); " + + "reporting failure so stale UI handles can be removed."); + return false; + } + + lock (_lock) + { + foreach (var pending in pendingItems) + { + _opcMonitoredItems[pending.ClientHandle] = pending.Item; + _opcHandleToClientHandle[pending.Item.ClientHandle] = pending.ClientHandle; + } + } + + _logger.Info($"Restored {restored} of {nodesToRestore.Count} subscription(s)"); + // Read current values to update UI - await RefreshAllValuesAsync(); + await RefreshAllValuesAsync().ConfigureAwait(false); return true; } @@ -618,7 +879,7 @@ private async Task RefreshAllValuesAsync() /// /// Cleans up the OPC UA subscription objects without clearing our MonitoredNode models. /// - private async Task CleanupOpcSubscriptionAsync() + private async Task CleanupOpcSubscriptionCoreAsync() { lock (_lock) { @@ -630,21 +891,27 @@ private async Task CleanupOpcSubscriptionAsync() _opcHandleToClientHandle.Clear(); } - if (_subscription != null) + var subscription = _subscription; + _subscription = null; + if (subscription != null) { try { - if (_clientWrapper.Session != null && _clientWrapper.Session.Subscriptions.Contains(_subscription)) + if (_clientWrapper.Session != null && _clientWrapper.Session.Subscriptions.Contains(subscription)) { - await _clientWrapper.Session.RemoveSubscriptionAsync(_subscription); + await _clientWrapper.Session + .RemoveSubscriptionAsync(subscription) + .ConfigureAwait(false); } - _subscription.Dispose(); } catch (Exception ex) { _logger.Warning($"Failed to cleanup OPC subscription: {ex.Message}"); } - _subscription = null; + finally + { + subscription.Dispose(); + } } _isInitialized = false; @@ -664,6 +931,7 @@ public void MarkAllAsStale() variable.Value = "(reconnecting...)"; variable.RawValue = "(reconnecting...)"; variable.StatusCode = StatusCodes.UncertainInitialValue; + variable.IsSyntheticValue = true; } } @@ -679,27 +947,27 @@ public void MarkAllAsStale() /// public async ValueTask DisposeAsync() { - if (_subscription != null && _clientWrapper.Session != null) + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) { - try - { - await _clientWrapper.Session.RemoveSubscriptionAsync(_subscription); - _subscription.Dispose(); - } - catch (Exception ex) - { - _logger?.Warning($"Failed to dispose OPC UA subscription: {ex.Message}"); - } + GC.SuppressFinalize(this); + return; } - _subscription = null; - _isInitialized = false; + await _mutationGate.WaitAsync().ConfigureAwait(false); + try + { + await CleanupOpcSubscriptionCoreAsync().ConfigureAwait(false); - lock (_lock) + lock (_lock) + { + _monitoredVariables.Clear(); + _opcMonitoredItems.Clear(); + _opcHandleToClientHandle.Clear(); + } + } + finally { - _monitoredVariables.Clear(); - _opcMonitoredItems.Clear(); - _opcHandleToClientHandle.Clear(); + _mutationGate.Release(); } GC.SuppressFinalize(this); diff --git a/Opcilloscope.csproj b/Opcilloscope.csproj index 961076a..f213c9c 100644 --- a/Opcilloscope.csproj +++ b/Opcilloscope.csproj @@ -28,12 +28,23 @@ true + + true true false + + + true + packages.$(NETCoreSdkPortableRuntimeIdentifier).lock.json + packages.$(RuntimeIdentifier).lock.json @@ -42,8 +53,8 @@ - - + + diff --git a/Program.cs b/Program.cs index f3d22d2..c677370 100644 --- a/Program.cs +++ b/Program.cs @@ -12,67 +12,41 @@ static int Main(string[] args) IApplication? app = null; try { - // Parse command-line arguments before initializing the terminal, so that --help/-h - // prints usage and exits without opening a tty (required for headless/CI environments). - // Note: If multiple arguments of the same type are provided (e.g., two config files), - // the last one specified will be used. - string? autoConnectUrl = null; - string? configPath = null; + CommandLineOptions options; + try + { + options = CommandLineParser.Parse(args); + } + catch (ArgumentException ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + Console.Error.WriteLine("Run 'opcilloscope --help' for usage."); + return 2; + } - for (int i = 0; i < args.Length; i++) + if (options.ShowHelp) { - // Config file options: --config or direct path ending with .cfg/.opcilloscope/.json - if ((args[i] == "--config" || args[i] == "-f") && i + 1 < args.Length) - { - configPath = args[i + 1]; - i++; // Skip the next argument - } - else if (args[i].EndsWith(".cfg", StringComparison.OrdinalIgnoreCase) || - args[i].EndsWith(".opcilloscope", StringComparison.OrdinalIgnoreCase) || - (args[i].EndsWith(".json", StringComparison.OrdinalIgnoreCase) && File.Exists(args[i]))) - { - configPath = args[i]; - } - // Connection URL options: --connect or direct opc.tcp:// URL - // Note: Direct URL connection is not currently implemented; use config files instead. - else if ((args[i] == "--connect" || args[i] == "-c") && i + 1 < args.Length) - { - autoConnectUrl = args[i + 1]; - i++; // Skip the next argument - } - else if (args[i].StartsWith("opc.tcp://")) - { - autoConnectUrl = args[i]; - } - else if (args[i] == "--insecure") - { - // Development-only: accept untrusted server certificates. Threaded to the - // OPC UA client wrapper as the process-wide default (secure-by-default - // otherwise). See OpcUaClientWrapper.AllowInsecureByDefault. - OpcUaClientWrapper.AllowInsecureByDefault = true; - } - else if (args[i] == "--help" || args[i] == "-h") - { - PrintUsage(); - return 0; - } + PrintUsage(); + return 0; } + OpcUaClientWrapper.AllowInsecureByDefault = options.AllowInsecureCertificates; + // Validate the config file path before initializing the terminal, so the error // message is printed to the regular screen rather than being lost in the // alternate screen buffer (same pattern as --help above). - if (!string.IsNullOrEmpty(configPath) && !File.Exists(configPath)) + if (!string.IsNullOrEmpty(options.ConfigPath) && !File.Exists(options.ConfigPath)) { - Console.Error.WriteLine($"Error: Configuration file not found: {configPath}"); + Console.Error.WriteLine($"Error: Configuration file not found: {options.ConfigPath}"); return 1; } // Warn about unimplemented auto-connect before initializing the terminal; // once the alternate screen buffer is active the message would be lost. - if (string.IsNullOrEmpty(configPath) && !string.IsNullOrEmpty(autoConnectUrl)) + if (string.IsNullOrEmpty(options.ConfigPath) && !string.IsNullOrEmpty(options.AutoConnectUrl)) { Console.Error.WriteLine( - $"Warning: Auto-connect via command-line URL ('{autoConnectUrl}') is not currently implemented. " + + $"Warning: Auto-connect via command-line URL ('{options.AutoConnectUrl}') is not currently implemented. " + "Please use a configuration file with an endpoint URL instead."); } @@ -84,15 +58,19 @@ static int Main(string[] args) try { // Load config file if specified (takes precedence over URL) - if (!string.IsNullOrEmpty(configPath)) + if (!string.IsNullOrEmpty(options.ConfigPath)) { - mainWindow.LoadConfigFromCommandLine(configPath); + mainWindow.LoadConfigFromCommandLine(options.ConfigPath); } app.Run(mainWindow); } finally { + // Cancel awaited UI dispatches before disposing the window. Once + // Run returns there will be no further main-loop iteration to + // execute callbacks queued by late network continuations. + TerminalUi.BeginShutdown(); mainWindow.Dispose(); } } @@ -121,7 +99,8 @@ private static void PrintUsage() Console.WriteLine(); Console.WriteLine("Options:"); Console.WriteLine(" -f, --config Load configuration file (.cfg, .opcilloscope, or .json)"); - Console.WriteLine(" --insecure Accept untrusted server certificates (development only)"); + Console.WriteLine(" -c, --connect Reserved; direct URL connection is not yet implemented"); + Console.WriteLine(" --insecure Disable server certificate validation (development only)"); Console.WriteLine(" -h, --help Show this help message"); Console.WriteLine(); Console.WriteLine("Note: Direct server connection via --connect or opc.tcp:// URLs is not yet"); diff --git a/README.md b/README.md index 84bba4a..39d1b4f 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ Browse, monitor, and subscribe to industrial automation data right from your ter | Traditional OPC Clients | opcilloscope | |------------------------|--------------| -| Heavy desktop apps | Single portable binary | +| Heavy desktop apps | Single self-contained executable | | Minutes to install | `curl \| bash` and you're running | -| Resource-hungry GUIs | ~40 MB RAM | -| Windows-only | Windows, Linux, macOS (x64 & ARM) | +| Resource-hungry GUIs | Terminal-native interface | +| Windows-only | Windows, Linux, macOS (x64 & ARM64) | | Click-heavy workflows | Keyboard-driven, mouse support | **Use cases:** commissioning (verify PLC tags), troubleshooting (live values during fault diagnosis), integration testing (validate OPC UA server configs), recording (export to CSV for reports). @@ -27,16 +27,16 @@ Browse, monitor, and subscribe to industrial automation data right from your ter ## Features - **Browse** — Lazily explore the OPC UA address space. Expand only what you need. -- **Monitor** — Subscribe to variables with `Enter`. Real-time updates via OPC UA pub/sub, not polling. +- **Monitor** — Subscribe to variables with `Enter`. Receive OPC UA monitored-item notifications instead of polling values. - **Inspect** — Full node attributes: Description, DataType, AccessLevel, ValueRank. - **Scope** — Real-time multi-signal oscilloscope (up to 5 signals, 30 s sliding window). -- **Record** — Export monitored values to CSV. Zero data loss — every server-pushed sample is captured at full precision in a locale-independent format (ISO 8601 timestamps, `.` decimal separator, arrays as semicolon-joined elements). +- **Record** — Export selected monitored variables to CSV. Notifications are queued with full-precision, locale-independent values (ISO 8601 UTC timestamps, `.` decimal separator, arrays as semicolon-joined elements). A bounded queue protects the UI if storage falls behind, and any dropped records are reported when recording stops. - **Configure** — Save/load connection and subscription configs (`.cfg` JSON files). - **Themes** — Dark (default), light, and terminal (inherits your terminal's ANSI colour scheme).

- Dark theme - Light theme + Dark theme + Light theme

@@ -47,26 +47,28 @@ Browse, monitor, and subscribe to industrial automation data right from your ter

How signal sampling works -opcilloscope does **not** poll your OPC UA server. The server *pushes* value updates using OPC UA's built-in publish/subscribe mechanism. +opcilloscope does **not** repeatedly read each value. It creates OPC UA subscriptions and monitored items, then processes the data-change notifications delivered by the server. This is distinct from the OPC UA PubSub transport model. ``` OPC UA Server - │ pushes values every 250ms (configurable 100ms–10s) + │ samples monitored items (250 ms requested by default) + │ delivers subscription notifications (250 ms publishing interval by default) ▼ -opcilloscope receives value change events - ├─→ Scope View — stores every sample (up to 2,000 per signal) - └─→ CSV Recording — writes every sample to disk (zero data loss) +opcilloscope receives data-change notifications + ├─→ Scope View — retains up to 2,000 numeric samples per signal while open + └─→ CSV Recording — queues selected-variable notifications for background writes ``` Data capture and screen rendering are decoupled: | What | Rate | Details | |------|------|---------| -| Server → Client updates | ~4 Hz (250 ms) | Default publishing + sampling interval, adjustable in connect dialog | +| Server sampling | 250 ms requested | Configurable through a saved configuration; the server may revise the interval | +| Subscription publishing | 250 ms by default | Adjustable from 100 ms to 10 s in the connect dialog | | Scope redraw | 10 FPS (100 ms) | Renders whatever samples arrived since last frame | -| CSV recording | Every update | Captures 100% of server-pushed values, flushes every 10 records | +| CSV recording | Every accepted notification for selected variables | Uses a 10,000-record bounded queue and flushes every 10 records | -The scope view holds a sliding **30-second window** (zoomable 5 s – 300 s). Display resolution is limited by terminal width — each character cell is one data point. +The scope view starts with a sliding **30-second window** (zoomable from 5 s to 300 s). It draws with Unicode braille subcells, so display resolution depends on the terminal's dimensions.
@@ -81,7 +83,7 @@ The scope view holds a sliding **30-second window** (zoomable 5 s – 300 s). Di | `Space` | Toggle selection / pause scope | | `S` | Open scope with selected variables | | `W` | Write value to node | -| `R` | Toggle CSV recording (monitored variables) | +| `R` | Toggle CSV recording (selected monitored variables) | | `+` / `-` | Zoom in / out (scope) | | `Ctrl+O` / `Ctrl+S` | Open / save configuration | | `Ctrl+R` | Toggle CSV recording | @@ -101,6 +103,10 @@ irm https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/instal Or grab a binary from [GitHub Releases](https://github.com/SquareWaveSystems/opcilloscope/releases). +Release archives include the self-contained executable, the project license, and notices for bundled third-party components. The installers preserve those notices in an app-owned license directory. + +The installers require the matching release checksum and refuse an unverified download. On Windows, the default install directory is added to your user `PATH`; a custom `OPCILLOSCOPE_INSTALL_DIR` is treated as shared and leaves `PATH` unchanged. + > **macOS note:** the binaries are unsigned, so archives downloaded with a browser are > quarantined by Gatekeeper. Either use the curl installer above, or clear the > quarantine attribute after extracting: `xattr -d com.apple.quarantine `. @@ -110,6 +116,18 @@ Then run: opcilloscope ``` +An automatic/omitted or partial security profile requires a `SignAndEncrypt` +endpoint and selects the strongest matching candidate offered by the server. +Explicit `securityMode: "Sign"` opts into signed-but-unencrypted traffic. +Explicit anonymous `securityMode: "None"` opts into unsecured plaintext; +username authentication never permits `None`. + +Server certificates that fail validation are rejected by default. For a +development server, `opcilloscope --insecure` disables server certificate +validation for that run; it does not enable plaintext transport. Do not use +this option in production. The connection log reports the trusted-certificate +store path when validation fails. +
Uninstall @@ -118,11 +136,21 @@ opcilloscope curl -fsSL https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/uninstall.sh | bash ``` -Or manually: +Or manually on Linux: +```bash +rm ~/.local/bin/opcilloscope +rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/opcilloscope/licenses" +rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/opcilloscope" # optional: configs and recent files +rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/opcilloscope/pki" # optional: certificates +``` + +Or manually on macOS: ```bash rm ~/.local/bin/opcilloscope -rm -rf ~/.config/opcilloscope/ # optional: remove config files -rm -rf ~/.local/share/opcilloscope/ # optional: remove OPC UA certificates +rm -rf "$HOME/Library/Application Support/opcilloscope/licenses" +rm -rf "$HOME/Library/Application Support/opcilloscope/configs" # optional +rm -f "$HOME/Library/Application Support/opcilloscope/recent-files.json" # optional +rm -rf "$HOME/Library/Application Support/opcilloscope/pki" # optional: certificates ``` **Windows (PowerShell):** @@ -132,12 +160,23 @@ irm https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/uninst Or manually: ```powershell -Remove-Item "$env:LOCALAPPDATA\Opcilloscope" -Recurse -Force # binary -Remove-Item "$env:LOCALAPPDATA\opcilloscope" -Recurse -Force # OPC UA certificates -Remove-Item "$env:APPDATA\opcilloscope" -Recurse -Force # config files +$installDir = "$env:LOCALAPPDATA\Programs\opcilloscope" +Remove-Item "$installDir\opcilloscope.exe" -Force +Remove-Item "$installDir\opcilloscope-licenses" -Recurse -Force +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +$pathEntries = @($userPath -split ";" | Where-Object { $_.Trim().TrimEnd('\') -ine $installDir.TrimEnd('\') }) +[Environment]::SetEnvironmentVariable("Path", ($pathEntries -join ";"), "User") +Remove-Item "$env:APPDATA\opcilloscope" -Recurse -Force # optional: configs and recent files +Remove-Item "$env:LOCALAPPDATA\opcilloscope\pki" -Recurse -Force # optional: certificates ``` -If you installed to a custom directory (`OPCILLOSCOPE_INSTALL_DIR`), replace the paths above with your custom install location. +If you set `OPCILLOSCOPE_INSTALL_DIR`, replace only the executable path above on +Linux/macOS; license notices remain in the platform data directory shown above. +On Windows, replace both `$installDir` executable/license paths with the custom +directory and skip the `PATH`-removal lines; the installer never adds a custom +directory to `PATH`. Configuration and certificate locations do not move. The +uninstall scripts remove only files owned by opcilloscope rather than recursively +deleting a shared custom install directory.
@@ -148,21 +187,30 @@ Requires [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0). ```bash git clone https://github.com/SquareWaveSystems/opcilloscope.git cd opcilloscope -dotnet build -dotnet run +dotnet build Opcilloscope.sln +dotnet run --project Opcilloscope.csproj ``` -Run tests: +Run the cross-platform unit, integration, and component suite: ```bash -dotnet test +dotnet test Opcilloscope.sln ``` +On Linux, also exercise a freshly published binary through the real PTY E2E +harness: + +```bash +dotnet test Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj +``` + +See [docs/TESTING.md](docs/TESTING.md) for test layers and exact-artifact usage. + ## OPC UA Test Servers **Built-in test server** (Counter, SineWave, RandomValue, writable nodes): ```bash dotnet run --project Tests/Opcilloscope.TestServer -# Starts at opc.tcp://localhost:4840 +# Starts at opc.tcp://localhost:4840/UA/OpcilloscopeTest ``` **Public servers** (no setup required): @@ -181,7 +229,7 @@ docker run -p 50000:50000 mcr.microsoft.com/iotedge/opc-plc:latest \ ## Contributing -Contributions welcome! Please submit an issue or a pull request. +Contributions welcome! Please submit an issue or a pull request, and see [CONTRIBUTING.md](CONTRIBUTING.md) for development and review guidance. ## License @@ -190,7 +238,7 @@ The opcilloscope **source code** is MIT-licensed — see [LICENSE](LICENSE). Official **binary releases** are self-contained builds that bundle the [OPC Foundation UA .NET Standard](https://github.com/OPCFoundation/UA-.NETStandard) stack and other third-party components. The bundled stack version -(1.5.378.65) is distributed by the OPC Foundation under its MIT license; +(1.5.378.156) is distributed by the OPC Foundation under its MIT license; earlier versions of that stack were dual-licensed GPL-2.0/RCL. See [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) for the full list of bundled components and their licenses. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 57d9403..61f5f33 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -1,220 +1,105 @@ # Third-Party Notices -opcilloscope's own source code is licensed under the MIT License (see -[LICENSE](LICENSE)). - -Official binary releases of opcilloscope are published as self-contained, -single-file builds. These binaries statically bundle the third-party -components listed below, together with the Microsoft .NET runtime. This file -lists those components, their versions, upstream projects, and licenses. - -Packages that are used only at build time (MinVer, Microsoft.SourceLink.*, -Microsoft.Build.Tasks.Git, Microsoft.NET.ILLink.Tasks, -Microsoft.CodeAnalysis.Analyzers) contribute no code to the published -binaries and are not listed individually. - -## Summary of bundled components - -| Package | Version | Upstream | License (SPDX) | -|---------|---------|----------|----------------| -| OPCFoundation.NetStandard.Opc.Ua.Client | 1.5.378.65 | https://github.com/OPCFoundation/UA-.NETStandard | MIT (see notes below) | -| OPCFoundation.NetStandard.Opc.Ua.Configuration | 1.5.378.65 | https://github.com/OPCFoundation/UA-.NETStandard | MIT (see notes below) | -| OPCFoundation.NetStandard.Opc.Ua.Core | 1.5.378.65 | https://github.com/OPCFoundation/UA-.NETStandard | MIT (see notes below) | -| OPCFoundation.NetStandard.Opc.Ua.Security.Certificates | 1.5.378.65 | https://github.com/OPCFoundation/UA-.NETStandard | MIT (see notes below) | -| OPCFoundation.NetStandard.Opc.Ua.Types | 1.5.378.65 | https://github.com/OPCFoundation/UA-.NETStandard | MIT (see notes below) | -| Terminal.Gui | 2.0.0 | https://github.com/gui-cs/Terminal.Gui | MIT | -| BitFaster.Caching | 2.5.4 | https://github.com/bitfaster/BitFaster.Caching | MIT | -| ColorHelper | 1.8.1 | https://github.com/iamartyom/ColorHelper | MIT | -| Humanizer.Core | 2.14.1 | https://github.com/Humanizr/Humanizer | MIT | -| JetBrains.Annotations | 2024.2.0 | https://github.com/JetBrains/JetBrains.Annotations | MIT | -| Microsoft.Bcl.AsyncInterfaces | 8.0.0 | https://github.com/dotnet/runtime | MIT | -| Microsoft.CodeAnalysis.Common | 4.10.0 | https://github.com/dotnet/roslyn | MIT | -| Microsoft.CodeAnalysis.CSharp | 4.10.0 | https://github.com/dotnet/roslyn | MIT | -| Microsoft.CodeAnalysis.CSharp.Workspaces | 4.10.0 | https://github.com/dotnet/roslyn | MIT | -| Microsoft.CodeAnalysis.VisualBasic | 4.10.0 | https://github.com/dotnet/roslyn | MIT | -| Microsoft.CodeAnalysis.VisualBasic.Workspaces | 4.10.0 | https://github.com/dotnet/roslyn | MIT | -| Microsoft.CodeAnalysis.Workspaces.Common | 4.10.0 | https://github.com/dotnet/roslyn | MIT | -| Microsoft.Extensions.DependencyInjection | 10.0.1 | https://github.com/dotnet/runtime | MIT | -| Microsoft.Extensions.DependencyInjection.Abstractions | 10.0.1 | https://github.com/dotnet/runtime | MIT | -| Microsoft.Extensions.Logging | 10.0.1 | https://github.com/dotnet/runtime | MIT | -| Microsoft.Extensions.Logging.Abstractions | 10.0.1 | https://github.com/dotnet/runtime | MIT | -| Microsoft.Extensions.Options | 10.0.1 | https://github.com/dotnet/runtime | MIT | -| Microsoft.Extensions.Primitives | 10.0.1 | https://github.com/dotnet/runtime | MIT | -| Newtonsoft.Json | 13.0.4 | https://www.newtonsoft.com/json | MIT | -| System.Composition.AttributedModel | 8.0.0 | https://github.com/dotnet/runtime | MIT | -| System.Composition.Convention | 8.0.0 | https://github.com/dotnet/runtime | MIT | -| System.Composition.Hosting | 8.0.0 | https://github.com/dotnet/runtime | MIT | -| System.Composition.Runtime | 8.0.0 | https://github.com/dotnet/runtime | MIT | -| System.Composition.TypedParts | 8.0.0 | https://github.com/dotnet/runtime | MIT | -| System.IO.Abstractions | 21.0.22 | https://github.com/TestableIO/System.IO.Abstractions | MIT | -| System.Text.Json | 8.0.5 | https://github.com/dotnet/runtime | MIT | -| TestableIO.System.IO.Abstractions | 21.0.22 | https://github.com/TestableIO/System.IO.Abstractions | MIT | -| TestableIO.System.IO.Abstractions.Wrappers | 21.0.22 | https://github.com/TestableIO/System.IO.Abstractions | MIT | -| Wcwidth | 2.0.0 | https://github.com/spectreconsole/wcwidth | MIT | -| Microsoft .NET Runtime (self-contained) | 10.0.x | https://github.com/dotnet/runtime | MIT | - -Direct NuGet dependencies of opcilloscope are `Terminal.Gui` and -`OPCFoundation.NetStandard.Opc.Ua.Client`; the remaining packages are -transitive dependencies resolved at restore time. - ---- - -## OPC Foundation UA .NET Standard stack - -Applies to: `OPCFoundation.NetStandard.Opc.Ua.Client`, -`OPCFoundation.NetStandard.Opc.Ua.Configuration`, -`OPCFoundation.NetStandard.Opc.Ua.Core`, -`OPCFoundation.NetStandard.Opc.Ua.Security.Certificates`, -`OPCFoundation.NetStandard.Opc.Ua.Types` (all version 1.5.378.65). - -Copyright (c) 2004-2025 OPC Foundation, Inc. - -Upstream project: https://github.com/OPCFoundation/UA-.NETStandard - -### Licensing of the bundled version (1.5.378.65) - -Version 1.5.378.65 of the UA .NET Standard stack — the version bundled in -opcilloscope binary releases — is distributed by the OPC Foundation under the -OPC Foundation MIT License 1.00 (SPDX: MIT). This is declared in each NuGet -package's license metadata (`MIT`) and -in the `LICENSE.txt` file shipped inside each package. Release 1.5.378.65 is -the release in which the OPC Foundation changed the project's licensing to -MIT. The full license text is also published at -https://opcfoundation.org/license/mit.html. - -> MIT License -> -> OPC Foundation MIT License 1.00 -> -> Copyright (c) 2005-2025 OPC Foundation, Inc. Permission is hereby granted, -> free of charge, to any person obtaining a copy of this software and -> associated documentation files (the "Software"), to deal in the Software -> without restriction, including without limitation the rights to use, copy, -> modify, merge, publish, distribute, sublicense, and/or sell copies of the -> Software, and to permit persons to whom the Software is furnished to do so, -> subject to the following conditions: -> -> The above copyright notice and this permission notice shall be included in -> all copies or substantial portions of the Software. -> -> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -> DEALINGS IN THE SOFTWARE. - -### Dual GPL-2.0/RCL licensing of earlier versions - -Releases of the UA .NET Standard stack prior to 1.5.378.65 were dual-licensed -by the OPC Foundation: - -- For OPC Foundation Corporate Members: the Reciprocal Community License - ("RCL", no SPDX identifier; published by the OPC Foundation), available at - https://opcfoundation.org/license/rcl.html. -- For everyone else: the GNU General Public License, version 2.0 (SPDX: - GPL-2.0-only), available at https://opcfoundation.org/license/gpl.html. - -For distributions that include one of those earlier, GPL-2.0-licensed -versions, the standard GPLv2 notice applies: - -> This program is free software; you can redistribute it and/or modify it -> under the terms of the GNU General Public License as published by the Free -> Software Foundation; version 2 of the License. -> -> This program is distributed in the hope that it will be useful, but WITHOUT -> ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -> FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -> more details. -> -> You should have received a copy of the GNU General Public License along -> with this program; if not, write to the Free Software Foundation, Inc., -> 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -Full license texts: https://opcfoundation.org/license/gpl.html (GPL-2.0), -https://opcfoundation.org/license/rcl.html (RCL), and the `LICENSE.txt` file -included in each `OPCFoundation.NetStandard.Opc.Ua.*` NuGet package for the -version actually in use. - ---- - -## Terminal.Gui - -Applies to: `Terminal.Gui` 2.0.0. - -Upstream project: https://github.com/gui-cs/Terminal.Gui - -License: MIT. - -> Copyright 2007-2011 Novell Inc -> Copyright 2017 Microsoft Corp -> -> Permission is hereby granted, free of charge, to any person obtaining a -> copy of this software and associated documentation files (the "Software"), -> to deal in the Software without restriction, including without limitation -> the rights to use, copy, modify, merge, publish, distribute, sublicense, -> and/or sell copies of the Software, and to permit persons to whom the -> Software is furnished to do so, subject to the following conditions: -> -> The above copyright notice and this permission notice shall be included in -> all copies or substantial portions of the Software. -> -> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -> DEALINGS IN THE SOFTWARE. - ---- +opcilloscope's own source code is licensed under the MIT License; see +[LICENSE](LICENSE). + +Official releases are self-contained .NET applications. The executable +bundles the managed dependencies below and embeds the native Oniguruma runtime +asset for extraction when needed. Release archives include this inventory, +the exact notices under [`licenses/`](licenses/), and the exact .NET runtime +and Microsoft.Extensions notices copied from the packages used for that RID. + +The authoritative resolved graphs are the checked-in `packages..lock.json` +files for all six supported release targets. CI verifies that their package +versions match [`licenses/THIRD-PARTY-PACKAGES.tsv`](licenses/THIRD-PARTY-PACKAGES.tsv). + +## Bundled package inventory + +| Package | Version | License | +|---------|---------|---------| +| OPCFoundation.NetStandard.Opc.Ua.Client | 1.5.378.156 | OPC Foundation MIT 1.00 | +| OPCFoundation.NetStandard.Opc.Ua.Configuration | 1.5.378.156 | OPC Foundation MIT 1.00 | +| OPCFoundation.NetStandard.Opc.Ua.Core | 1.5.378.156 | OPC Foundation MIT 1.00 | +| OPCFoundation.NetStandard.Opc.Ua.Security.Certificates | 1.5.378.156 | OPC Foundation MIT 1.00 | +| OPCFoundation.NetStandard.Opc.Ua.Types | 1.5.378.156 | OPC Foundation MIT 1.00 | +| Terminal.Gui | 2.4.5 | MIT | +| BitFaster.Caching | 2.6.0 | MIT | +| ColorHelper | 1.8.1 | MIT | +| JetBrains.Annotations | 2025.2.4 | MIT | +| Markdig | 1.1.3 | BSD-2-Clause | +| Microsoft.Extensions.DependencyInjection | 10.0.8 | MIT | +| Microsoft.Extensions.DependencyInjection.Abstractions | 10.0.8 | MIT | +| Microsoft.Extensions.Logging | 10.0.8 | MIT | +| Microsoft.Extensions.Logging.Abstractions | 10.0.8 | MIT | +| Microsoft.Extensions.Options | 10.0.8 | MIT | +| Microsoft.Extensions.Primitives | 10.0.8 | MIT | +| Newtonsoft.Json | 13.0.4 | MIT | +| Onigwrap | 1.0.11 | MIT; bundled Oniguruma uses a BSD-style license | +| System.IO.Abstractions | 22.1.1 | MIT | +| TestableIO.System.IO.Abstractions | 22.1.1 | MIT | +| TestableIO.System.IO.Abstractions.Wrappers | 22.1.1 | MIT | +| Testably.Abstractions.FileSystem.Interface | 10.1.0 | MIT | +| TextMateSharp | 2.0.4 | MIT | +| TextMateSharp.Grammars | 2.0.4 | MIT | +| Wcwidth | 4.0.1 | MIT | +| Microsoft .NET Runtime | resolved from the .NET 10 SDK for each RID | MIT plus packaged third-party notices | + +`MinVer` 7.0.0 (Apache-2.0) and `Microsoft.NET.ILLink.Tasks` 10.0.9 +(MIT) are build-only tools and contribute no code to published binaries. + +## Exact notices carried with releases + +- [OPC Foundation MIT License 1.00](licenses/OPC-FOUNDATION-LICENSE.txt) +- [Markdig BSD-2-Clause License](licenses/MARKDIG-LICENSE.txt) +- [Onigwrap third-party notices](licenses/ONIGWRAP-THIRD-PARTY-NOTICES.txt), + including the required native Oniguruma notice +- `DOTNET-RUNTIME--LICENSE.txt` and + `DOTNET-RUNTIME--THIRD-PARTY-NOTICES.txt`, copied from the exact + self-contained runtime pack during release publishing +- `MICROSOFT-EXTENSIONS-THIRD-PARTY-NOTICES.txt`, copied from the exact + resolved Microsoft.Extensions package after verifying that the six bundled + packages carry identical notice content + +See [notice sources and maintenance](licenses/NOTICE-SOURCES.md) for the +validation and regeneration procedure. ## Other MIT-licensed components -The following bundled components are licensed under the MIT License. The -standard MIT License text is reproduced once below the copyright notices. - -- BitFaster.Caching 2.5.4 — Copyright (c) 2020 Alex Peck — - https://github.com/bitfaster/BitFaster.Caching -- ColorHelper 1.8.1 — Copyright (c) Artyom Tonoyan — - https://github.com/iamartyom/ColorHelper -- Humanizer.Core 2.14.1 — Copyright (c) .NET Foundation and Contributors — - https://github.com/Humanizr/Humanizer -- JetBrains.Annotations 2024.2.0 — Copyright (c) 2016-2024 JetBrains s.r.o. — - https://github.com/JetBrains/JetBrains.Annotations -- Microsoft.Bcl.AsyncInterfaces 8.0.0, Microsoft.Extensions.* 10.0.1, - System.Composition.* 8.0.0, System.Text.Json 8.0.5, and the Microsoft .NET - Runtime — Copyright (c) .NET Foundation and Contributors / Microsoft - Corporation — https://github.com/dotnet/runtime -- Microsoft.CodeAnalysis.* (Roslyn) 4.10.0 — Copyright (c) .NET Foundation - and Contributors / Microsoft Corporation — - https://github.com/dotnet/roslyn -- Newtonsoft.Json 13.0.4 — Copyright (c) 2007 James Newton-King — - https://www.newtonsoft.com/json -- System.IO.Abstractions / TestableIO.System.IO.Abstractions / - TestableIO.System.IO.Abstractions.Wrappers 21.0.22 — Copyright (c) Tatham - Oddie & friends 2010-2024 — - https://github.com/TestableIO/System.IO.Abstractions -- Wcwidth 2.0.0 — Copyright (c) Patrik Svensson and contributors — - https://github.com/spectreconsole/wcwidth - -> MIT License -> -> Permission is hereby granted, free of charge, to any person obtaining a -> copy of this software and associated documentation files (the "Software"), -> to deal in the Software without restriction, including without limitation -> the rights to use, copy, modify, merge, publish, distribute, sublicense, -> and/or sell copies of the Software, and to permit persons to whom the -> Software is furnished to do so, subject to the following conditions: -> -> The above copyright notice and this permission notice shall be included in -> all copies or substantial portions of the Software. -> -> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -> DEALINGS IN THE SOFTWARE. +The following copyright notices apply to the remaining MIT components: + +- Terminal.Gui — Copyright 2007-2011 Novell Inc; Copyright 2017 Microsoft Corp +- BitFaster.Caching — Copyright (c) 2020 Alex Peck +- ColorHelper — Copyright (c) 2020 Artyom Gritsuk +- JetBrains.Annotations — Copyright (c) 2016-2024 JetBrains s.r.o. +- Microsoft.Extensions.* and the Microsoft .NET Runtime: + - Copyright (c) .NET Foundation and Contributors + - All rights reserved. +- Newtonsoft.Json — Copyright (c) 2007 James Newton-King +- Onigwrap — Copyright (c) 2024 Aikawa Yataro; its packaged historical and + third-party notices are reproduced separately +- System.IO.Abstractions / TestableIO.System.IO.Abstractions*: + - Copyright (c) Tatham Oddie and Contributors + - All rights reserved. +- Testably.Abstractions.FileSystem.Interface — Copyright (c) 2022 Valentin Breuß +- TextMateSharp / TextMateSharp.Grammars — Copyright (c) 2021 Daniel Peñalba +- Wcwidth — Copyright Patrik Svensson. Phil Scott + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj b/Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj new file mode 100644 index 0000000..c1d0ecb --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + true + true + true + + + + + + + + + + + + + diff --git a/Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs b/Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs new file mode 100644 index 0000000..2f7f7d2 --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs @@ -0,0 +1,175 @@ +using System.Diagnostics; +using System.Text; + +namespace Opcilloscope.E2ETests; + +/// +/// Runs opcilloscope over a real PTY and reconstructs its rendered terminal screen. +/// +public sealed class OpcilloscopeSession : IDisposable +{ + private readonly Pty _pty; + private readonly VtScreen _screen; + private readonly Thread _reader; + private readonly object _gate = new(); + private volatile bool _stop; + private string _queryPending = string.Empty; + private volatile Exception? _readerFailure; + + public OpcilloscopeSession( + string binaryPath, + IReadOnlyList? arguments = null, + int rows = 30, + int cols = 100) + { + Rows = rows; + Cols = cols; + _screen = new VtScreen(rows, cols); + _pty = Pty.Spawn(binaryPath, arguments ?? Array.Empty(), rows, cols); + _reader = new Thread(ReadLoop) + { + IsBackground = true, + Name = "opcilloscope-e2e-pty-reader", + }; + _reader.Start(); + } + + public int Rows { get; } + + public int Cols { get; } + + public bool HasExited => _pty.HasExited; + + public int? ExitCode => _pty.ExitCode; + + public string Snapshot() + { + lock (_gate) + { + return _screen.Text(); + } + } + + public bool WaitForText(string text, TimeSpan timeout) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < timeout) + { + ThrowIfReaderFailed(); + if (Snapshot().Contains(text, StringComparison.Ordinal)) + { + return true; + } + + if (_pty.HasExited) + { + break; + } + + Thread.Sleep(40); + } + + ThrowIfReaderFailed(); + return Snapshot().Contains(text, StringComparison.Ordinal); + } + + public bool WaitForExit(TimeSpan timeout) => _pty.WaitForExit(timeout); + + public void Send(string input) => _pty.Write(Encoding.UTF8.GetBytes(input)); + + public void SendByte(byte input) => _pty.Write([input]); + + public void Dispose() + { + _stop = true; + _pty.Dispose(); + _reader.Join(TimeSpan.FromSeconds(2)); + } + + private void ReadLoop() + { + try + { + var decoder = Encoding.UTF8.GetDecoder(); + var bytes = new byte[8192]; + var chars = new char[Encoding.UTF8.GetMaxCharCount(bytes.Length)]; + + while (!_stop) + { + var byteCount = _pty.Read(bytes); + if (byteCount <= 0) + { + break; + } + + var charCount = decoder.GetChars(bytes, 0, byteCount, chars, 0); + var chunk = new string(chars, 0, charCount); + lock (_gate) + { + _screen.Feed(chunk); + RespondToQueries(chunk); + } + } + } + catch (Exception exception) when (_stop && exception is ObjectDisposedException or IOException) + { + // Expected when Dispose closes the PTY to release a blocking read. + } + catch (Exception exception) + { + _readerFailure = exception; + } + } + + private void RespondToQueries(string chunk) + { + _queryPending += chunk; + + while (TakeQuery("\x1b[18t")) + { + Reply($"\x1b[8;{Rows};{Cols}t"); + } + + while (TakeQuery("\x1b[6n")) + { + Reply("\x1b[1;1R"); + } + + while (TakeQuery("\x1b]10;?")) + { + Reply("\x1b]10;rgb:cccc/cccc/cccc\x1b\\"); + } + + while (TakeQuery("\x1b]11;?")) + { + Reply("\x1b]11;rgb:0000/0000/0000\x1b\\"); + } + + if (_queryPending.Length > 4096) + { + _queryPending = _queryPending[^1024..]; + } + } + + private bool TakeQuery(string marker) + { + var index = _queryPending.IndexOf(marker, StringComparison.Ordinal); + if (index < 0) + { + return false; + } + + _queryPending = _queryPending.Remove(index, marker.Length); + return true; + } + + private void Reply(string value) => _pty.Write(Encoding.ASCII.GetBytes(value)); + + private void ThrowIfReaderFailed() + { + if (_readerFailure is { } failure) + { + throw new InvalidOperationException("The PTY reader failed.", failure); + } + } +} diff --git a/Tests/Opcilloscope.E2ETests/Pty.cs b/Tests/Opcilloscope.E2ETests/Pty.cs new file mode 100644 index 0000000..8620d7c --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/Pty.cs @@ -0,0 +1,462 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Opcilloscope.E2ETests; + +/// +/// Linux pseudo-terminal process host backed directly by libc. +/// +public sealed class Pty : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + private struct WinSize + { + public ushort Rows; + public ushort Cols; + public ushort XPixel; + public ushort YPixel; + } + + [DllImport("libc", SetLastError = true)] + private static extern int openpty( + out int master, + out int slave, + IntPtr name, + IntPtr termios, + ref WinSize windowSize); + + [DllImport("libc", CharSet = CharSet.Ansi)] + private static extern int posix_spawn( + out int processId, + string path, + IntPtr fileActions, + IntPtr attributes, + string?[] arguments, + string?[] environment); + + [DllImport("libc")] + private static extern int posix_spawn_file_actions_init(IntPtr fileActions); + + [DllImport("libc")] + private static extern int posix_spawn_file_actions_adddup2(IntPtr fileActions, int descriptor, int newDescriptor); + + [DllImport("libc")] + private static extern int posix_spawn_file_actions_addclose(IntPtr fileActions, int descriptor); + + [DllImport("libc")] + private static extern int posix_spawn_file_actions_destroy(IntPtr fileActions); + + [DllImport("libc")] + private static extern int posix_spawnattr_init(IntPtr attributes); + + [DllImport("libc")] + private static extern int posix_spawnattr_setflags(IntPtr attributes, short flags); + + [DllImport("libc")] + private static extern int posix_spawnattr_destroy(IntPtr attributes); + + [DllImport("libc", SetLastError = true)] + private static extern nint read(int descriptor, [Out] byte[] buffer, nuint count); + + [DllImport("libc", SetLastError = true)] + private static extern unsafe nint write(int descriptor, byte* buffer, nuint count); + + [DllImport("libc", EntryPoint = "close", SetLastError = true)] + private static extern int CloseFileDescriptor(int descriptor); + + [DllImport("libc", SetLastError = true)] + private static extern int kill(int processId, int signal); + + [DllImport("libc", SetLastError = true)] + private static extern int waitpid(int processId, out int status, int options); + + private const int OpaqueStructureSize = 1024; + private const short PosixSpawnSetSession = 0x80; + private const int SignalKill = 9; + private const int SignalTerminate = 15; + private const int WaitNoHang = 1; + private const int ErrorInterrupted = 4; + private const int ErrorNoSuchProcess = 3; + private const int ErrorNoChild = 10; + private const int ErrorIo = 5; + + private readonly object _processGate = new(); + private readonly object _writeGate = new(); + private readonly int _master; + private int _processId; + private int? _exitCode; + private volatile bool _disposed; + + private Pty(int master, int processId, int rows, int cols) + { + _master = master; + _processId = processId; + Rows = rows; + Cols = cols; + } + + public int Rows { get; } + + public int Cols { get; } + + public bool HasExited + { + get + { + lock (_processGate) + { + return TryReapNoHang(); + } + } + } + + public int? ExitCode + { + get + { + _ = HasExited; + lock (_processGate) + { + return _exitCode; + } + } + } + + public static Pty Spawn( + string path, + IReadOnlyList arguments, + int rows, + int cols, + IReadOnlyDictionary? extraEnvironment = null) + { + if (!OperatingSystem.IsLinux()) + { + throw new PlatformNotSupportedException("The PTY E2E harness is Linux-only."); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(arguments); + ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(rows, ushort.MaxValue); + ArgumentOutOfRangeException.ThrowIfLessThan(cols, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(cols, ushort.MaxValue); + + var windowSize = new WinSize { Rows = (ushort)rows, Cols = (ushort)cols }; + var master = -1; + var slave = -1; + var fileActions = Marshal.AllocHGlobal(OpaqueStructureSize); + var attributes = Marshal.AllocHGlobal(OpaqueStructureSize); + var fileActionsInitialized = false; + var attributesInitialized = false; + + try + { + ZeroMemory(fileActions); + ZeroMemory(attributes); + + if (openpty(out master, out slave, IntPtr.Zero, IntPtr.Zero, ref windowSize) != 0) + { + throw LibcFailure("openpty"); + } + + CheckPosixResult(posix_spawn_file_actions_init(fileActions), "posix_spawn_file_actions_init"); + fileActionsInitialized = true; + CheckPosixResult(posix_spawn_file_actions_adddup2(fileActions, slave, 0), "dup PTY to stdin"); + CheckPosixResult(posix_spawn_file_actions_adddup2(fileActions, slave, 1), "dup PTY to stdout"); + CheckPosixResult(posix_spawn_file_actions_adddup2(fileActions, slave, 2), "dup PTY to stderr"); + CheckPosixResult(posix_spawn_file_actions_addclose(fileActions, master), "close PTY master in child"); + CheckPosixResult(posix_spawn_file_actions_addclose(fileActions, slave), "close original PTY slave in child"); + + CheckPosixResult(posix_spawnattr_init(attributes), "posix_spawnattr_init"); + attributesInitialized = true; + CheckPosixResult(posix_spawnattr_setflags(attributes, PosixSpawnSetSession), "posix_spawnattr_setflags"); + + var argumentVector = new List { path }; + argumentVector.AddRange(arguments); + argumentVector.Add(null); + + var result = posix_spawn( + out var processId, + path, + fileActions, + attributes, + argumentVector.ToArray(), + BuildEnvironment(extraEnvironment)); + CheckPosixResult(result, $"posix_spawn('{path}')"); + + CloseDescriptorNoThrow(slave); + slave = -1; + + var pty = new Pty(master, processId, rows, cols); + master = -1; + return pty; + } + finally + { + if (fileActionsInitialized) + { + CheckPosixResult(posix_spawn_file_actions_destroy(fileActions), "posix_spawn_file_actions_destroy"); + } + + if (attributesInitialized) + { + CheckPosixResult(posix_spawnattr_destroy(attributes), "posix_spawnattr_destroy"); + } + + Marshal.FreeHGlobal(fileActions); + Marshal.FreeHGlobal(attributes); + CloseDescriptorNoThrow(slave); + CloseDescriptorNoThrow(master); + } + } + + public int Read(byte[] buffer) + { + ArgumentNullException.ThrowIfNull(buffer); + + while (true) + { + var count = read(_master, buffer, (nuint)buffer.Length); + if (count >= 0) + { + return checked((int)count); + } + + var error = Marshal.GetLastPInvokeError(); + if (error == ErrorInterrupted) + { + continue; + } + + if ((_disposed && error != 0) || error == ErrorIo) + { + return 0; + } + + throw LibcFailure("read", error); + } + } + + public unsafe void Write(byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + ObjectDisposedException.ThrowIf(_disposed, this); + + lock (_writeGate) + { + fixed (byte* start = data) + { + var offset = 0; + while (offset < data.Length) + { + var count = write(_master, start + offset, (nuint)(data.Length - offset)); + if (count > 0) + { + offset += checked((int)count); + continue; + } + + if (count == 0) + { + throw new IOException("write returned zero before all PTY input was sent."); + } + + var error = Marshal.GetLastPInvokeError(); + if (error == ErrorInterrupted) + { + continue; + } + + throw LibcFailure("write", error); + } + } + } + } + + public bool WaitForExit(TimeSpan timeout) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < timeout) + { + if (HasExited) + { + return true; + } + + Thread.Sleep(20); + } + + return HasExited; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + int processId; + lock (_processGate) + { + _ = TryReapNoHang(); + processId = _processId; + } + + if (processId != 0) + { + SendSignal(processId, SignalTerminate); + if (!WaitForExit(TimeSpan.FromSeconds(1))) + { + SendSignal(processId, SignalKill); + lock (_processGate) + { + ReapBlocking(); + } + } + } + + CloseDescriptorNoThrow(_master); + } + + private bool TryReapNoHang() + { + if (_processId == 0) + { + return true; + } + + while (true) + { + var result = waitpid(_processId, out var status, WaitNoHang); + if (result == 0) + { + return false; + } + + if (result == _processId) + { + RecordExit(status); + return true; + } + + var error = Marshal.GetLastPInvokeError(); + if (error == ErrorInterrupted) + { + continue; + } + + if (error == ErrorNoChild) + { + _processId = 0; + return true; + } + + throw LibcFailure("waitpid", error); + } + } + + private void ReapBlocking() + { + while (_processId != 0) + { + var result = waitpid(_processId, out var status, 0); + if (result == _processId) + { + RecordExit(status); + return; + } + + var error = Marshal.GetLastPInvokeError(); + if (error == ErrorInterrupted) + { + continue; + } + + if (error == ErrorNoChild) + { + _processId = 0; + return; + } + + throw LibcFailure("waitpid", error); + } + } + + private void RecordExit(int status) + { + var signal = status & 0x7f; + _exitCode = signal == 0 + ? (status >> 8) & 0xff + : 128 + signal; + _processId = 0; + } + + private static void SendSignal(int processId, int signal) + { + if (kill(processId, signal) == 0) + { + return; + } + + var error = Marshal.GetLastPInvokeError(); + if (error != ErrorNoSuchProcess) + { + throw LibcFailure($"kill({processId}, {signal})", error); + } + } + + private static string?[] BuildEnvironment(IReadOnlyDictionary? extraEnvironment) + { + var environment = new Dictionary(StringComparer.Ordinal); + foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + environment[(string)entry.Key] = (string?)entry.Value ?? string.Empty; + } + + environment["TERM"] = "xterm-256color"; + if (extraEnvironment is not null) + { + foreach (var pair in extraEnvironment) + { + environment[pair.Key] = pair.Value; + } + } + + var values = environment.Select(pair => (string?)$"{pair.Key}={pair.Value}").ToList(); + values.Add(null); + return values.ToArray(); + } + + private static void CheckPosixResult(int result, string operation) + { + if (result != 0) + { + throw new InvalidOperationException($"{operation} failed with error {result}."); + } + } + + private static void ZeroMemory(IntPtr pointer) + { + for (var index = 0; index < OpaqueStructureSize; index++) + { + Marshal.WriteByte(pointer, index, 0); + } + } + + private static void CloseDescriptorNoThrow(int descriptor) + { + if (descriptor >= 0) + { + _ = CloseFileDescriptor(descriptor); + } + } + + private static IOException LibcFailure(string operation, int? error = null) + { + var errorNumber = error ?? Marshal.GetLastPInvokeError(); + return new IOException($"{operation} failed with errno {errorNumber}."); + } +} diff --git a/Tests/Opcilloscope.E2ETests/PublishedBinaryFixture.cs b/Tests/Opcilloscope.E2ETests/PublishedBinaryFixture.cs new file mode 100644 index 0000000..24ab434 --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/PublishedBinaryFixture.cs @@ -0,0 +1,151 @@ +using System.Diagnostics; + +namespace Opcilloscope.E2ETests; + +/// +/// Supplies the exact CI artifact or creates a fresh local publish in a temporary directory. +/// +public sealed class PublishedBinaryFixture : IAsyncLifetime +{ + private static readonly TimeSpan PublishTimeout = TimeSpan.FromMinutes(5); + private string? _ownedPublishDirectory; + + public string BinaryPath { get; private set; } = string.Empty; + + public async Task InitializeAsync() + { + if (!OperatingSystem.IsLinux()) + { + throw new PlatformNotSupportedException("Opcilloscope PTY E2E tests run on Linux only."); + } + + var configuredBinary = Environment.GetEnvironmentVariable("OPCILLOSCOPE_BIN"); + if (configuredBinary is not null) + { + if (string.IsNullOrWhiteSpace(configuredBinary) || !File.Exists(configuredBinary)) + { + throw new FileNotFoundException( + "OPCILLOSCOPE_BIN was set, but the published binary does not exist.", + configuredBinary); + } + + BinaryPath = Path.GetFullPath(configuredBinary); + return; + } + + var repositoryRoot = FindRepositoryRoot(); + _ownedPublishDirectory = Path.Combine( + Path.GetTempPath(), + $"opcilloscope-e2e-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_ownedPublishDirectory); + + try + { + await PublishAsync(repositoryRoot, _ownedPublishDirectory); + BinaryPath = Path.Combine(_ownedPublishDirectory, "opcilloscope"); + if (!File.Exists(BinaryPath)) + { + throw new FileNotFoundException("The fresh publish did not produce opcilloscope.", BinaryPath); + } + } + catch + { + await DisposeAsync(); + throw; + } + } + + public async Task DisposeAsync() + { + if (_ownedPublishDirectory is null) + { + return; + } + + var directory = _ownedPublishDirectory; + _ownedPublishDirectory = null; + for (var attempt = 0; attempt < 3; attempt++) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + + return; + } + catch (IOException) when (attempt < 2) + { + await Task.Delay(100); + } + } + } + + private static async Task PublishAsync(string repositoryRoot, string outputDirectory) + { + var startInfo = new ProcessStartInfo("dotnet") + { + WorkingDirectory = repositoryRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add("publish"); + startInfo.ArgumentList.Add(Path.Combine(repositoryRoot, "Opcilloscope.csproj")); + startInfo.ArgumentList.Add("--configuration"); + startInfo.ArgumentList.Add("Release"); + startInfo.ArgumentList.Add("--runtime"); + startInfo.ArgumentList.Add("linux-x64"); + startInfo.ArgumentList.Add("--output"); + startInfo.ArgumentList.Add(outputDirectory); + startInfo.ArgumentList.Add("-p:DebugType=none"); + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start dotnet publish."); + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(PublishTimeout); + + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + + await process.WaitForExitAsync(); + var timedOutOutput = await standardOutput; + var timedOutError = await standardError; + throw new TimeoutException( + $"dotnet publish exceeded {PublishTimeout}.\nstdout:\n{timedOutOutput}\nstderr:\n{timedOutError}"); + } + + var output = await standardOutput; + var error = await standardError; + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"dotnet publish exited with code {process.ExitCode}.\nstdout:\n{output}\nstderr:\n{error}"); + } + } + + private static string FindRepositoryRoot() + { + string? directory = AppContext.BaseDirectory; + while (directory is not null && !File.Exists(Path.Combine(directory, "Opcilloscope.sln"))) + { + directory = Directory.GetParent(directory)?.FullName; + } + + return directory + ?? throw new InvalidOperationException("Could not locate the repository root from the E2E test output."); + } +} + +[CollectionDefinition("E2E", DisableParallelization = true)] +public sealed class E2ECollection : ICollectionFixture; diff --git a/Tests/Opcilloscope.E2ETests/README.md b/Tests/Opcilloscope.E2ETests/README.md new file mode 100644 index 0000000..e6b4142 --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/README.md @@ -0,0 +1,33 @@ +# Opcilloscope black-box E2E tests + +These Linux-only tests launch the published `opcilloscope` binary on a sized pseudo-terminal, +answer Terminal.Gui's terminal-capability queries, reconstruct its VT/ANSI output, and assert +against the rendered screen. They exercise the self-contained publish, native console driver, +real rendering, keyboard input, and clean shutdown. + +The harness is pure .NET plus Linux libc. It does not need Node, Python, `script`, or an external +terminal emulator. + +## Run locally on Linux + +```bash +dotnet test Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj +``` + +Without configuration, the suite creates a fresh `linux-x64` publish in a uniquely named system +temporary directory and removes it when the suite finishes. It never reuses repository-local +publish output. + +To test an existing artifact, set its exact path: + +```bash +OPCILLOSCOPE_BIN="$PWD/publish/opcilloscope" \ + dotnet test Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj +``` + +If `OPCILLOSCOPE_BIN` is set but does not exist, the suite fails instead of silently publishing a +different binary. CI uses this mode so the screen tests exercise the same artifact whose layout it +validated. + +`Opcilloscope.E2ETests` intentionally stays out of `Opcilloscope.sln`; the normal solution remains +portable across Linux, macOS, and Windows. Run this project explicitly only on Linux. diff --git a/Tests/Opcilloscope.E2ETests/StartupTests.cs b/Tests/Opcilloscope.E2ETests/StartupTests.cs new file mode 100644 index 0000000..3ec1dd8 --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/StartupTests.cs @@ -0,0 +1,74 @@ +namespace Opcilloscope.E2ETests; + +/// +/// Black-box tests for the published single-file binary and real Terminal.Gui driver. +/// +[Collection("E2E")] +public sealed class StartupTests +{ + private static readonly TimeSpan RenderTimeout = TimeSpan.FromSeconds(15); + private readonly PublishedBinaryFixture _fixture; + + public StartupTests(PublishedBinaryFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public void Startup_RendersAllPrimaryPanes() + { + using var application = new OpcilloscopeSession(_fixture.BinaryPath); + + foreach (var pane in new[] { "Address Space", "Monitored Variables", "Node Details", "Log" }) + { + Assert.True( + application.WaitForText(pane, RenderTimeout), + $"Missing pane '{pane}'.\n{RenderedScreen(application)}"); + } + } + + [Fact] + public void Startup_RendersMenuAndStatusHints() + { + using var application = new OpcilloscopeSession(_fixture.BinaryPath); + Assert.True(application.WaitForText("Address Space", RenderTimeout), RenderedScreen(application)); + Assert.True(application.WaitForText("Switch", RenderTimeout), RenderedScreen(application)); + + var screen = application.Snapshot(); + Assert.Contains("File", screen); + Assert.Contains("Connection", screen); + Assert.Contains("View", screen); + Assert.Contains("Help", screen); + Assert.Contains("Subscribe", screen); + } + + [Fact] + public void QuestionMark_OpensHelpDialog() + { + using var application = new OpcilloscopeSession(_fixture.BinaryPath); + Assert.True(application.WaitForText("Address Space", RenderTimeout), RenderedScreen(application)); + + application.Send("?"); + + Assert.True( + application.WaitForText("opcilloscope - Help", RenderTimeout), + RenderedScreen(application)); + } + + [Fact] + public void ControlQ_ExitsCleanlyWithSuccess() + { + using var application = new OpcilloscopeSession(_fixture.BinaryPath); + Assert.True(application.WaitForText("Address Space", RenderTimeout), RenderedScreen(application)); + + application.SendByte(0x11); + + Assert.True( + application.WaitForExit(TimeSpan.FromSeconds(5)), + $"Application did not exit after Ctrl+Q.\n{RenderedScreen(application)}"); + Assert.Equal(0, application.ExitCode); + } + + private static string RenderedScreen(OpcilloscopeSession application) => + "Rendered screen was:\n" + application.Snapshot(); +} diff --git a/Tests/Opcilloscope.E2ETests/VtScreen.cs b/Tests/Opcilloscope.E2ETests/VtScreen.cs new file mode 100644 index 0000000..c2317d7 --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/VtScreen.cs @@ -0,0 +1,338 @@ +using System.Text; + +namespace Opcilloscope.E2ETests; + +/// +/// Minimal VT100/ANSI screen used to reconstruct Terminal.Gui output for assertions. +/// +public sealed class VtScreen +{ + private readonly int _rows; + private readonly int _cols; + private readonly char[,] _grid; + private int _row; + private int _col; + private string _pending = string.Empty; + + public VtScreen(int rows, int cols) + { + ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(cols, 1); + + _rows = rows; + _cols = cols; + _grid = new char[rows, cols]; + Clear(); + } + + public void Feed(string input) + { + ArgumentNullException.ThrowIfNull(input); + + input = _pending + input; + _pending = string.Empty; + + var i = 0; + while (i < input.Length) + { + var c = input[i]; + if (c == '\x1b') + { + if (!TryHandleEscape(input, i, out var nextIndex)) + { + _pending = input[i..]; + break; + } + + i = nextIndex; + continue; + } + + switch (c) + { + case '\r': + _col = 0; + break; + case '\n': + NewLine(); + break; + case '\b': + _col = Math.Max(0, _col - 1); + break; + case '\t': + _col = Math.Min(_cols - 1, ((_col / 8) + 1) * 8); + break; + default: + if (c >= ' ') + { + if (_col >= _cols) + { + _col = 0; + NewLine(); + } + + if (InBounds(_row, _col)) + { + _grid[_row, _col] = c; + } + + _col++; + } + break; + } + + i++; + } + } + + public string Text() + { + var output = new StringBuilder(); + for (var row = 0; row < _rows; row++) + { + var line = new StringBuilder(_cols); + for (var col = 0; col < _cols; col++) + { + line.Append(_grid[row, col]); + } + + output.Append(line.ToString().TrimEnd()); + if (row < _rows - 1) + { + output.Append('\n'); + } + } + + return output.ToString(); + } + + private bool TryHandleEscape(string input, int index, out int nextIndex) + { + if (index + 1 >= input.Length) + { + nextIndex = index; + return false; + } + + switch (input[index + 1]) + { + case '[': + return TryHandleCsi(input, index + 2, out nextIndex); + case ']': + return TrySkipOsc(input, index + 2, out nextIndex); + case 'P': + case '^': + case '_': + return TrySkipUntilStringTerminator(input, index + 2, out nextIndex); + case '(': + case ')': + case '*': + case '+': + if (index + 2 >= input.Length) + { + nextIndex = index; + return false; + } + + nextIndex = index + 3; + return true; + default: + nextIndex = index + 2; + return true; + } + } + + private bool TryHandleCsi(string input, int index, out int nextIndex) + { + var start = index; + var isPrivate = index < input.Length && input[index] is '?' or '>' or '!'; + if (isPrivate) + { + index++; + } + + while (index < input.Length && input[index] is not (>= '@' and <= '~')) + { + index++; + } + + if (index >= input.Length) + { + nextIndex = start - 2; + return false; + } + + var final = input[index]; + var bodyStart = start + (isPrivate ? 1 : 0); + var body = input.Substring(bodyStart, index - bodyStart); + var parameters = ParseParameters(body); + + if (!isPrivate) + { + switch (final) + { + case 'H': + case 'f': + _row = Clamp(Parameter(parameters, 0, 1) - 1, _rows); + _col = Clamp(Parameter(parameters, 1, 1) - 1, _cols); + break; + case 'A': + _row = Clamp(_row - Math.Max(1, Parameter(parameters, 0, 1)), _rows); + break; + case 'B': + _row = Clamp(_row + Math.Max(1, Parameter(parameters, 0, 1)), _rows); + break; + case 'C': + _col = Clamp(_col + Math.Max(1, Parameter(parameters, 0, 1)), _cols); + break; + case 'D': + _col = Clamp(_col - Math.Max(1, Parameter(parameters, 0, 1)), _cols); + break; + case 'E': + _col = 0; + _row = Clamp(_row + Math.Max(1, Parameter(parameters, 0, 1)), _rows); + break; + case 'F': + _col = 0; + _row = Clamp(_row - Math.Max(1, Parameter(parameters, 0, 1)), _rows); + break; + case 'G': + _col = Clamp(Parameter(parameters, 0, 1) - 1, _cols); + break; + case 'd': + _row = Clamp(Parameter(parameters, 0, 1) - 1, _rows); + break; + case 'J': + EraseDisplay(Parameter(parameters, 0, 0)); + break; + case 'K': + EraseLine(Parameter(parameters, 0, 0)); + break; + } + } + + nextIndex = index + 1; + return true; + } + + private void EraseDisplay(int mode) + { + if (mode is 2 or 3) + { + Clear(); + return; + } + + if (mode == 0) + { + EraseLine(0); + for (var row = _row + 1; row < _rows; row++) + { + for (var col = 0; col < _cols; col++) + { + _grid[row, col] = ' '; + } + } + } + else if (mode == 1) + { + for (var row = 0; row < _row; row++) + { + for (var col = 0; col < _cols; col++) + { + _grid[row, col] = ' '; + } + } + + EraseLine(1); + } + } + + private void EraseLine(int mode) + { + if (!InBounds(_row, 0)) + { + return; + } + + var from = mode == 0 ? _col : 0; + var to = mode == 1 ? _col : _cols - 1; + for (var col = Math.Max(0, from); col <= Math.Min(_cols - 1, to); col++) + { + _grid[_row, col] = ' '; + } + } + + private void Clear() + { + for (var row = 0; row < _rows; row++) + { + for (var col = 0; col < _cols; col++) + { + _grid[row, col] = ' '; + } + } + } + + private void NewLine() + { + if (_row < _rows - 1) + { + _row++; + } + } + + private static bool TrySkipOsc(string input, int index, out int nextIndex) + { + while (index < input.Length) + { + if (input[index] == '\x07') + { + nextIndex = index + 1; + return true; + } + + if (input[index] == '\x1b' && index + 1 < input.Length && input[index + 1] == '\\') + { + nextIndex = index + 2; + return true; + } + + index++; + } + + nextIndex = index; + return false; + } + + private static bool TrySkipUntilStringTerminator(string input, int index, out int nextIndex) + { + while (index < input.Length) + { + if (input[index] == '\x1b' && index + 1 < input.Length && input[index + 1] == '\\') + { + nextIndex = index + 2; + return true; + } + + index++; + } + + nextIndex = index; + return false; + } + + private static List ParseParameters(string body) => + body.Split(';') + .Select(part => int.TryParse(part, out var value) ? value : 0) + .ToList(); + + private static int Parameter(IReadOnlyList parameters, int index, int defaultValue) => + index < parameters.Count && parameters[index] > 0 + ? parameters[index] + : defaultValue; + + private static int Clamp(int value, int maximum) => Math.Max(0, Math.Min(maximum - 1, value)); + + private bool InBounds(int row, int col) => row >= 0 && row < _rows && col >= 0 && col < _cols; +} diff --git a/Tests/Opcilloscope.E2ETests/VtScreenTests.cs b/Tests/Opcilloscope.E2ETests/VtScreenTests.cs new file mode 100644 index 0000000..90c0780 --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/VtScreenTests.cs @@ -0,0 +1,63 @@ +namespace Opcilloscope.E2ETests; + +public class VtScreenTests +{ + [Fact] + public void Feed_FragmentedEraseDisplaySequence_ClearsTheScreen() + { + var screen = new VtScreen(rows: 2, cols: 10); + screen.Feed("stale text"); + + screen.Feed("\x1b["); + screen.Feed("2J"); + + Assert.Equal("\n", screen.Text()); + } + + [Fact] + public void Feed_FragmentedCursorSequence_PositionsSubsequentText() + { + var screen = new VtScreen(rows: 3, cols: 8); + + screen.Feed("\x1b[2;"); + screen.Feed("3Hplaced"); + + Assert.Equal("\n placed\n", screen.Text()); + } + + [Fact] + public void Feed_ZeroCursorParameters_UseTheAnsiDefaultOfOne() + { + var screen = new VtScreen(rows: 2, cols: 4); + screen.Feed("xxxx"); + + screen.Feed("\x1b[0;0HZ"); + + Assert.Equal("Zxxx\n", screen.Text()); + } + + [Theory] + [InlineData("\x1b]10;rgb:ffff/ffff/ffff\x07")] + [InlineData("\x1b]11;rgb:0000/0000/0000\x1b\\")] + public void Feed_FragmentedOscSequence_IgnoresPayload(string sequence) + { + var screen = new VtScreen(rows: 1, cols: 8); + var split = sequence.Length - 1; + + screen.Feed(sequence[..split]); + screen.Feed(sequence[split..] + "ok"); + + Assert.Equal("ok", screen.Text()); + } + + [Fact] + public void Feed_FragmentedDeviceControlString_IgnoresPayload() + { + var screen = new VtScreen(rows: 1, cols: 8); + + screen.Feed("\x1bPignored\x1b"); + screen.Feed("\\ok"); + + Assert.Equal("ok", screen.Text()); + } +} diff --git a/Tests/Opcilloscope.TestServer/Opcilloscope.TestServer.csproj b/Tests/Opcilloscope.TestServer/Opcilloscope.TestServer.csproj index b85e40c..33b5be4 100644 --- a/Tests/Opcilloscope.TestServer/Opcilloscope.TestServer.csproj +++ b/Tests/Opcilloscope.TestServer/Opcilloscope.TestServer.csproj @@ -10,7 +10,7 @@ - + diff --git a/Tests/Opcilloscope.TestServer/TestServer.cs b/Tests/Opcilloscope.TestServer/TestServer.cs index 1242d99..7e26b7e 100644 --- a/Tests/Opcilloscope.TestServer/TestServer.cs +++ b/Tests/Opcilloscope.TestServer/TestServer.cs @@ -11,6 +11,7 @@ public class TestServer : IAsyncDisposable, IDisposable { private StandardServer? _server; private ApplicationInstance? _application; + private readonly string _pkiRootPath; private bool _disposed; public const string ApplicationName = "Opcilloscope Test Server"; @@ -19,6 +20,17 @@ public class TestServer : IAsyncDisposable, IDisposable public string EndpointUrl { get; private set; } = string.Empty; public bool IsRunning => _server != null; + public TestServer(string? pkiRootPath = null) + { + _pkiRootPath = string.IsNullOrWhiteSpace(pkiRootPath) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "opcilloscope", + "TestServer", + "pki") + : Path.GetFullPath(pkiRootPath); + } + /// /// Starts the test server on the specified port. /// @@ -68,12 +80,6 @@ public async Task StopAsync() private ApplicationConfiguration CreateApplicationConfiguration(int port) { - var pkiPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "opcilloscope", - "TestServer", - "pki"); - var config = new ApplicationConfiguration { ApplicationName = ApplicationName, @@ -86,23 +92,23 @@ private ApplicationConfiguration CreateApplicationConfiguration(int port) ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(pkiPath, "own"), + StorePath = Path.Combine(_pkiRootPath, "own"), SubjectName = $"CN={ApplicationName}, O=Opcilloscope, DC=localhost" }, TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(pkiPath, "issuers") + StorePath = Path.Combine(_pkiRootPath, "issuers") }, TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(pkiPath, "trusted") + StorePath = Path.Combine(_pkiRootPath, "trusted") }, RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(pkiPath, "rejected") + StorePath = Path.Combine(_pkiRootPath, "rejected") }, // WARNING: Auto-accepting untrusted certificates is appropriate for test/development // environments only. NEVER use this setting in production. diff --git a/Tests/Opcilloscope.Tests/App/MainWindowStateTests.cs b/Tests/Opcilloscope.Tests/App/MainWindowStateTests.cs new file mode 100644 index 0000000..133177c --- /dev/null +++ b/Tests/Opcilloscope.Tests/App/MainWindowStateTests.cs @@ -0,0 +1,109 @@ +using Opcilloscope.App; +using Terminal.Gui; + +namespace Opcilloscope.Tests.App; + +public class MainWindowStateTests +{ + [Fact] + public void CanQuit_WhenClean_DoesNotPrompt() + { + var prompted = false; + + var result = MainWindow.CanQuit(false, () => + { + prompted = true; + return false; + }); + + Assert.True(result); + Assert.False(prompted); + } + + [Fact] + public void CanQuit_WhenDirtyAndDiscardRejected_BlocksQuit() + { + Assert.False(MainWindow.CanQuit(true, () => false)); + } + + [Fact] + public void CanQuit_WhenDirtyAndDiscardConfirmed_AllowsQuit() + { + Assert.True(MainWindow.CanQuit(true, () => true)); + } + + [Fact] + public void IsQuitKey_Escape_UsesGuardedQuitPath() + { + Assert.True(MainWindow.IsQuitKey(Key.Esc)); + Assert.False(MainWindow.IsQuitKey(Key.Enter)); + Assert.False(MainWindow.IsQuitKey(Key.Q.WithCtrl)); + } + + [Fact] + public async Task AwaitRecordingStopForQuitAsync_CompletedStop_DoesNotPrompt() + { + var prompted = false; + + var result = await MainWindow.AwaitRecordingStopForQuitAsync( + Task.CompletedTask, + TimeSpan.Zero, + () => + { + prompted = true; + return Task.FromResult(MainWindow.SlowRecordingQuitDecision.Cancel); + }); + + Assert.True(result); + Assert.False(prompted); + } + + [Fact] + public async Task AwaitRecordingStopForQuitAsync_SlowStopAndCancel_BlocksQuit() + { + var stop = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var result = await MainWindow.AwaitRecordingStopForQuitAsync( + stop.Task, + TimeSpan.Zero, + () => Task.FromResult(MainWindow.SlowRecordingQuitDecision.Cancel)); + + Assert.False(result); + stop.TrySetResult(); + } + + [Fact] + public async Task AwaitRecordingStopForQuitAsync_KeepWaiting_ObservesCompletion() + { + var stop = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var promptCount = 0; + + var result = await MainWindow.AwaitRecordingStopForQuitAsync( + stop.Task, + TimeSpan.Zero, + () => + { + promptCount++; + stop.TrySetResult(); + return Task.FromResult(MainWindow.SlowRecordingQuitDecision.KeepWaiting); + }); + + Assert.True(result); + Assert.Equal(1, promptCount); + } + + [Fact] + public async Task AwaitRecordingStopForQuitAsync_QuitAnyway_DoesNotWaitForWriter() + { + var stop = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var result = await MainWindow.AwaitRecordingStopForQuitAsync( + stop.Task, + TimeSpan.Zero, + () => Task.FromResult(MainWindow.SlowRecordingQuitDecision.QuitAnyway)); + + Assert.True(result); + Assert.False(stop.Task.IsCompleted); + stop.TrySetResult(); + } +} diff --git a/Tests/Opcilloscope.Tests/App/SaveConfigDialogTests.cs b/Tests/Opcilloscope.Tests/App/SaveConfigDialogTests.cs new file mode 100644 index 0000000..5858971 --- /dev/null +++ b/Tests/Opcilloscope.Tests/App/SaveConfigDialogTests.cs @@ -0,0 +1,23 @@ +using Opcilloscope.App.Dialogs; + +namespace Opcilloscope.Tests.App; + +public class SaveConfigDialogTests +{ + [Fact] + public void GetNormalizedFilePath_TrimsBeforeAddingExtension() + { + var path = SaveConfigDialog.GetNormalizedFilePath(" /tmp/configs ", " production "); + + Assert.Equal(Path.Combine("/tmp/configs", "production.cfg"), path); + } + + [Fact] + public void GetNormalizedFilePath_UsesTheSameExistingTargetForWhitespaceVariant() + { + var canonical = SaveConfigDialog.GetNormalizedFilePath("/tmp/configs", "production.cfg"); + var whitespace = SaveConfigDialog.GetNormalizedFilePath(" /tmp/configs ", " production.cfg "); + + Assert.Equal(canonical, whitespace); + } +} diff --git a/Tests/Opcilloscope.Tests/App/Views/BrailleCanvasTests.cs b/Tests/Opcilloscope.Tests/App/Views/BrailleCanvasTests.cs index 27752e1..98a810e 100644 --- a/Tests/Opcilloscope.Tests/App/Views/BrailleCanvasTests.cs +++ b/Tests/Opcilloscope.Tests/App/Views/BrailleCanvasTests.cs @@ -32,8 +32,8 @@ public void EmptyCanvas_ReturnsBlankBrailleChars() var canvas = new BrailleCanvas(3, 3); for (int cx = 0; cx < 3; cx++) - for (int cy = 0; cy < 3; cy++) - Assert.Equal('\u2800', canvas.GetCell(cx, cy)); + for (int cy = 0; cy < 3; cy++) + Assert.Equal('\u2800', canvas.GetCell(cx, cy)); } [Fact] @@ -82,8 +82,8 @@ public void SetPixel_AllDotsInCell_ReturnsFullBlock() // All 8 bits set => U+28FF var canvas = new BrailleCanvas(2, 2); for (int dx = 0; dx < 2; dx++) - for (int dy = 0; dy < 4; dy++) - canvas.SetPixel(dx, dy); + for (int dy = 0; dy < 4; dy++) + canvas.SetPixel(dx, dy); Assert.Equal('\u28FF', canvas.GetCell(0, 0)); } diff --git a/Tests/Opcilloscope.Tests/App/WriteValueDialogTests.cs b/Tests/Opcilloscope.Tests/App/WriteValueDialogTests.cs new file mode 100644 index 0000000..6c67f39 --- /dev/null +++ b/Tests/Opcilloscope.Tests/App/WriteValueDialogTests.cs @@ -0,0 +1,25 @@ +using Opc.Ua; +using Opcilloscope.App.Dialogs; + +namespace Opcilloscope.Tests.App; + +public class WriteValueDialogTests +{ + [Fact] + public void NormalizeInput_String_PreservesSignificantWhitespace() + { + Assert.Equal(" value ", WriteValueDialog.NormalizeInput(" value ", BuiltInType.String)); + } + + [Fact] + public void NormalizeInput_String_AllowsEmptyValue() + { + Assert.Equal(string.Empty, WriteValueDialog.NormalizeInput(string.Empty, BuiltInType.String)); + } + + [Fact] + public void NormalizeInput_Numeric_TrimsWhitespace() + { + Assert.Equal("42", WriteValueDialog.NormalizeInput(" 42 ", BuiltInType.Int32)); + } +} diff --git a/Tests/Opcilloscope.Tests/CommandLineParserTests.cs b/Tests/Opcilloscope.Tests/CommandLineParserTests.cs new file mode 100644 index 0000000..d0e31aa --- /dev/null +++ b/Tests/Opcilloscope.Tests/CommandLineParserTests.cs @@ -0,0 +1,73 @@ +namespace Opcilloscope.Tests; + +public class CommandLineParserTests +{ + [Fact] + public void Parse_ConfigOptionWithoutValue_Throws() + { + var error = Assert.Throws(() => CommandLineParser.Parse(["--config"])); + + Assert.Contains("requires a value", error.Message); + } + + [Fact] + public void Parse_OptionLookingConfigValue_Throws() + { + Assert.Throws(() => CommandLineParser.Parse(["--config", "--insecure"])); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Parse_EmptyOptionValue_Throws(string value) + { + Assert.Throws(() => CommandLineParser.Parse(["--config", value])); + } + + [Fact] + public void Parse_UnknownOption_Throws() + { + Assert.Throws(() => CommandLineParser.Parse(["--wat"])); + } + + [Theory] + [InlineData("settings.cfg")] + [InlineData("settings.opcilloscope")] + [InlineData("settings.json")] + public void Parse_DirectConfigPath_AcceptsEverySupportedExtension(string path) + { + var options = CommandLineParser.Parse([path]); + + Assert.Equal(path, options.ConfigPath); + } + + [Fact] + public void Parse_UnexpectedPositionalArgument_Throws() + { + Assert.Throws(() => CommandLineParser.Parse(["notes.txt"])); + } + + [Theory] + [InlineData("--help")] + [InlineData("-h")] + public void Parse_Help_ShortCircuitsTrailingInvalidArguments(string help) + { + var options = CommandLineParser.Parse([help, "--wat", "--config"]); + + Assert.True(options.ShowHelp); + Assert.Null(options.ConfigPath); + Assert.Null(options.AutoConnectUrl); + } + + [Fact] + public void Parse_AllPositiveOptions_ArePreserved() + { + var options = CommandLineParser.Parse( + ["--config", "settings.cfg", "--connect", "opc.tcp://server:4840", "--insecure"]); + + Assert.Equal("settings.cfg", options.ConfigPath); + Assert.Equal("opc.tcp://server:4840", options.AutoConnectUrl); + Assert.True(options.AllowInsecureCertificates); + Assert.False(options.ShowHelp); + } +} diff --git a/Tests/Opcilloscope.Tests/Configuration/ConfigurationServiceTests.cs b/Tests/Opcilloscope.Tests/Configuration/ConfigurationServiceTests.cs index 522bb42..7cb7f32 100644 --- a/Tests/Opcilloscope.Tests/Configuration/ConfigurationServiceTests.cs +++ b/Tests/Opcilloscope.Tests/Configuration/ConfigurationServiceTests.cs @@ -707,8 +707,9 @@ public void CaptureCurrentState_NoLoadedConfig_UsesModelDefaults() 1000, new List()); - // Assert: model defaults are used rather than throwing or nulling fields. - Assert.Equal("None", config.Server.SecurityMode); + // Assert: an omitted profile means secure automatic selection rather + // than silently opting into SecurityMode=None. + Assert.Null(config.Server.SecurityMode); Assert.Equal(250, config.Settings.SamplingIntervalMs); Assert.Equal((uint)10, config.Settings.QueueSize); } @@ -991,6 +992,57 @@ public async Task LoadAsync_MissingVersion_Loads() Assert.Equal("1.0", loaded.Version); } + [Theory] + [InlineData("Certificate", "operator")] + [InlineData("FutureAuth", "operator")] + [InlineData("UserName", "")] + public async Task LoadAsync_InvalidAuthentication_DoesNotSilentlyDowngradeToAnonymous( + string authenticationType, + string username) + { + var filePath = Path.Combine(_tempDir, "invalid-auth.cfg"); + var json = $$""" + { + "version": "1.0", + "server": { + "endpointUrl": "opc.tcp://localhost:4840", + "authentication": { + "type": "{{authenticationType}}", + "username": "{{username}}" + } + }, + "settings": {}, + "monitoredNodes": [], + "metadata": {} + } + """; + await File.WriteAllTextAsync(filePath, json); + + await Assert.ThrowsAsync(() => _service.LoadAsync(filePath)); + } + + [Fact] + public async Task LoadAsync_MissingSecurityMode_RemainsSecureAutoSelection() + { + var filePath = Path.Combine(_tempDir, "auto-security.cfg"); + await File.WriteAllTextAsync(filePath, """ + { + "version": "1.0", + "server": { + "endpointUrl": "opc.tcp://localhost:4840", + "authentication": { "type": "Anonymous" } + }, + "settings": {}, + "monitoredNodes": [], + "metadata": {} + } + """); + + var loaded = await _service.LoadAsync(filePath); + + Assert.Null(loaded.Server.SecurityMode); + } + #endregion private static OpcilloscopeConfig CreateTestConfig() diff --git a/Tests/Opcilloscope.Tests/Infrastructure/TestModuleInitializer.cs b/Tests/Opcilloscope.Tests/Infrastructure/TestModuleInitializer.cs index aa22be1..7c03f1c 100644 --- a/Tests/Opcilloscope.Tests/Infrastructure/TestModuleInitializer.cs +++ b/Tests/Opcilloscope.Tests/Infrastructure/TestModuleInitializer.cs @@ -16,5 +16,24 @@ internal static class TestModuleInitializer internal static void Init() { OpcUaClientWrapper.AllowInsecureByDefault = true; + var pkiRoot = Path.Combine( + Path.GetTempPath(), + "opcilloscope-tests", + "client-pki", + $"testhost-{Environment.ProcessId}-{Guid.NewGuid():N}"); + OpcUaClientWrapper.PkiRootPathOverrideForTests = pkiRoot; + + AppDomain.CurrentDomain.ProcessExit += (_, _) => + { + try + { + if (Directory.Exists(pkiRoot)) + Directory.Delete(pkiRoot, recursive: true); + } + catch + { + // Best-effort cleanup must not hide the test process's real result. + } + }; } } diff --git a/Tests/Opcilloscope.Tests/Infrastructure/TestServerFixture.cs b/Tests/Opcilloscope.Tests/Infrastructure/TestServerFixture.cs index ce63d55..aaa0676 100644 --- a/Tests/Opcilloscope.Tests/Infrastructure/TestServerFixture.cs +++ b/Tests/Opcilloscope.Tests/Infrastructure/TestServerFixture.cs @@ -16,6 +16,7 @@ public class TestServerFixture : IAsyncLifetime private static int _nextPort = 48400; // Use higher port range to avoid conflicts with existing OPC UA servers private Opcilloscope.TestServer.TestServer? _server; + private string? _pkiRootPath; private int _port; /// @@ -45,18 +46,47 @@ private static int AllocatePort() public async Task InitializeAsync() { _port = AllocatePort(); + _pkiRootPath = Path.Combine( + Path.GetTempPath(), + "opcilloscope-tests", + "server-pki", + Guid.NewGuid().ToString("N")); - _server = new Opcilloscope.TestServer.TestServer(); + _server = new Opcilloscope.TestServer.TestServer(_pkiRootPath); await _server.StartAsync(_port); } public async Task DisposeAsync() { - if (_server != null) + var server = _server; + _server = null; + try { - await _server.StopAsync(); - _server.Dispose(); - _server = null; + if (server != null) + { + try + { + await server.StopAsync(); + } + finally + { + server.Dispose(); + } + } + } + finally + { + try + { + if (_pkiRootPath != null && Directory.Exists(_pkiRootPath)) + Directory.Delete(_pkiRootPath, recursive: true); + } + catch + { + // Cleanup failure must not replace a server shutdown exception. + } + + _pkiRootPath = null; } } diff --git a/Tests/Opcilloscope.Tests/Integration/AuthenticationIntegrationTests.cs b/Tests/Opcilloscope.Tests/Integration/AuthenticationIntegrationTests.cs index d1de30e..3b8934e 100644 --- a/Tests/Opcilloscope.Tests/Integration/AuthenticationIntegrationTests.cs +++ b/Tests/Opcilloscope.Tests/Integration/AuthenticationIntegrationTests.cs @@ -2,6 +2,7 @@ using Opcilloscope.Configuration.Models; using Opcilloscope.OpcUa; using Opcilloscope.Tests.Infrastructure; +using Opc.Ua; namespace Opcilloscope.Tests.Integration; @@ -24,10 +25,84 @@ public AuthenticationIntegrationTests(TestServerFixture fixture) [Fact] public async Task ConnectAnonymous_Succeeds() { - using var client = new OpcUaClientWrapper(); + using var client = new OpcUaClientWrapper(allowInsecure: true); var result = await client.ConnectAsync(_fixture.EndpointUrl); + Assert.True(result); Assert.True(client.IsConnected); + Assert.Equal(MessageSecurityMode.SignAndEncrypt, client.CurrentSecurityMode); + Assert.Equal(SecurityPolicies.Basic256Sha256, client.CurrentSecurityPolicy); + } + + [Fact] + public async Task ConnectAnonymous_ExplicitNone_UsesUnsecuredEndpoint() + { + using var client = new OpcUaClientWrapper(allowInsecure: false); + + var result = await client.ConnectAsync( + _fixture.EndpointUrl, + securityMode: nameof(MessageSecurityMode.None), + securityPolicy: SecurityPolicies.None); + + Assert.True(result); + Assert.Equal(MessageSecurityMode.None, client.CurrentSecurityMode); + Assert.Equal(SecurityPolicies.None, client.CurrentSecurityPolicy); + } + + [Fact] + public async Task ConnectAnonymous_UntrustedSignAndEncryptCertificate_IsRejectedWithAllowInsecureFalse() + { + var pkiRoot = CreateTemporaryPkiRoot(); + try + { + using var client = new OpcUaClientWrapper( + logger: null, + allowInsecure: false, + pkiRootPath: pkiRoot); + string? error = null; + client.ConnectionError += message => error = message; + + var result = await client.ConnectAsync( + _fixture.EndpointUrl, + securityMode: nameof(MessageSecurityMode.SignAndEncrypt), + securityPolicy: SecurityPolicies.Basic256Sha256); + + Assert.False(result); + Assert.False(client.IsConnected); + Assert.NotNull(error); + Assert.Contains("certificate", error, StringComparison.OrdinalIgnoreCase); + } + finally + { + DeleteTemporaryPkiRoot(pkiRoot); + } + } + + [Fact] + public async Task ConnectAnonymous_UntrustedSignAndEncryptCertificate_IsAcceptedWithAllowInsecureTrue() + { + var pkiRoot = CreateTemporaryPkiRoot(); + try + { + using var client = new OpcUaClientWrapper( + logger: null, + allowInsecure: true, + pkiRootPath: pkiRoot); + + var result = await client.ConnectAsync( + _fixture.EndpointUrl, + securityMode: nameof(MessageSecurityMode.SignAndEncrypt), + securityPolicy: SecurityPolicies.Basic256Sha256); + + Assert.True(result); + Assert.True(client.IsConnected); + Assert.Equal(MessageSecurityMode.SignAndEncrypt, client.CurrentSecurityMode); + Assert.Equal(SecurityPolicies.Basic256Sha256, client.CurrentSecurityPolicy); + } + finally + { + DeleteTemporaryPkiRoot(pkiRoot); + } } [Fact] @@ -44,6 +119,68 @@ public async Task ConnectWithValidCredentials_Succeeds() Assert.True(client.IsConnected); } + [Fact] + public async Task ConnectWithCredentials_SelectsEncryptedEndpointAndExposesSelection() + { + var credentials = new ConnectionCredentials( + AuthenticationType.UserName, + TestUsername, + TestPassword); + + using var client = new OpcUaClientWrapper(allowInsecure: true); + var result = await client.ConnectAsync(_fixture.EndpointUrl, credentials); + + Assert.True(result); + Assert.Equal(MessageSecurityMode.SignAndEncrypt, client.CurrentSecurityMode); + Assert.Equal(SecurityPolicies.Basic256Sha256, client.CurrentSecurityPolicy); + } + + [Fact] + public async Task ConnectWithCredentials_ExplicitNoneSecurity_FailsClosed() + { + var credentials = new ConnectionCredentials( + AuthenticationType.UserName, + TestUsername, + TestPassword); + + using var client = new OpcUaClientWrapper(allowInsecure: true); + string? error = null; + client.ConnectionError += message => error = message; + + var result = await client.ConnectAsync( + _fixture.EndpointUrl, + credentials, + nameof(MessageSecurityMode.None), + SecurityPolicies.None); + + Assert.False(result); + Assert.False(client.IsConnected); + Assert.NotNull(error); + Assert.Contains("encrypted", error, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("Sign", null)] + [InlineData("SignAndEncrypt", "Basic128Rsa15")] + public async Task ConnectWithExplicitUnavailableSecurityProfile_FailsClosed( + string securityMode, + string? securityPolicy) + { + using var client = new OpcUaClientWrapper(allowInsecure: true); + string? error = null; + client.ConnectionError += message => error = message; + + var result = await client.ConnectAsync( + _fixture.EndpointUrl, + securityMode: securityMode, + securityPolicy: securityPolicy); + + Assert.False(result); + Assert.False(client.IsConnected); + Assert.NotNull(error); + Assert.Contains("No endpoint matched", error, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ConnectWithInvalidPassword_Fails() { @@ -206,4 +343,13 @@ public async Task ConfigRoundTrip_SaveWithAuth_LoadAndConnect() File.Delete(tempFile); } } + + private static string CreateTemporaryPkiRoot() + => Path.Combine(Path.GetTempPath(), "opcilloscope-tests", "client-pki", Guid.NewGuid().ToString("N")); + + private static void DeleteTemporaryPkiRoot(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } } diff --git a/Tests/Opcilloscope.Tests/Integration/ConnectionManagerIntegrationTests.cs b/Tests/Opcilloscope.Tests/Integration/ConnectionManagerIntegrationTests.cs index 659c40f..09735dc 100644 --- a/Tests/Opcilloscope.Tests/Integration/ConnectionManagerIntegrationTests.cs +++ b/Tests/Opcilloscope.Tests/Integration/ConnectionManagerIntegrationTests.cs @@ -1,6 +1,7 @@ using Opcilloscope.OpcUa; using Opcilloscope.Tests.Infrastructure; using Opcilloscope.Utilities; +using Opc.Ua; namespace Opcilloscope.Tests.Integration; @@ -174,6 +175,238 @@ public async Task ReconnectAsync_WithoutPreviousConnection_ReturnsFalse() Assert.False(result); } + [Fact] + public async Task ReconnectAsync_AfterExplicitDisconnect_PerformsFreshConnectWithStoredProfile() + { + var credentials = new ConnectionCredentials( + AuthenticationType.UserName, + "testuser", + "testpass"); + + var connected = await _connectionManager!.ConnectAsync( + _fixture.EndpointUrl, + publishingInterval: 500, + credentials: credentials, + securityMode: nameof(MessageSecurityMode.SignAndEncrypt), + securityPolicy: SecurityPolicies.Basic256Sha256, + samplingInterval: 750, + queueSize: 25); + Assert.True(connected); + + await _connectionManager.DisconnectAsync(); + Assert.False(_connectionManager.IsConnected); + + var reconnected = await _connectionManager.ReconnectAsync(); + + Assert.True(reconnected); + Assert.True(_connectionManager.IsConnected); + Assert.Equal(MessageSecurityMode.SignAndEncrypt, _connectionManager.CurrentSecurityMode); + Assert.Equal(SecurityPolicies.Basic256Sha256, _connectionManager.CurrentSecurityPolicy); + Assert.Equal(500, _connectionManager.SubscriptionManager?.PublishingInterval); + Assert.Equal(750, _connectionManager.SubscriptionManager?.SamplingInterval); + Assert.Equal(25u, _connectionManager.SubscriptionManager?.QueueSize); + } + + [Fact] + public async Task DisconnectAsync_QueuedBehindConnect_CannotBeOvertakenByLateSessionCreation() + { + using var manager = new ConnectionManager(_logger, allowInsecure: true); + + var connectTask = manager.ConnectAsync(_fixture.EndpointUrl); + var disconnectTask = manager.DisconnectAsync(); + + var connected = await connectTask; + await disconnectTask; + + Assert.False(connected); + Assert.False(manager.IsConnected); + Assert.Null(manager.SubscriptionManager); + } + + [Fact] + public async Task StaleDisconnectIntent_CannotTearDownNewerConnection() + { + var staleDisconnect = _connectionManager!.RegisterExplicitLifecycleIntent(); + var newerConnect = _connectionManager.RegisterExplicitLifecycleIntent(); + + var connected = await _connectionManager.ConnectWithIntentAsync( + _fixture.EndpointUrl, + publishingInterval: 250, + credentials: null, + securityMode: null, + securityPolicy: null, + samplingInterval: 250, + queueSize: 10, + operationGeneration: newerConnect); + var disconnected = await _connectionManager.DisconnectWithIntentAsync(staleDisconnect); + + Assert.True(connected); + Assert.False(disconnected); + Assert.True(_connectionManager.IsConnected); + } + + [Fact] + public async Task AbandonedIntent_RestoresExistingSessionAndMonitoredGeneration() + { + Assert.True(await _connectionManager!.ConnectAsync(_fixture.EndpointUrl)); + var node = await _connectionManager.SubscribeAsync( + new NodeId("Counter", (ushort)GetNamespaceIndex()), + "Counter"); + Assert.NotNull(node); + + var abandonedGeneration = _connectionManager.RegisterExplicitLifecycleIntent(); + Assert.False(_connectionManager.IsConnected); + + _connectionManager.RestoreSessionAfterAbandonedIntent(abandonedGeneration); + + Assert.True(_connectionManager.IsConnected); + Assert.Equal(abandonedGeneration, node.ConnectionGeneration); + } + + [Fact] + public void TryRegisterExplicitIntent_FailsWhenNewerIntentAlreadyExists() + { + var expectedVersion = _connectionManager!.ConnectionIntentVersion; + _connectionManager.RegisterExplicitLifecycleIntent(); + + var registered = _connectionManager.TryRegisterExplicitLifecycleIntent( + expectedVersion, + out _); + + Assert.False(registered); + } + + [Fact] + public async Task AutomaticReconnect_QueuedBeforeExplicitDisconnect_CannotResurrectSession() + { + var connected = await _connectionManager!.ConnectAsync(_fixture.EndpointUrl); + Assert.True(connected); + + long? reconnectIntent = null; + _connectionManager.AutoReconnectTriggered += intent => reconnectIntent = intent; + _connectionManager.OnReconnectRequired(); + Assert.NotNull(reconnectIntent); + + await _connectionManager.DisconnectAsync(); + var reconnected = await _connectionManager.ReconnectAutomaticallyAsync(reconnectIntent.Value); + + Assert.False(reconnected); + Assert.False(_connectionManager.IsConnected); + Assert.Null(_connectionManager.SubscriptionManager); + } + + [Fact] + public async Task KeepAliveAfterExplicitIntent_CannotSupersedeQueuedDisconnect() + { + var connected = await _connectionManager!.ConnectAsync(_fixture.EndpointUrl); + Assert.True(connected); + + var disconnectGeneration = _connectionManager.RegisterExplicitLifecycleIntent(); + var autoReconnectRaised = false; + _connectionManager.AutoReconnectTriggered += _ => autoReconnectRaised = true; + + _connectionManager.OnReconnectRequired(); + var disconnected = await _connectionManager.DisconnectWithIntentAsync(disconnectGeneration); + + Assert.False(autoReconnectRaised); + Assert.True(disconnected); + Assert.False(_connectionManager.IsConnected); + } + + [Fact] + public async Task Disconnect_CancelsManualReconnectBeforeWrapperGateIsAcquired() + { + Assert.True(await _connectionManager!.ConnectAsync(_fixture.EndpointUrl)); + var gateEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var holdGate = _connectionManager.Client.ExecuteLifecycleAsync(async () => + { + gateEntered.TrySetResult(true); + await releaseGate.Task; + }); + await gateEntered.Task; + + var reconnectGeneration = _connectionManager.RegisterExplicitLifecycleIntent(); + var reconnectTask = _connectionManager.ReconnectWithIntentAsync(reconnectGeneration); + var disconnectGeneration = _connectionManager.RegisterExplicitLifecycleIntent(); + var disconnectTask = _connectionManager.DisconnectWithIntentAsync(disconnectGeneration); + releaseGate.TrySetResult(true); + + await holdGate; + Assert.False(await reconnectTask); + Assert.True(await disconnectTask); + Assert.False(_connectionManager.IsConnected); + } + + [Fact] + public async Task OperationsAfterKeepAliveLoss_AreRejectedUntilReconnectIsPublished() + { + var connected = await _connectionManager!.ConnectAsync(_fixture.EndpointUrl); + Assert.True(connected); + + _connectionManager.OnReconnectRequired(); + var reconnectGeneration = _connectionManager.ConnectionGeneration; + var namespaceIndex = (ushort)GetNamespaceIndex(); + var nodeId = new NodeId("WritableNumber", namespaceIndex); + var root = _connectionManager.NodeBrowser.GetRootNode(); + + var writeStatus = await _connectionManager.WriteValueAsync( + nodeId, + 123456789, + reconnectGeneration); + var subscription = await _connectionManager.SubscribeAsync( + nodeId, + "during reconnect", + reconnectGeneration); + var snapshot = await _connectionManager.ReadWriteSnapshotAsync( + nodeId, + reconnectGeneration, + Attributes.Value); + var children = await _connectionManager.NodeBrowser.GetChildrenAsync(root); + + Assert.False(_connectionManager.IsConnectionGenerationActive(reconnectGeneration)); + Assert.Equal(StatusCodes.BadNotConnected, writeStatus.Code); + Assert.Null(subscription); + Assert.Null(snapshot); + Assert.Empty(children); + Assert.False(root.ChildrenLoaded); + } + + [Fact] + public async Task OperationsFromPriorGeneration_AreRejectedAfterReconnect() + { + var connected = await _connectionManager!.ConnectAsync(_fixture.EndpointUrl); + Assert.True(connected); + + var oldGeneration = _connectionManager.ConnectionGeneration; + var oldRoot = _connectionManager.NodeBrowser.GetRootNode(); + var namespaceIndex = (ushort)GetNamespaceIndex(); + var writableNode = new NodeId("WritableNumber", namespaceIndex); + var valueBefore = await _connectionManager.Client.ReadValueAsync(writableNode); + + await _connectionManager.DisconnectAsync(); + connected = await _connectionManager.ConnectAsync(_fixture.EndpointUrl); + Assert.True(connected); + Assert.NotEqual(oldGeneration, _connectionManager.ConnectionGeneration); + + var writeStatus = await _connectionManager.WriteValueAsync( + writableNode, + 123456789, + oldGeneration); + var staleSubscription = await _connectionManager.SubscribeAsync( + writableNode, + "stale", + oldGeneration); + var staleChildren = await _connectionManager.NodeBrowser.GetChildrenAsync(oldRoot); + var valueAfter = await _connectionManager.Client.ReadValueAsync(writableNode); + + Assert.Equal(StatusCodes.BadNotConnected, writeStatus.Code); + Assert.Null(staleSubscription); + Assert.Empty(staleChildren); + Assert.False(oldRoot.ChildrenLoaded); + Assert.Equal(valueBefore?.Value, valueAfter?.Value); + } + [Fact] public async Task LastEndpoint_RemembersEndpoint() { @@ -228,11 +461,7 @@ public async Task UnsubscribeAsync_RemovesSubscription() var nodeId = new Opc.Ua.NodeId("Counter", (ushort)nsIndex); var node = await _connectionManager.SubscribeAsync(nodeId, "Counter"); - // Skip test if subscription failed (server may not support the node) - if (node == null) - { - return; - } + Assert.NotNull(node); // Act var result = await _connectionManager.UnsubscribeAsync(node.ClientHandle); @@ -255,10 +484,7 @@ public async Task ValueChanged_FiresWhenValueUpdates() // Act var node = await _connectionManager.SubscribeAsync(nodeId, "Counter"); - if (node == null) - { - return; // Skip if subscription failed - } + Assert.NotNull(node); await Task.Delay(1500); // Wait for subscription updates @@ -282,10 +508,8 @@ public async Task VariableAdded_FiresWhenSubscribing() var node = await _connectionManager.SubscribeAsync(nodeId, "Counter"); // Assert - if (node != null) - { - Assert.NotNull(addedNode); - } + Assert.NotNull(node); + Assert.NotNull(addedNode); } [Fact] @@ -298,14 +522,10 @@ public async Task VariableRemoved_FiresWhenUnsubscribing() var nsIndex = GetNamespaceIndex(); var nodeId = new Opc.Ua.NodeId("Counter", (ushort)nsIndex); var node = await _connectionManager.SubscribeAsync(nodeId, "Counter"); - - if (node == null) - { - return; // Skip if subscription failed - } + Assert.NotNull(node); uint? removedHandle = null; - _connectionManager.VariableRemoved += handle => removedHandle = handle; + _connectionManager.VariableRemoved += (handle, _) => removedHandle = handle; // Act await _connectionManager.UnsubscribeAsync(node.ClientHandle); @@ -342,12 +562,13 @@ public async Task Client_IsAccessibleWhenConnected() private int GetNamespaceIndex() { - if (_connectionManager?.Client?.Session == null) - { - return -1; - } + var session = _connectionManager?.Client.Session + ?? throw new InvalidOperationException("The test connection has no active OPC UA session."); - return _connectionManager.Client.Session.NamespaceUris.GetIndex( + var index = session.NamespaceUris.GetIndex( Opcilloscope.TestServer.TestNodeManager.NamespaceUri); + return index >= 0 + ? index + : throw new InvalidOperationException("The test server namespace was not registered in the session."); } } diff --git a/Tests/Opcilloscope.Tests/Integration/NodeBrowserIntegrationTests.cs b/Tests/Opcilloscope.Tests/Integration/NodeBrowserIntegrationTests.cs index 0e026db..c2c6e97 100644 --- a/Tests/Opcilloscope.Tests/Integration/NodeBrowserIntegrationTests.cs +++ b/Tests/Opcilloscope.Tests/Integration/NodeBrowserIntegrationTests.cs @@ -188,6 +188,35 @@ public async Task GetChildrenAsync_FolderNodes_HaveHasChildrenTrue() Assert.True(objectsFolder.HasChildren); } + [Fact] + public async Task GetChildrenAsync_LeafVariable_ClearsOptimisticHasChildrenFlag() + { + var root = _nodeBrowser.GetRootNode(); + var objects = (await _nodeBrowser.GetChildrenAsync(root)).First(c => c.DisplayName == "Objects"); + var simulation = (await _nodeBrowser.GetChildrenAsync(objects)).First(c => c.DisplayName == "Simulation"); + var counter = (await _nodeBrowser.GetChildrenAsync(simulation)).First(c => c.DisplayName == "Counter"); + + var children = await _nodeBrowser.GetChildrenAsync(counter); + + Assert.Empty(children); + Assert.True(counter.ChildrenLoaded); + Assert.False(counter.HasChildren); + } + + [Fact] + public async Task GetChildrenAsync_TypesFolder_ReturnsTypeNodes() + { + var root = _nodeBrowser.GetRootNode(); + var types = (await _nodeBrowser.GetChildrenAsync(root)).First(c => c.DisplayName == "Types"); + + var categories = await _nodeBrowser.GetChildrenAsync(types); + var objectTypes = categories.First(c => c.DisplayName == "ObjectTypes"); + var children = await _nodeBrowser.GetChildrenAsync(objectTypes); + + Assert.NotEmpty(children); + Assert.Contains(children, child => child.NodeClass == NodeClass.ObjectType); + } + [Fact] public async Task GetNodeAttributesAsync_ReturnsAttributes() { diff --git a/Tests/Opcilloscope.Tests/Integration/ReconnectIntegrationTests.cs b/Tests/Opcilloscope.Tests/Integration/ReconnectIntegrationTests.cs index 847a7dc..4fea150 100644 --- a/Tests/Opcilloscope.Tests/Integration/ReconnectIntegrationTests.cs +++ b/Tests/Opcilloscope.Tests/Integration/ReconnectIntegrationTests.cs @@ -26,7 +26,12 @@ public class ReconnectIntegrationTests public async Task Reconnect_AfterSessionLoss_RestoresConnectionAndResumesValueUpdates() { var logger = new Logger(); - var server = new Opcilloscope.TestServer.TestServer(); + var serverPkiRoot = Path.Combine( + Path.GetTempPath(), + "opcilloscope-tests", + "server-pki", + Guid.NewGuid().ToString("N")); + var server = new Opcilloscope.TestServer.TestServer(serverPkiRoot); await server.StartAsync(DedicatedPort); var connectionManager = new ConnectionManager(logger); @@ -39,8 +44,8 @@ public async Task Reconnect_AfterSessionLoss_RestoresConnectionAndResumesValueUp var dropOccurred = false; // When keep-alive reports the drop, drive the documented reconnect/backoff loop. - connectionManager.AutoReconnectTriggered += () => - connectionManager.ReconnectAsync().FireAndForget(logger); + connectionManager.AutoReconnectTriggered += intentVersion => + connectionManager.ReconnectAutomaticallyAsync(intentVersion).FireAndForget(logger); connectionManager.StateChanged += state => { @@ -67,7 +72,9 @@ public async Task Reconnect_AfterSessionLoss_RestoresConnectionAndResumesValueUp }; // Confirm we are receiving values before forcing the drop. - await WaitForAsync(() => node!.Timestamp != null, TimeSpan.FromSeconds(15)); + Assert.True( + await WaitForAsync(() => node!.Timestamp != null, TimeSpan.FromSeconds(15)), + "a ValueChanged tick should arrive before forcing session loss"); // Force session loss by stopping the server, then bring it back so reconnection // (with exponential backoff) can succeed. @@ -87,18 +94,35 @@ public async Task Reconnect_AfterSessionLoss_RestoresConnectionAndResumesValueUp finally { connectionManager.Dispose(); - await server.StopAsync(); - server.Dispose(); + try + { + await server.StopAsync(); + } + finally + { + server.Dispose(); + try + { + if (Directory.Exists(serverPkiRoot)) + Directory.Delete(serverPkiRoot, recursive: true); + } + catch + { + // Preserve the server/test failure over best-effort temp cleanup. + } + } } } - private static async Task WaitForAsync(Func condition, TimeSpan timeout) + private static async Task WaitForAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { - if (condition()) return; + if (condition()) return true; await Task.Delay(100); } + + return condition(); } } diff --git a/Tests/Opcilloscope.Tests/Integration/SubscriptionManagerIntegrationTests.cs b/Tests/Opcilloscope.Tests/Integration/SubscriptionManagerIntegrationTests.cs index b551797..1fb0977 100644 --- a/Tests/Opcilloscope.Tests/Integration/SubscriptionManagerIntegrationTests.cs +++ b/Tests/Opcilloscope.Tests/Integration/SubscriptionManagerIntegrationTests.cs @@ -34,23 +34,30 @@ public async Task InitializeAsync_CreatesSubscription_Successfully() public async Task AddNodeAsync_SubscribesToNode_Successfully() { // Arrange - using var subscriptionManager = new SubscriptionManager(Client!, _logger); + const long connectionGeneration = 41; + using var subscriptionManager = new SubscriptionManager( + Client!, + _logger, + connectionGeneration); await subscriptionManager.InitializeAsync(); var nodeId = new NodeId("Counter", (ushort)GetNamespaceIndex()); // Act var node = await subscriptionManager.AddNodeAsync(nodeId, "Counter"); - // Skip test if subscription failed (server may not support the node) - if (node == null) - { - return; - } - // Assert + Assert.NotNull(node); Assert.Equal("Counter", node.DisplayName); Assert.Equal(nodeId, node.NodeId); + Assert.Equal(connectionGeneration, node.ConnectionGeneration); Assert.Single(subscriptionManager.MonitoredVariables); + + subscriptionManager.AdvanceConnectionGeneration(42); + + Assert.Equal(42, node.ConnectionGeneration); + Assert.All( + subscriptionManager.MonitoredVariables, + monitored => Assert.Equal(42, monitored.ConnectionGeneration)); } [Fact] @@ -64,13 +71,8 @@ public async Task AddNodeAsync_ReadsInitialValue() // Act var node = await subscriptionManager.AddNodeAsync(nodeId, "ServerName"); - // Skip test if subscription failed (server may not support the node) - if (node == null) - { - return; - } - // Assert + Assert.NotNull(node); Assert.Equal("Opcilloscope Test Server", node.Value); } @@ -85,13 +87,8 @@ public async Task AddNodeAsync_ReadsNodeAttributes() // Act var node = await subscriptionManager.AddNodeAsync(nodeId, "WritableString"); - // Skip test if subscription failed (server may not support the node) - if (node == null) - { - return; - } - // Assert + Assert.NotNull(node); Assert.NotNull(node.DataTypeName); Assert.Equal("String", node.DataTypeName); } @@ -107,11 +104,7 @@ public async Task AddNodeAsync_DuplicateNode_ReturnsNull() // Act var firstNode = await subscriptionManager.AddNodeAsync(nodeId, "Counter"); - // Skip test if first subscription failed - if (firstNode == null) - { - return; - } + Assert.NotNull(firstNode); var duplicate = await subscriptionManager.AddNodeAsync(nodeId, "Counter"); @@ -133,13 +126,8 @@ public async Task AddNodeAsync_FiresVariableAddedEvent() // Act var node = await subscriptionManager.AddNodeAsync(nodeId, "Counter"); - // Skip test if subscription failed (server may not support the node) - if (node == null) - { - return; - } - // Assert + Assert.NotNull(node); Assert.NotNull(addedNode); Assert.Equal("Counter", addedNode.DisplayName); } @@ -153,11 +141,7 @@ public async Task RemoveNodeAsync_UnsubscribesFromNode_Successfully() var nodeId = new NodeId("Counter", (ushort)GetNamespaceIndex()); var node = await subscriptionManager.AddNodeAsync(nodeId, "Counter"); - // Skip test if subscription failed (server may not support the node) - if (node == null) - { - return; - } + Assert.NotNull(node); // Act var result = await subscriptionManager.RemoveNodeAsync(node.ClientHandle); @@ -176,14 +160,10 @@ public async Task RemoveNodeAsync_FiresVariableRemovedEvent() var nodeId = new NodeId("Counter", (ushort)GetNamespaceIndex()); var node = await subscriptionManager.AddNodeAsync(nodeId, "Counter"); - // Skip test if subscription failed (server may not support the node) - if (node == null) - { - return; - } + Assert.NotNull(node); uint? removedHandle = null; - subscriptionManager.VariableRemoved += handle => removedHandle = handle; + subscriptionManager.VariableRemoved += (handle, _) => removedHandle = handle; // Act await subscriptionManager.RemoveNodeAsync(node.ClientHandle); @@ -206,11 +186,7 @@ public async Task ValueChanged_ReceivesUpdates_WhenValueChanges() // Act var node = await subscriptionManager.AddNodeAsync(nodeId, "Counter"); - // Skip test if subscription failed (server may not support the node) - if (node == null) - { - return; - } + Assert.NotNull(node); // Wait for at least one subscription notification (counter updates every second) await Task.Delay(1500); @@ -229,14 +205,10 @@ public async Task ClearAsync_RemovesAllNodes() var node2 = await subscriptionManager.AddNodeAsync(new NodeId("SineWave", (ushort)GetNamespaceIndex()), "SineWave"); var node3 = await subscriptionManager.AddNodeAsync(new NodeId("RandomValue", (ushort)GetNamespaceIndex()), "RandomValue"); - // Skip test if no subscriptions succeeded - if (subscriptionManager.MonitoredVariables.Count == 0) - { - return; - } - - var initialCount = subscriptionManager.MonitoredVariables.Count; - Assert.True(initialCount > 0, "At least one subscription should succeed"); + Assert.NotNull(node1); + Assert.NotNull(node2); + Assert.NotNull(node3); + Assert.Equal(3, subscriptionManager.MonitoredVariables.Count); // Act await subscriptionManager.ClearAsync(); @@ -334,5 +306,76 @@ public async Task AddNodeAsync_InvalidNodeId_ReturnsNull() // Assert - a bad monitored item status causes AddNodeAsync to clean up // and return null per its implementation contract. Assert.Null(node); + Assert.Empty(subscriptionManager.MonitoredVariables); + Assert.Equal((uint?)0, subscriptionManager.GetOpcSubscription()?.MonitoredItemCount); + } + + [Fact] + public async Task AddNodeAsync_WhenVariableAddedHandlerThrows_KeepsReturnAndStateConsistent() + { + // Arrange + using var subscriptionManager = new SubscriptionManager(Client!, _logger); + await subscriptionManager.InitializeAsync(); + var nodeId = new NodeId("Counter", (ushort)GetNamespaceIndex()); + subscriptionManager.VariableAdded += _ => throw new InvalidOperationException("test handler failure"); + + // Act + var node = await subscriptionManager.AddNodeAsync(nodeId, "Counter"); + + // Assert - an observer failure must not turn a committed server item into a + // null result while leaving it hidden in the manager as a ghost. + Assert.NotNull(node); + Assert.Single(subscriptionManager.MonitoredVariables); + Assert.Equal((uint?)1, subscriptionManager.GetOpcSubscription()?.MonitoredItemCount); + } + + [Fact] + public async Task RecreateSubscriptionsAsync_WhenServerRejectsOneItem_ReturnsFalseAndRollsBackItems() + { + // Arrange - changing the retained model's init-only NodeId simulates a node + // disappearing from the server between the original session and recreation. + using var subscriptionManager = new SubscriptionManager(Client!, _logger); + await subscriptionManager.InitializeAsync(); + var node = await subscriptionManager.AddNodeAsync( + new NodeId("Counter", (ushort)GetNamespaceIndex()), + "Counter"); + Assert.NotNull(node); + + var missingNodeId = new NodeId("RemovedBeforeReconnect", (ushort)GetNamespaceIndex()); + typeof(Opcilloscope.OpcUa.Models.MonitoredNode) + .GetProperty(nameof(Opcilloscope.OpcUa.Models.MonitoredNode.NodeId))! + .SetValue(node, missingNodeId); + + // Act + var recreated = await subscriptionManager.RecreateSubscriptionsAsync(); + + // Assert - the caller can now execute its explicit loss fallback instead of + // being told that a rejected monitored item was fully restored. + Assert.False(recreated); + Assert.Equal((uint?)0, subscriptionManager.GetOpcSubscription()?.MonitoredItemCount); + } + + [Fact] + public async Task AddAndRemoveNodeAsync_ConcurrentMutationsLeaveConsistentState() + { + // Arrange + using var subscriptionManager = new SubscriptionManager(Client!, _logger); + await subscriptionManager.InitializeAsync(); + var nodeId = new NodeId("Counter", (ushort)GetNamespaceIndex()); + var original = await subscriptionManager.AddNodeAsync(nodeId, "Counter"); + Assert.NotNull(original); + + // Act - RemoveNodeAsync reaches its first ApplyChangesAsync before returning + // control, so the add attempts to mutate the same OPC subscription concurrently + // unless SubscriptionManager serializes the complete mutation transaction. + var removeTask = subscriptionManager.RemoveNodeAsync(original.ClientHandle); + var addTask = subscriptionManager.AddNodeAsync(nodeId, "Counter replacement"); + await Task.WhenAll(removeTask, addTask); + + // Assert + Assert.True(await removeTask); + Assert.NotNull(await addTask); + Assert.Single(subscriptionManager.MonitoredVariables); + Assert.Equal((uint?)1, subscriptionManager.GetOpcSubscription()?.MonitoredItemCount); } } diff --git a/Tests/Opcilloscope.Tests/OpcUa/ConnectionCredentialsTests.cs b/Tests/Opcilloscope.Tests/OpcUa/ConnectionCredentialsTests.cs new file mode 100644 index 0000000..faba403 --- /dev/null +++ b/Tests/Opcilloscope.Tests/OpcUa/ConnectionCredentialsTests.cs @@ -0,0 +1,28 @@ +using Opcilloscope.OpcUa; + +namespace Opcilloscope.Tests.OpcUa; + +public class ConnectionCredentialsTests +{ + [Theory] + [InlineData("Anonymous", AuthenticationType.Anonymous)] + [InlineData("anonymous", AuthenticationType.Anonymous)] + [InlineData("UserName", AuthenticationType.UserName)] + [InlineData("username", AuthenticationType.UserName)] + public void ParseAuthType_KnownValue_ReturnsExpectedType( + string value, + AuthenticationType expected) + { + Assert.Equal(expected, ConnectionCredentials.ParseAuthType(value)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Certificate")] + [InlineData("future-auth-mode")] + public void ParseAuthType_UnknownValue_ThrowsInsteadOfDowngradingToAnonymous(string? value) + { + Assert.Throws(() => ConnectionCredentials.ParseAuthType(value)); + } +} diff --git a/Tests/Opcilloscope.Tests/OpcUa/Models/MonitoredNodeTests.cs b/Tests/Opcilloscope.Tests/OpcUa/Models/MonitoredNodeTests.cs index 1df43ca..c3f5ebd 100644 --- a/Tests/Opcilloscope.Tests/OpcUa/Models/MonitoredNodeTests.cs +++ b/Tests/Opcilloscope.Tests/OpcUa/Models/MonitoredNodeTests.cs @@ -123,13 +123,22 @@ public void MonitoredNode_StatusString_ReturnsUncertainForUncertainStatus() public void MonitoredNode_TimestampString_ReturnsFormattedTime() { // Arrange - var testTime = new DateTime(2024, 1, 15, 14, 30, 45); + var testTime = new DateTime(2024, 1, 15, 14, 30, 45, DateTimeKind.Local); var node = new MonitoredNode { Timestamp = testTime }; // Assert Assert.Equal("14:30:45", node.TimestampString); } + [Fact] + public void MonitoredNode_TimestampString_ConvertsUtcToLocalTime() + { + var utc = new DateTime(2024, 1, 15, 14, 30, 45, DateTimeKind.Utc); + var node = new MonitoredNode { Timestamp = utc }; + + Assert.Equal(utc.ToLocalTime().ToString("HH:mm:ss"), node.TimestampString); + } + [Fact] public void MonitoredNode_TimestampString_ReturnsDashWhenNull() { @@ -140,6 +149,50 @@ public void MonitoredNode_TimestampString_ReturnsDashWhenNull() Assert.Equal("-", node.TimestampString); } + [Fact] + public void MonitoredNode_IsWritable_UsesCurrentUsersAccessLevel() + { + var node = new MonitoredNode + { + AccessLevel = AccessLevels.CurrentReadOrWrite, + UserAccessLevel = AccessLevels.CurrentRead + }; + + Assert.False(node.IsWritable); + Assert.Equal("R", node.AccessString); + } + + [Fact] + public void MonitoredNode_CanWrite_IsFalseForArrayValue() + { + var node = new MonitoredNode + { + UserAccessLevel = AccessLevels.CurrentReadOrWrite, + ValueRank = ValueRanks.OneDimension + }; + + Assert.True(node.IsWritable); + Assert.False(node.CanWrite); + } + + [Fact] + public void MonitoredNode_CanWrite_IsFalseUntilValueRankIsKnownToBeScalar() + { + var node = new MonitoredNode + { + UserAccessLevel = AccessLevels.CurrentReadOrWrite + }; + + Assert.Equal(ValueRanks.Any, node.ValueRank); + Assert.False(node.CanWrite); + } + + [Fact] + public void MonitoredNode_SyntheticValue_DefaultsToFalse() + { + Assert.False(new MonitoredNode().IsSyntheticValue); + } + [Fact] public void MonitoredNode_RecentlyChanged_ReturnsTrueWithinThreshold() { diff --git a/Tests/Opcilloscope.Tests/OpcUa/OpcUaClientWrapperTests.cs b/Tests/Opcilloscope.Tests/OpcUa/OpcUaClientWrapperTests.cs new file mode 100644 index 0000000..34e70e4 --- /dev/null +++ b/Tests/Opcilloscope.Tests/OpcUa/OpcUaClientWrapperTests.cs @@ -0,0 +1,452 @@ +using System.Reflection; +using Moq; +using Opc.Ua; +using Opc.Ua.Client; +using Opcilloscope.OpcUa; +using Opcilloscope.Tests.Infrastructure; + +namespace Opcilloscope.Tests.OpcUa; + +public class OpcUaClientWrapperTests +{ + [Fact] + public void FilterEndpointCandidates_AutoSecurity_RemovesNoneAndRequiresSecurity() + { + var endpoints = new EndpointDescriptionCollection + { + CreateEndpoint(MessageSecurityMode.None, SecurityPolicies.None), + CreateEndpoint(MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256) + }; + + var result = OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.Anonymous, + securityMode: null, + securityPolicy: null, + endpointUrl: "opc.tcp://server:4840"); + + var selected = Assert.Single(result.Candidates); + Assert.Equal(MessageSecurityMode.SignAndEncrypt, selected.SecurityMode); + Assert.True(result.UseSecurity); + } + + [Fact] + public void FilterEndpointCandidates_AutoSecurity_NoneOnlyServerFailsClosed() + { + var endpoints = new EndpointDescriptionCollection + { + CreateEndpoint(MessageSecurityMode.None, SecurityPolicies.None) + }; + + var error = Assert.Throws(() => + OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.Anonymous, + securityMode: null, + securityPolicy: null, + endpointUrl: "opc.tcp://server:4840")); + + Assert.Contains("no SignAndEncrypt", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FilterEndpointCandidates_ExplicitNone_AllowsNoneOnlyServer() + { + var endpoints = new EndpointDescriptionCollection + { + CreateEndpoint(MessageSecurityMode.None, SecurityPolicies.None) + }; + + var result = OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.Anonymous, + nameof(MessageSecurityMode.None), + SecurityPolicies.None, + "opc.tcp://server:4840"); + + var selected = Assert.Single(result.Candidates); + Assert.Equal(MessageSecurityMode.None, selected.SecurityMode); + Assert.False(result.UseSecurity); + } + + [Fact] + public void FilterEndpointCandidates_NonePolicyWithoutExplicitNoneMode_FailsClosed() + { + var endpoints = new EndpointDescriptionCollection + { + CreateEndpoint(MessageSecurityMode.None, SecurityPolicies.None) + }; + + var error = Assert.Throws(() => + OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.Anonymous, + securityMode: null, + securityPolicy: SecurityPolicies.None, + endpointUrl: "opc.tcp://server:4840")); + + Assert.Contains("SecurityMode=None", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void FilterEndpointCandidates_AutoSecurity_SignOnlyServerFailsClosed() + { + var endpoints = new EndpointDescriptionCollection + { + CreateEndpoint(MessageSecurityMode.Sign, SecurityPolicies.Basic256Sha256) + }; + + var error = Assert.Throws(() => + OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.Anonymous, + securityMode: null, + securityPolicy: null, + endpointUrl: "opc.tcp://server:4840")); + + Assert.Contains("no SignAndEncrypt", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FilterEndpointCandidates_ExplicitSign_AllowsSignOnlyServer() + { + var endpoints = new EndpointDescriptionCollection + { + CreateEndpoint(MessageSecurityMode.Sign, SecurityPolicies.Basic256Sha256) + }; + + var result = OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.Anonymous, + nameof(MessageSecurityMode.Sign), + SecurityPolicies.Basic256Sha256, + "opc.tcp://server:4840"); + + var selected = Assert.Single(result.Candidates); + Assert.Equal(MessageSecurityMode.Sign, selected.SecurityMode); + Assert.True(result.UseSecurity); + } + + [Fact] + public void FilterEndpointCandidates_Anonymous_RemovesEndpointsWithoutAnonymousPolicy() + { + var userNameOnly = CreateEndpoint( + MessageSecurityMode.SignAndEncrypt, + SecurityPolicies.Basic256Sha256, + CreateUserTokenPolicy("username", UserTokenType.UserName)); + var anonymous = CreateEndpoint( + MessageSecurityMode.SignAndEncrypt, + SecurityPolicies.Basic256Sha256, + CreateUserTokenPolicy("anonymous", UserTokenType.Anonymous)); + var endpoints = new EndpointDescriptionCollection { userNameOnly, anonymous }; + + var result = OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.Anonymous, + securityMode: null, + securityPolicy: null, + endpointUrl: "opc.tcp://server:4840"); + + var selected = Assert.Single(result.Candidates); + Assert.Equal("anonymous", Assert.Single(selected.UserIdentityTokens).PolicyId); + } + + [Fact] + public void FilterEndpointCandidates_UserName_RemovesEndpointsWithoutUserNamePolicy() + { + var anonymousOnly = CreateEndpoint( + MessageSecurityMode.SignAndEncrypt, + SecurityPolicies.Basic256Sha256, + CreateUserTokenPolicy("anonymous", UserTokenType.Anonymous)); + var userName = CreateEndpoint( + MessageSecurityMode.SignAndEncrypt, + SecurityPolicies.Basic256Sha256, + CreateUserTokenPolicy("username", UserTokenType.UserName)); + var endpoints = new EndpointDescriptionCollection { anonymousOnly, userName }; + + var result = OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.UserName, + securityMode: null, + securityPolicy: null, + endpointUrl: "opc.tcp://server:4840"); + + var selected = Assert.Single(result.Candidates); + Assert.Equal("username", Assert.Single(selected.UserIdentityTokens).PolicyId); + } + + [Theory] + [InlineData(AuthenticationType.Anonymous, UserTokenType.UserName)] + [InlineData(AuthenticationType.UserName, UserTokenType.Anonymous)] + public void FilterEndpointCandidates_NoMatchingIdentityPolicyFailsClosed( + AuthenticationType authenticationType, + UserTokenType offeredTokenType) + { + var endpoints = new EndpointDescriptionCollection + { + CreateEndpoint( + MessageSecurityMode.SignAndEncrypt, + SecurityPolicies.Basic256Sha256, + CreateUserTokenPolicy("wrong-type", offeredTokenType)) + }; + + var error = Assert.Throws(() => + OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + authenticationType, + securityMode: null, + securityPolicy: null, + endpointUrl: "opc.tcp://server:4840")); + + Assert.True(error.StatusCode == StatusCodes.BadIdentityTokenRejected); + Assert.Contains(authenticationType.ToString(), error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FilterEndpointCandidates_UserNameSign_KeepsOnlyEncryptedUserTokenPolicies() + { + var endpoint = CreateEndpoint( + MessageSecurityMode.Sign, + SecurityPolicies.Basic256Sha256, + CreateUserTokenPolicy("plaintext", UserTokenType.UserName, SecurityPolicies.None), + CreateUserTokenPolicy("encrypted", UserTokenType.UserName, SecurityPolicies.Basic256Sha256)); + var endpoints = new EndpointDescriptionCollection { endpoint }; + + var result = OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.UserName, + nameof(MessageSecurityMode.Sign), + SecurityPolicies.Basic256Sha256, + "opc.tcp://server:4840"); + + var selected = Assert.Single(result.Candidates); + var tokenPolicy = Assert.Single(selected.UserIdentityTokens); + Assert.Equal("encrypted", tokenPolicy.PolicyId); + Assert.Equal(2, endpoint.UserIdentityTokens.Count); + } + + [Fact] + public void FilterEndpointCandidates_UserNameSign_NoneUserTokenPolicyOnlyFailsClosed() + { + var endpoints = new EndpointDescriptionCollection + { + CreateEndpoint( + MessageSecurityMode.Sign, + SecurityPolicies.Basic256Sha256, + CreateUserTokenPolicy("plaintext", UserTokenType.UserName, SecurityPolicies.None)) + }; + + var error = Assert.Throws(() => + OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.UserName, + nameof(MessageSecurityMode.Sign), + SecurityPolicies.Basic256Sha256, + "opc.tcp://server:4840")); + + Assert.True(error.StatusCode == StatusCodes.BadIdentityTokenRejected); + Assert.Contains("compatible UserName", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SignAndEncrypt", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void FilterEndpointCandidates_UserNameSign_EmptyUserTokenPolicyInheritsEndpointPolicy() + { + var endpoints = new EndpointDescriptionCollection + { + CreateEndpoint( + MessageSecurityMode.Sign, + SecurityPolicies.Basic256Sha256, + CreateUserTokenPolicy("inherited", UserTokenType.UserName)) + }; + + var result = OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.UserName, + nameof(MessageSecurityMode.Sign), + SecurityPolicies.Basic256Sha256, + "opc.tcp://server:4840"); + + Assert.Equal("inherited", Assert.Single(Assert.Single(result.Candidates).UserIdentityTokens).PolicyId); + } + + [Fact] + public void FilterEndpointCandidates_UserNameSignAndEncrypt_AllowsNoneUserTokenPolicy() + { + var endpoints = new EndpointDescriptionCollection + { + CreateEndpoint( + MessageSecurityMode.SignAndEncrypt, + SecurityPolicies.Basic256Sha256, + CreateUserTokenPolicy("channel-encrypted", UserTokenType.UserName, SecurityPolicies.None)) + }; + + var result = OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.UserName, + nameof(MessageSecurityMode.SignAndEncrypt), + SecurityPolicies.Basic256Sha256, + "opc.tcp://server:4840"); + + Assert.Equal( + "channel-encrypted", + Assert.Single(Assert.Single(result.Candidates).UserIdentityTokens).PolicyId); + } + + [Theory] + [InlineData("None")] + [InlineData("urn:example:unsupported-security-policy")] + public void FilterEndpointCandidates_UserName_InvalidUserTokenSecurityPolicyFailsClosed( + string tokenSecurityPolicy) + { + var endpoints = new EndpointDescriptionCollection + { + CreateEndpoint( + MessageSecurityMode.SignAndEncrypt, + SecurityPolicies.Basic256Sha256, + CreateUserTokenPolicy( + "unsupported", + UserTokenType.UserName, + tokenSecurityPolicy)) + }; + + var error = Assert.Throws(() => + OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.UserName, + securityMode: null, + securityPolicy: null, + endpointUrl: "opc.tcp://server:4840")); + + Assert.True(error.StatusCode == StatusCodes.BadIdentityTokenRejected); + } + + [Fact] + public void FilterEndpointCandidates_UserNameEncryptedToken_MissingServerCertificateFailsClosed() + { + var endpoint = CreateEndpoint( + MessageSecurityMode.SignAndEncrypt, + SecurityPolicies.Basic256Sha256, + CreateUserTokenPolicy("encrypted", UserTokenType.UserName, SecurityPolicies.Basic256Sha256)); + endpoint.ServerCertificate = default; + var endpoints = new EndpointDescriptionCollection { endpoint }; + + var error = Assert.Throws(() => + OpcUaClientWrapper.FilterEndpointCandidates( + endpoints, + AuthenticationType.UserName, + securityMode: null, + securityPolicy: null, + endpointUrl: "opc.tcp://server:4840")); + + Assert.True(error.StatusCode == StatusCodes.BadIdentityTokenRejected); + } + + [Fact] + public async Task DisconnectAsync_WhenCloseThrows_StillDisposesDetachedSession() + { + var session = new Mock(MockBehavior.Loose); + session.Setup(candidate => candidate.CloseAsync()) + .ThrowsAsync(new InvalidOperationException("close failed")); + + using var client = new OpcUaClientWrapper(); + typeof(OpcUaClientWrapper) + .GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(client, session.Object); + + await client.DisconnectAsync(); + + session.Verify(candidate => candidate.Dispose(), Times.Once); + Assert.Null(client.Session); + } + + [Fact] + public async Task DisconnectAsync_WhenKeepAliveRemovalThrows_StillClosesAndDisposesSession() + { + var session = new Mock(MockBehavior.Loose); + session.SetupRemove( + candidate => candidate.KeepAlive -= It.IsAny()) + .Throws(new InvalidOperationException("event removal failed")); + session.Setup(candidate => candidate.CloseAsync()) + .Returns(Task.FromResult((Opc.Ua.StatusCode)Opc.Ua.StatusCodes.Good)); + + using var client = new OpcUaClientWrapper(); + typeof(OpcUaClientWrapper) + .GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(client, session.Object); + + await client.DisconnectAsync(); + + session.Verify(candidate => candidate.CloseAsync(), Times.Once); + session.Verify(candidate => candidate.Dispose(), Times.Once); + Assert.Null(client.Session); + } + + [Fact] + public void IsCurrentSessionCallback_RejectsDetachedSession() + { + var detached = new Mock().Object; + var current = new Mock().Object; + using var client = new OpcUaClientWrapper(); + typeof(OpcUaClientWrapper) + .GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(client, current); + + Assert.False(client.IsCurrentSessionCallback(detached)); + Assert.True(client.IsCurrentSessionCallback(current)); + } + + private static EndpointDescription CreateEndpoint( + MessageSecurityMode securityMode, + string securityPolicy, + params UserTokenPolicy[] userIdentityTokens) => new() + { + EndpointUrl = "opc.tcp://server:4840", + SecurityMode = securityMode, + SecurityPolicyUri = securityPolicy, + ServerCertificate = new byte[] { 0x01 }, + UserIdentityTokens = userIdentityTokens.Length == 0 + ? new UserTokenPolicyCollection + { + CreateUserTokenPolicy("anonymous", UserTokenType.Anonymous), + CreateUserTokenPolicy("username", UserTokenType.UserName) + } + : new UserTokenPolicyCollection(userIdentityTokens) + }; + + private static UserTokenPolicy CreateUserTokenPolicy( + string policyId, + UserTokenType tokenType, + string? securityPolicy = null) => new(tokenType) + { + PolicyId = policyId, + SecurityPolicyUri = securityPolicy + }; +} + +[Collection("TestServer")] +public class OpcUaClientWrapperLifecycleTests +{ + private readonly TestServerFixture _fixture; + + public OpcUaClientWrapperLifecycleTests(TestServerFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task DisconnectAsync_QueuedBehindConnect_CannotBeOvertakenByLateSessionCreation() + { + using var client = new OpcUaClientWrapper(allowInsecure: true); + + var connectTask = client.ConnectAsync(_fixture.EndpointUrl); + var disconnectTask = client.DisconnectAsync(); + + var connected = await connectTask; + await disconnectTask; + + Assert.True(connected); + Assert.False(client.IsConnected); + Assert.Null(client.Session); + } +} diff --git a/Tests/Opcilloscope.Tests/Tui/MonitoredVariablesViewTests.cs b/Tests/Opcilloscope.Tests/Tui/MonitoredVariablesViewTests.cs index bd6d93d..aee08cf 100644 --- a/Tests/Opcilloscope.Tests/Tui/MonitoredVariablesViewTests.cs +++ b/Tests/Opcilloscope.Tests/Tui/MonitoredVariablesViewTests.cs @@ -19,13 +19,19 @@ namespace Opcilloscope.Tests.Tui; [Collection("Tui")] public class MonitoredVariablesViewTests { - private static MonitoredNode Node(uint handle, string name, bool scope = false) => new() - { - ClientHandle = handle, - NodeId = $"ns=2;s={name}", - DisplayName = name, - IsSelectedForScope = scope, - }; + private static MonitoredNode Node( + uint handle, + string name, + bool scope = false, + long connectionGeneration = 0) => new() + { + ClientHandle = handle, + ConnectionGeneration = connectionGeneration, + NodeId = $"ns=2;s={name}", + DisplayName = name, + Value = name, + IsSelectedForScope = scope, + }; [Fact] public void NewView_StartsEmpty() @@ -73,4 +79,83 @@ public void RemoveVariable_RemovesFromScopeSelection() Assert.Single(view.ScopeSelectedNodes); Assert.Equal("SineWave", view.ScopeSelectedNodes[0].DisplayName); } + + [Fact] + public void ProcessPendingUpdates_UpdateArrivesDuringIdleTransition_KeepsProcessingAlive() + { + using var view = new MonitoredVariablesView(); + var variable = Node(1, "initial"); + view.AddVariable(variable); + variable.Value = "first"; + view.UpdateVariable(variable); + + var keepRunning = view.ProcessPendingUpdatesForTest( + () => + { + variable.Value = "second"; + view.UpdateVariable(variable); + }); + + Assert.True(keepRunning); + Assert.Equal(1, view.PendingUpdateCountForTest); + + Assert.False(view.ProcessPendingUpdatesForTest()); + Assert.Equal("second", view.GetDisplayedValueForTest(1)); + } + + [Theory] + [InlineData(1L)] // Fresh manager created by a reconnect fallback in the same generation. + [InlineData(2L)] // Fresh manager created by a later connection generation. + public void Clear_InvalidatesQueuedAndLateOldSessionUpdatesBeforeClientHandleReuse( + long newConnectionGeneration) + { + var timerToken = new object(); + Func? timerCallback = null; + object? removedTimer = null; + using var view = new MonitoredVariablesView( + (_, callback) => + { + timerCallback = callback; + return timerToken; + }, + token => removedTimer = token); + + var oldVariable = Node(1, "old-live", connectionGeneration: 1); + view.AddVariable(oldVariable); + oldVariable.Value = "old-pending"; + view.UpdateVariable(oldVariable); + Assert.NotNull(timerCallback); + + view.Clear(); + view.AddVariable(Node( + 1, + "new-session", + connectionGeneration: newConnectionGeneration)); + + // A notification already in flight from the old SubscriptionManager can + // reach the view after Clear. Its reused handle must not target the new row. + oldVariable.Value = "late-old-session-update"; + view.UpdateVariable(oldVariable); + + Assert.Same(timerToken, removedTimer); + Assert.False(timerCallback!()); + Assert.False(view.ProcessPendingUpdatesForTest()); + Assert.Equal(0, view.PendingUpdateCountForTest); + Assert.Equal("new-session", view.GetDisplayedValueForTest(1)); + } + + [Fact] + public void ReconcileVariables_RebuildsMembershipAndPreservesScopeSelection() + { + using var view = new MonitoredVariablesView(); + var retained = Node(2, "retained", connectionGeneration: 2, scope: true); + view.AddVariable(Node(1, "removed", connectionGeneration: 1)); + + view.ReconcileVariables([retained]); + + Assert.Null(view.GetDisplayedValueForTest(1)); + Assert.Equal("retained", view.GetDisplayedValueForTest(2)); + Assert.True(retained.IsSelectedForScope); + Assert.Equal(1, view.ScopeSelectionCount); + } } diff --git a/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs b/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs index 162dc0f..34c6f48 100644 --- a/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs +++ b/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using Opc.Ua; using Opcilloscope.OpcUa.Models; using Opcilloscope.Utilities; @@ -858,4 +859,149 @@ public void StartRecording_ClearsStaleQueueFromPreviousSession() Assert.DoesNotContain("stale", content); Assert.Equal(0, _manager.RecordCount); } + + [Fact] + public void RecordValue_WhenBoundedQueueIsFull_DropsNewRecordAndCountsIt() + { + var writer = new BlockingTextWriter(); + using var manager = new CsvRecordingManager(_logger, queueCapacity: 1, _ => writer); + Assert.True(manager.StartRecording("unused.csv")); + + manager.RecordValue(CreateNode("first")); + Assert.True(writer.WaitUntilRecordWriteStarts(TimeSpan.FromSeconds(5))); + + // The writer has consumed the first record and is deliberately stalled. + // One record fits in the channel; the next is dropped without blocking + // the OPC notification thread. + manager.RecordValue(CreateNode("second")); + manager.RecordValue(CreateNode("third")); + + Assert.Equal(1, manager.DroppedRecordCount); + + writer.Release(); + manager.StopRecording(); + + Assert.Equal(2, manager.RecordCount); + Assert.Contains("first", writer.ToString()); + Assert.Contains("second", writer.ToString()); + Assert.DoesNotContain("third", writer.ToString()); + } + + [Fact] + public async Task StopRecordingAsync_WhenWriterDoesNotFinish_ReportsTimeoutAndKeepsSessionTracked() + { + var writer = new BlockingTextWriter(); + using var manager = new CsvRecordingManager(_logger, queueCapacity: 1, _ => writer); + Assert.True(manager.StartRecording("unused.csv")); + manager.RecordValue(CreateNode("first")); + Assert.True(writer.WaitUntilRecordWriteStarts(TimeSpan.FromSeconds(5))); + + var timedOut = await manager.StopRecordingAsync(TimeSpan.FromMilliseconds(50)); + + Assert.False(timedOut.Completed); + Assert.True(manager.IsStopping); + Assert.False(manager.StartRecording("second.csv")); + + writer.Release(); + var completed = await manager.StopRecordingAsync(System.Threading.Timeout.InfiniteTimeSpan); + + Assert.True(completed.Completed); + Assert.False(manager.IsStopping); + Assert.Equal(1, completed.RecordCount); + } + + [Fact] + public void StopRecording_WhenRecordWriteFails_ReportsFailedRecordAndError() + { + var writer = new ThrowingRecordWriter(); + using var manager = new CsvRecordingManager(_logger, queueCapacity: 10, _ => writer); + Assert.True(manager.StartRecording("unused.csv")); + + manager.RecordValue(CreateNode("will-fail")); + manager.StopRecording(); + + Assert.Equal(0, manager.RecordCount); + Assert.Equal(1, manager.FailedRecordCount); + Assert.Contains("failed", manager.LastError, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Dispose_WhenRecordWriteFails_LogsDataLoss() + { + var writer = new ThrowingRecordWriter(); + var manager = new CsvRecordingManager(_logger, queueCapacity: 10, _ => writer); + Assert.True(manager.StartRecording("unused.csv")); + manager.RecordValue(CreateNode("will-fail")); + + manager.Dispose(); + + Assert.Contains( + _logger.GetEntries(), + entry => entry.Level == LogLevel.Error + && entry.Message.Contains("data loss", StringComparison.OrdinalIgnoreCase)); + } + + private static MonitoredNode CreateNode(string value) => new() + { + DisplayName = value, + NodeId = new NodeId(value, namespaceIndex: 1), + Value = value, + RawValue = value + }; + + private sealed class BlockingTextWriter : StringWriter + { + private readonly ManualResetEventSlim _recordWriteStarted = new(); + private readonly ManualResetEventSlim _release = new(); + private int _lineCount; + private int _disposed; + + public override Encoding Encoding => Encoding.UTF8; + + public override void WriteLine(string? value) + { + if (Interlocked.Increment(ref _lineCount) > 1) + { + _recordWriteStarted.Set(); + if (!_release.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException("Test writer was not released"); + } + } + + base.WriteLine(value); + } + + public bool WaitUntilRecordWriteStarts(TimeSpan timeout) => _recordWriteStarted.Wait(timeout); + + public void Release() => _release.Set(); + + protected override void Dispose(bool disposing) + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _release.Set(); + _recordWriteStarted.Dispose(); + _release.Dispose(); + base.Dispose(disposing); + } + } + + private sealed class ThrowingRecordWriter : StringWriter + { + private int _lineCount; + + public override void WriteLine(string? value) + { + if (Interlocked.Increment(ref _lineCount) > 1) + { + throw new IOException("simulated storage failure"); + } + + base.WriteLine(value); + } + } } diff --git a/Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs b/Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs index f55e64c..926dda4 100644 --- a/Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs +++ b/Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs @@ -137,12 +137,16 @@ public void ErrorQuery_WithNoApp_Throws() public void Invoke_WithApp_DelegatesToApplication() { var app = new Mock(); + app.Setup(a => a.Invoke(It.IsAny())) + .Callback(callback => callback()); TerminalUi.App = app.Object; - Action action = () => { }; + var ran = false; + Action action = () => ran = true; TerminalUi.Invoke(action); - app.Verify(a => a.Invoke(action), Times.Once); + Assert.True(ran); + app.Verify(a => a.Invoke(It.IsAny()), Times.Once); } [Fact] diff --git a/Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs b/Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs index eb6e0e1..b1bd88d 100644 --- a/Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs +++ b/Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs @@ -36,11 +36,72 @@ public void Run_WithNoApp_DoesNotThrow() public void Run_WithApp_MarshalsThroughApplicationInvoke() { var app = new Mock(); + app.Setup(a => a.Invoke(It.IsAny())) + .Callback(callback => callback()); TerminalUi.App = app.Object; - Action action = () => { }; + var ran = false; + Action action = () => ran = true; UiThread.Run(action); - app.Verify(a => a.Invoke(action), Times.Once); + Assert.True(ran); + app.Verify(a => a.Invoke(It.IsAny()), Times.Once); + } + + [Fact] + public void RunAsync_WithNoApp_ThrowsInsteadOfReturningANeverCompletingTask() + { + Assert.Throws(() => + { + _ = UiThread.RunAsync(() => 42); + }); + } + + [Fact] + public async Task RunAsync_WithApp_CompletesWithCallbackResult() + { + var app = new Mock(); + app.Setup(a => a.Invoke(It.IsAny())) + .Callback(action => action()); + TerminalUi.App = app.Object; + + var result = await UiThread.RunAsync(() => 42); + + Assert.Equal(42, result); + } + + [Fact] + public async Task RunAsync_WhenCallbackThrows_PropagatesException() + { + var app = new Mock(); + app.Setup(a => a.Invoke(It.IsAny())) + .Callback(action => action()); + TerminalUi.App = app.Object; + + var error = await Assert.ThrowsAsync( + () => UiThread.RunAsync(() => throw new InvalidOperationException("boom"))); + + Assert.Equal("boom", error.Message); + } + + [Fact] + public async Task RunAsync_WhenMainLoopStopsBeforeDeferredInvoke_FailsInsteadOfHanging() + { + Action? queued = null; + var ran = false; + var app = new Mock(); + app.Setup(a => a.Invoke(It.IsAny())) + .Callback(action => queued = action); + TerminalUi.App = app.Object; + + var task = UiThread.RunAsync(() => ran = true); + Assert.NotNull(queued); + + TerminalUi.BeginShutdown(); + + var error = await Assert.ThrowsAsync(() => task); + queued!(); + Assert.Contains("main loop stopped", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(ran); } } diff --git a/Utilities/CsvRecordingManager.cs b/Utilities/CsvRecordingManager.cs index c82f0b9..1174ced 100644 --- a/Utilities/CsvRecordingManager.cs +++ b/Utilities/CsvRecordingManager.cs @@ -1,9 +1,21 @@ -using System.Collections.Concurrent; using System.Globalization; +using System.Threading.Channels; using Opcilloscope.OpcUa.Models; namespace Opcilloscope.Utilities; +public readonly record struct RecordingStopResult( + bool Completed, + long RecordCount, + long DroppedRecordCount, + long FailedRecordCount, + string? ErrorMessage) +{ + public bool HasDataLoss => DroppedRecordCount > 0 + || FailedRecordCount > 0 + || !string.IsNullOrEmpty(ErrorMessage); +} + /// /// Manages CSV recording of monitored variable value changes. /// Writes data to file in real-time as values change using a background queue. @@ -138,17 +150,28 @@ private readonly record struct RecordSnapshot( string Value, string Status); + private sealed class RecordingSession + { + public required string FilePath { get; init; } + public required TextWriter Writer { get; init; } + public required Channel Queue { get; init; } + public required DateTime StartTime { get; init; } + public Task WriteTask { get; set; } = Task.CompletedTask; + public long RecordCount; + public long DroppedRecordCount; + public long FailedRecordCount; + public string? ErrorMessage; + } + private readonly Logger _logger; - private StreamWriter? _writer; - private string? _filePath; - private bool _isRecording; private readonly object _lock = new(); - private DateTime _recordingStartTime; - private long _recordCount; - private readonly ConcurrentQueue _recordQueue = new(); - private readonly SemaphoreSlim _queueSemaphore = new(0); - private Task? _writeTask; - private CancellationTokenSource? _cancellationTokenSource; + private readonly int _queueCapacity; + private readonly Func _writerFactory; + private RecordingSession? _session; + private RecordingSession? _stoppingSession; + private RecordingSession? _lastSession; + + private const int DefaultQueueCapacity = 10_000; public event Action? RecordingStateChanged; @@ -158,27 +181,114 @@ public bool IsRecording { lock (_lock) { - return _isRecording; + return _session is not null; } } } - public string? FilePath => _filePath; - public long RecordCount => Interlocked.Read(ref _recordCount); + public bool IsStopping + { + get + { + lock (_lock) + { + return _stoppingSession is not null; + } + } + } + + public string? FilePath + { + get + { + lock (_lock) + { + return (_session ?? _lastSession)?.FilePath; + } + } + } + + public long RecordCount + { + get + { + RecordingSession? session; + lock (_lock) + { + session = _session ?? _lastSession; + } + return session is null ? 0 : Interlocked.Read(ref session.RecordCount); + } + } + + public long DroppedRecordCount + { + get + { + RecordingSession? session; + lock (_lock) + { + session = _session ?? _lastSession; + } + return session is null ? 0 : Interlocked.Read(ref session.DroppedRecordCount); + } + } + + public long FailedRecordCount + { + get + { + RecordingSession? session; + lock (_lock) + { + session = _session ?? _stoppingSession ?? _lastSession; + } + return session is null ? 0 : Interlocked.Read(ref session.FailedRecordCount); + } + } + + public string? LastError + { + get + { + RecordingSession? session; + lock (_lock) + { + session = _session ?? _stoppingSession ?? _lastSession; + } + return session is null ? null : Volatile.Read(ref session.ErrorMessage); + } + } + public TimeSpan RecordingDuration { get { lock (_lock) { - return _isRecording ? DateTime.Now - _recordingStartTime : TimeSpan.Zero; + return _session is null ? TimeSpan.Zero : DateTime.Now - _session.StartTime; } } } public CsvRecordingManager(Logger logger) + : this(logger, DefaultQueueCapacity, path => new StreamWriter(path, append: false)) { + } + + internal CsvRecordingManager( + Logger logger, + int queueCapacity, + Func writerFactory) + { + if (queueCapacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(queueCapacity)); + } + _logger = logger; + _queueCapacity = queueCapacity; + _writerFactory = writerFactory; } /// @@ -188,41 +298,51 @@ public bool StartRecording(string filePath) { lock (_lock) { - if (_isRecording) + if (_session is not null || _stoppingSession is not null) { _logger.Warning("Recording is already in progress"); return false; } + TextWriter? writer = null; try { - _filePath = filePath; - _writer = new StreamWriter(_filePath, append: false); + writer = _writerFactory(filePath); // Write CSV header - _writer.WriteLine("Timestamp,DisplayName,NodeId,Value,Status"); - _writer.Flush(); + writer.WriteLine("Timestamp,DisplayName,NodeId,Value,Status"); + writer.Flush(); - // Discard any stale snapshots left over from a previous session - // so they cannot cross-contaminate the new recording. - while (_recordQueue.TryDequeue(out _)) { } - - _isRecording = true; - _recordingStartTime = DateTime.Now; - _recordCount = 0; + var queue = Channel.CreateBounded(new BoundedChannelOptions(_queueCapacity) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait + }); + var session = new RecordingSession + { + FilePath = filePath, + Writer = writer, + Queue = queue, + StartTime = DateTime.Now + }; - // Start background writer task - _cancellationTokenSource = new CancellationTokenSource(); - _writeTask = Task.Run(() => WriteQueuedRecordsAsync(_cancellationTokenSource.Token)); + _session = session; + session.WriteTask = Task.Run(() => WriteQueuedRecordsAsync(session)); - _logger.Info($"Started recording to {_filePath}"); + _logger.Info($"Started recording to {filePath}"); } catch (Exception ex) { _logger.Error($"Failed to start recording: {ex.Message}"); - _writer?.Dispose(); - _writer = null; - _filePath = null; + try + { + writer?.Dispose(); + } + catch (Exception disposeException) + { + _logger.Error($"Failed to close recording file after start error: {disposeException.Message}"); + } return false; } } @@ -237,97 +357,110 @@ public bool StartRecording(string filePath) /// Stop recording and close the file. /// public void StopRecording() - { - Task? taskToWait = null; - CancellationTokenSource? ctsToDispose = null; + => StopRecordingAsync(System.Threading.Timeout.InfiniteTimeSpan).GetAwaiter().GetResult(); - lock (_lock) + /// + /// Stops accepting records and asynchronously drains every accepted record. + /// A timeout is reported explicitly; the session remains tracked and blocks a + /// new recording until its writer actually closes. + /// + public async Task StopRecordingAsync(TimeSpan? timeout = null) + { + var wait = timeout ?? TimeSpan.FromSeconds(10); + if (wait < TimeSpan.Zero && wait != System.Threading.Timeout.InfiniteTimeSpan) { - if (!_isRecording) - { - return; - } - - _isRecording = false; - taskToWait = _writeTask; - ctsToDispose = _cancellationTokenSource; + throw new ArgumentOutOfRangeException(nameof(timeout)); } - // 1. Signal cancellation BEFORE waiting - ctsToDispose?.Cancel(); + RecordingSession? session; + var beganStopping = false; - // 2. Wait for write loop to complete with timeout - // This must happen BEFORE disposing the writer to prevent ObjectDisposedException - bool taskCompleted = false; - if (taskToWait != null) + lock (_lock) { - try - { - // Wait with a reasonable timeout - if the task doesn't complete, - // we still need to clean up, but the writer access is protected by the lock - taskCompleted = taskToWait.Wait(TimeSpan.FromSeconds(10)); - if (!taskCompleted) - { - _logger.Warning("Background writer task did not complete within timeout"); - } - } - catch (AggregateException ex) + session = _session ?? _stoppingSession; + if (session is null) { - // Task.Wait wraps exceptions in AggregateException - foreach (var inner in ex.InnerExceptions) - { - if (inner is not OperationCanceledException) - { - _logger.Error($"Error in background writer: {inner.Message}"); - } - } + return CreateStopResult(_lastSession, completed: true); } - catch (Exception ex) + + if (_session is not null) { - _logger.Error($"Error waiting for background writer: {ex.Message}"); + _session = null; + _stoppingSession = session; + _lastSession = session; + beganStopping = true; } } - // 3. Only dispose writer AFTER the write loop has exited (or timed out) - lock (_lock) + if (beganStopping) { - try - { - _writer?.Flush(); - _writer?.Dispose(); - _writer = null; + // Completing this session's private channel prevents a late callback + // from crossing into the next recording and lets the reader drain all + // accepted records before it closes its writer. + session.Queue.Writer.TryComplete(); + RecordingStateChanged?.Invoke(false); + } - var duration = DateTime.Now - _recordingStartTime; - _logger.Info($"Stopped recording. {_recordCount} records written to {_filePath} (duration: {duration:hh\\:mm\\:ss})"); - } - catch (Exception ex) - { - _logger.Error($"Error closing recording file: {ex.Message}"); - } - finally + bool completed; + if (wait == System.Threading.Timeout.InfiniteTimeSpan) + { + await session.WriteTask.ConfigureAwait(false); + completed = true; + } + else + { + completed = ReferenceEquals( + await Task.WhenAny(session.WriteTask, Task.Delay(wait)).ConfigureAwait(false), + session.WriteTask); + if (completed) { - ctsToDispose?.Dispose(); - _cancellationTokenSource = null; - _writeTask = null; + await session.WriteTask.ConfigureAwait(false); } } - // Raise the event after releasing the lock to avoid invoking - // subscriber callbacks while holding it. - RecordingStateChanged?.Invoke(false); + var result = CreateStopResult(session, completed); + if (!completed) + { + _logger.Error( + $"Recording writer did not finish within {wait}. The file is still open; " + + "new recordings remain disabled until it closes."); + return result; + } + + var duration = DateTime.Now - session.StartTime; + var lossSuffix = result.HasDataLoss + ? $", {result.DroppedRecordCount} queue drops, {result.FailedRecordCount} failed writes" + : string.Empty; + _logger.Info( + $"Stopped recording. {result.RecordCount} records written to {session.FilePath}" + + $" (duration: {duration:hh\\:mm\\:ss}{lossSuffix})"); + return result; } + private static RecordingStopResult CreateStopResult(RecordingSession? session, bool completed) => + session is null + ? new RecordingStopResult(completed, 0, 0, 0, null) + : new RecordingStopResult( + completed, + Interlocked.Read(ref session.RecordCount), + Interlocked.Read(ref session.DroppedRecordCount), + Interlocked.Read(ref session.FailedRecordCount), + Volatile.Read(ref session.ErrorMessage)); + /// /// Record a value change. Called from the subscription's ValueChanged event. /// Queues the value for asynchronous writing to avoid blocking the notification thread. /// public void RecordValue(MonitoredNode item) { - if (!IsRecording) + RecordingSession? session; + lock (_lock) { - return; + session = _session; } + if (session is null) return; + // Capture an immutable snapshot at enqueue time. The OPC notification // thread mutates the live MonitoredNode in place, so queuing the // reference would let the writer serialize a newer state than was @@ -342,62 +475,71 @@ public void RecordValue(MonitoredNode item) string.IsNullOrEmpty(item.RawValue) ? item.Value : item.RawValue, item.StatusString); - // Queue the snapshot for background writing (non-blocking) - _recordQueue.Enqueue(snapshot); - _queueSemaphore.Release(); + long dropped = 0; + lock (_lock) + { + // The snapshot was built outside the lock. If Stop/Start happened + // meanwhile, discard it instead of contaminating the new file. + if (!ReferenceEquals(_session, session)) + { + return; + } + + if (!session.Queue.Writer.TryWrite(snapshot)) + { + dropped = Interlocked.Increment(ref session.DroppedRecordCount); + } + } + + if (dropped == 1) + { + _logger.Warning( + $"CSV recording queue reached its {_queueCapacity:N0}-record capacity; new records will be dropped until storage catches up"); + } } /// /// Background task that processes the queue and writes records to the file. /// - private async Task WriteQueuedRecordsAsync(CancellationToken cancellationToken) + private async Task WriteQueuedRecordsAsync(RecordingSession session) { try { - while (!cancellationToken.IsCancellationRequested) + await foreach (var item in session.Queue.Reader.ReadAllAsync()) { - // Wait for items in the queue or cancellation - try - { - await _queueSemaphore.WaitAsync(cancellationToken); - } - catch (OperationCanceledException) - { - // Cancellation requested - exit the loop cleanly - break; - } - - // Check cancellation again before processing (defensive) - if (cancellationToken.IsCancellationRequested) - { - break; - } - - // Process all queued items - while (_recordQueue.TryDequeue(out var item)) - { - // Check cancellation between items for faster shutdown - if (cancellationToken.IsCancellationRequested) - { - // Re-queue the item so it can be processed in the finally block - _recordQueue.Enqueue(item); - break; - } - WriteRecord(item); - } + WriteRecord(session, item); } } catch (Exception ex) { - _logger.Error($"Error in background writer: {ex.Message}"); + RegisterSessionError(session, "Background writer failed", ex); } finally { - // Write any remaining queued items before exiting - // This runs BEFORE StopRecording disposes the writer (due to the Wait) - while (_recordQueue.TryDequeue(out var item)) + try + { + session.Writer.Flush(); + } + catch (Exception ex) + { + RegisterSessionError(session, "Final recording flush failed", ex); + } + + try { - WriteRecord(item); + session.Writer.Dispose(); + } + catch (Exception ex) + { + RegisterSessionError(session, "Recording file close failed", ex); + } + + lock (_lock) + { + if (ReferenceEquals(_stoppingSession, session)) + { + _stoppingSession = null; + } } } } @@ -405,61 +547,60 @@ private async Task WriteQueuedRecordsAsync(CancellationToken cancellationToken) /// /// Write a single record to the CSV file. /// - private void WriteRecord(RecordSnapshot item) + private void WriteRecord(RecordingSession session, RecordSnapshot item) { - lock (_lock) + try { - // Only the writer guard here: _isRecording is cleared before the - // shutdown drain, so checking it would discard the in-flight tail - // (flush-on-stop and re-queue-on-cancel records). - if (_writer == null) + // Use ISO 8601 timestamp format with milliseconds for precision. + // InvariantCulture is required: the ':' custom-format specifier + // is replaced by the culture's time separator (fi-FI uses '.') + // and the culture's default calendar applies (th-TH uses the + // Buddhist calendar), which would break the ISO 8601 contract. + // All timestamps are normalized to UTC with an explicit 'Z' + // designator: OPC UA source timestamps are UTC while the + // no-timestamp fallback used to be local time, so a single file + // could silently mix timezones with no way to tell them apart. + var ts = item.Timestamp ?? DateTime.UtcNow; + if (ts.Kind == DateTimeKind.Local) { - return; + ts = ts.ToUniversalTime(); } - - try + // Kind=Unspecified is treated as UTC (the OPC UA convention) + // rather than local, so the recorded instant never shifts. + var timestamp = ts.ToString("yyyy-MM-ddTHH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); + + // Escape values for CSV (RFC 4180 quoting plus formula + // injection neutralization for server-supplied fields). + // The timestamp is generated locally in a fixed format, so it + // needs no escaping; the header line is a constant. + var displayName = EscapeCsvField(item.DisplayName); + var nodeId = EscapeCsvField(item.NodeId); + var value = EscapeCsvField(item.Value); + var status = EscapeCsvField(item.Status); + + session.Writer.WriteLine($"{timestamp},{displayName},{nodeId},{value},{status}"); + + // Flush periodically (every 10 records) for durability without too much I/O + var count = Interlocked.Increment(ref session.RecordCount); + if (count % 10 == 0) { - // Use ISO 8601 timestamp format with milliseconds for precision. - // InvariantCulture is required: the ':' custom-format specifier - // is replaced by the culture's time separator (fi-FI uses '.') - // and the culture's default calendar applies (th-TH uses the - // Buddhist calendar), which would break the ISO 8601 contract. - // All timestamps are normalized to UTC with an explicit 'Z' - // designator: OPC UA source timestamps are UTC while the - // no-timestamp fallback used to be local time, so a single file - // could silently mix timezones with no way to tell them apart. - var ts = item.Timestamp ?? DateTime.UtcNow; - if (ts.Kind == DateTimeKind.Local) - { - ts = ts.ToUniversalTime(); - } - // Kind=Unspecified is treated as UTC (the OPC UA convention) - // rather than local, so the recorded instant never shifts. - var timestamp = ts.ToString("yyyy-MM-ddTHH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); - - // Escape values for CSV (RFC 4180 quoting plus formula - // injection neutralization for server-supplied fields). - // The timestamp is generated locally in a fixed format, so it - // needs no escaping; the header line is a constant. - var displayName = EscapeCsvField(item.DisplayName); - var nodeId = EscapeCsvField(item.NodeId); - var value = EscapeCsvField(item.Value); - var status = EscapeCsvField(item.Status); - - _writer.WriteLine($"{timestamp},{displayName},{nodeId},{value},{status}"); - - // Flush periodically (every 10 records) for durability without too much I/O - Interlocked.Increment(ref _recordCount); - if (_recordCount % 10 == 0) - { - _writer.Flush(); - } - } - catch (Exception ex) - { - _logger.Warning($"Error writing record: {ex.Message}"); + session.Writer.Flush(); } } + catch (Exception ex) + { + Interlocked.Increment(ref session.FailedRecordCount); + RegisterSessionError(session, "Writing a recording row failed", ex); + } + } + + private void RegisterSessionError(RecordingSession session, string context, Exception exception) + { + var message = $"{context}: {exception.Message}"; + if (Interlocked.CompareExchange(ref session.ErrorMessage, message, null) is null) + { + _logger.Error(message); + } } private static string EscapeCsvField(string field) @@ -516,6 +657,19 @@ private static string NeutralizeFormulaInjection(string field) public void Dispose() { - StopRecording(); + var result = StopRecordingAsync(TimeSpan.FromSeconds(10)).GetAwaiter().GetResult(); + if (!result.Completed) + { + _logger.Error("Recording shutdown is incomplete; the output file may be truncated."); + } + else if (result.HasDataLoss) + { + _logger.Error( + "Recording closed with data loss: " + + $"{result.DroppedRecordCount} dropped, {result.FailedRecordCount} failed" + + (string.IsNullOrEmpty(result.ErrorMessage) + ? "." + : $". {result.ErrorMessage}")); + } } } diff --git a/Utilities/TerminalUi.cs b/Utilities/TerminalUi.cs index 780b421..35aaf99 100644 --- a/Utilities/TerminalUi.cs +++ b/Utilities/TerminalUi.cs @@ -19,11 +19,90 @@ namespace Opcilloscope.Utilities; /// public static class TerminalUi { + private static readonly object AppLock = new(); + private static IApplication? _app; + private static CancellationTokenSource _shutdown = CreateShutdownSource(isShutdown: true); + /// /// The running Terminal.Gui application instance. Set once by Program.Main /// right after Application.Create(); null in headless unit tests. /// - public static IApplication? App { get; set; } + public static IApplication? App + { + get + { + lock (AppLock) + { + return _app; + } + } + set + { + CancellationTokenSource previousShutdown; + lock (AppLock) + { + if (ReferenceEquals(_app, value)) + { + return; + } + + previousShutdown = _shutdown; + _app = value; + _shutdown = CreateShutdownSource(isShutdown: value is null); + } + + // Cancellation callbacks may themselves touch TerminalUi. Never + // invoke them while holding AppLock. + previousShutdown.Cancel(); + previousShutdown.Dispose(); + } + } + + /// + /// Signals that the application's final main-loop session has ended. Any + /// queued/awaited UI dispatches are failed instead of being left pending. + /// + public static void BeginShutdown() + { + CancellationTokenSource shutdown; + lock (AppLock) + { + shutdown = _shutdown; + } + + try + { + shutdown.Cancel(); + } + catch (ObjectDisposedException) + { + // App was replaced concurrently; its previous token was already + // cancelled by the property setter. + } + } + + internal static bool TryGetDispatchContext( + out IApplication? app, + out CancellationToken shutdownToken) + { + lock (AppLock) + { + app = _app; + shutdownToken = _shutdown.Token; + return app is not null && !shutdownToken.IsCancellationRequested; + } + } + + private static CancellationTokenSource CreateShutdownSource(bool isShutdown) + { + var source = new CancellationTokenSource(); + if (isShutdown) + { + source.Cancel(); + } + + return source; + } private static IApplication RequireApp() => App ?? throw new InvalidOperationException("No Terminal.Gui application is running (TerminalUi.App is not set)."); @@ -34,7 +113,18 @@ private static IApplication RequireApp() => /// public static void Invoke(Action action) { - App?.Invoke(action); + if (!TryGetDispatchContext(out var app, out var shutdownToken)) + { + return; + } + + app!.Invoke(() => + { + if (!shutdownToken.IsCancellationRequested) + { + action(); + } + }); } /// diff --git a/Utilities/UiThread.cs b/Utilities/UiThread.cs index 9132c35..cb34da5 100644 --- a/Utilities/UiThread.cs +++ b/Utilities/UiThread.cs @@ -23,20 +23,57 @@ public static void Run(Action action) /// public static Task RunAsync(Func func) { - var app = TerminalUi.App - ?? throw new InvalidOperationException("No Terminal.Gui application is running (TerminalUi.App is not set)."); + if (!TerminalUi.TryGetDispatchContext(out var app, out var shutdownToken)) + { + throw new InvalidOperationException("No active Terminal.Gui main loop is available for UI dispatch."); + } + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - app.Invoke(() => + var cancellationRegistration = shutdownToken.Register(() => + tcs.TrySetException(new InvalidOperationException( + "The Terminal.Gui main loop stopped before the UI callback could run."))); + _ = tcs.Task.ContinueWith( + _ => cancellationRegistration.Dispose(), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + try { - try + app!.Invoke(() => { - tcs.SetResult(func()); - } - catch (Exception ex) - { - tcs.SetException(ex); - } - }); + if (shutdownToken.IsCancellationRequested) + { + return; + } + + try + { + tcs.TrySetResult(func()); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + } + catch + { + cancellationRegistration.Dispose(); + throw; + } + return tcs.Task; } + + /// + /// Executes an action on the UI thread and completes once it has run. + /// Unlike , this throws when no application is running so + /// callers cannot await a callback that will never be scheduled. + /// + public static Task RunAsync(Action action) => RunAsync(() => + { + action(); + return true; + }); } diff --git a/docs/TESTING.md b/docs/TESTING.md index f9e3d4f..9fcdaec 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,7 +1,6 @@ # Testing opcilloscope -The suite has three layers. All run under `dotnet test` with **no extra language -toolchain** (pure .NET). +The suite has three layers and uses only .NET plus the operating-system facilities noted below. ## 1. Unit / integration tests (existing) @@ -9,10 +8,14 @@ toolchain** (pure .NET). utility layers, plus integration tests against the in-process `Opcilloscope.TestServer`. ```bash -dotnet test # everything -dotnet test --filter "FullyQualifiedName~Integration" +dotnet test Opcilloscope.sln +dotnet test Tests/Opcilloscope.Tests/Opcilloscope.Tests.csproj \ + --filter "FullyQualifiedName~Integration" ``` +The solution contains the cross-platform unit, integration, and component tests. The Linux-only +black-box project is invoked explicitly as described in layer 3. + ## 2. In-process TUI component tests `Tests/Opcilloscope.Tests/Tui/` — constructs the **real** Terminal.Gui views and dialogs @@ -20,12 +23,13 @@ dotnet test --filter "FullyQualifiedName~Integration" their observable behaviour and state. ```bash -dotnet test --filter "FullyQualifiedName~Tui" +dotnet test Tests/Opcilloscope.Tests/Opcilloscope.Tests.csproj \ + --filter "FullyQualifiedName~Tui" ``` These tests live in a **non-parallel xUnit collection** (`[Collection("Tui")]`) because -Terminal.Gui's `Application` is global mutable state and must not be shared across parallel -tests. +the app's `TerminalUi.App` reference and Terminal.Gui theme/driver state are process-global and +must not be shared across parallel tests. > **Why these assert on state, not rendered cells.** Terminal.Gui **2.4.5 (stable)** does not > expose a public headless driver: `Application.Create()` leaves `Driver` null until the real @@ -36,15 +40,22 @@ tests. ## 3. Black-box end-to-end (PTY) tests -`Tests/Opcilloscope.E2ETests/` — launches the **published binary** attached to a pseudo- -terminal, reconstructs the rendered screen from the VT/ANSI output, and asserts on it. Pure -.NET (uses the system `script` PTY + an in-process ANSI→grid parser); no Node/Python. +`Tests/Opcilloscope.E2ETests/` — launches the **published binary** attached to a sized Linux +pseudo-terminal, reconstructs the rendered screen from the VT/ANSI output, and asserts on it. The +harness uses .NET plus Linux libc (`openpty`/`posix_spawn`); it needs no Node, Python, `script`, or +external terminal emulator. ```bash -dotnet test Tests/Opcilloscope.E2ETests # publishes the binary on first run +# Linux only; creates and removes a fresh temporary publish: +dotnet test Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj + +# Or exercise one exact pre-published artifact, as CI does: +OPCILLOSCOPE_BIN="$PWD/publish/opcilloscope" \ + dotnet test Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj ``` -See `Tests/Opcilloscope.E2ETests/README.md` for details and CI notes. +The project intentionally stays out of `Opcilloscope.sln`, preserving normal solution builds and +tests on macOS and Windows. If `OPCILLOSCOPE_BIN` is set but missing, the suite fails rather than +silently publishing a different binary. -> **Note:** the E2E project is not yet delivered — it arrives in the follow-up -> layer-2 (PTY harness) PR. The references above describe the planned layout. +See `Tests/Opcilloscope.E2ETests/README.md` for details and CI notes. diff --git a/docs/deep-repo-review-2026-07-07.md b/docs/deep-repo-review-2026-07-07.md index ccf69e7..21f92b2 100644 --- a/docs/deep-repo-review-2026-07-07.md +++ b/docs/deep-repo-review-2026-07-07.md @@ -1,5 +1,18 @@ # Deep repository review — pre-release (2026-07-07) +> [!IMPORTANT] +> **Historical snapshot — superseded by the 2026-07-11 release sweep.** +> The findings, source line numbers, workflow descriptions, warning counts, and +> test totals below describe the repository when this review was performed; +> they are not the current release status. The later sweep resolved the +> remaining timestamp/scope-key issues, lifecycle and reconnect races, static +> Terminal.Gui deprecations, SignAndEncrypt-by-default endpoint selection, +> packaging and installer gaps, and added locked six-RID publishing plus +> real-PTY E2E tests. +> Use the current [README](../README.md), [testing guide](TESTING.md), and +> [CI/release workflows](../.github/workflows/) as operational guidance. The +> original review body is retained unchanged as an audit record. + Scope: full read-through of all production source (`OpcUa/`, `App/`, `Configuration/`, `Utilities/`, `Program.cs`), project files, and CI/CD workflows, plus a clean Release build and full test run. diff --git a/global.json b/global.json new file mode 100644 index 0000000..e6b463b --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.109", + "rollForward": "disable", + "allowPrerelease": false + } +} diff --git a/install.ps1 b/install.ps1 index bd75694..98b5cb4 100644 --- a/install.ps1 +++ b/install.ps1 @@ -4,18 +4,61 @@ $ErrorActionPreference = "Stop" $Repo = "SquareWaveSystems/opcilloscope" -$InstallDir = if ($env:OPCILLOSCOPE_INSTALL_DIR) { $env:OPCILLOSCOPE_INSTALL_DIR } else { "$env:LOCALAPPDATA\Opcilloscope" } +$UsingCustomInstallDir = -not [string]::IsNullOrWhiteSpace($env:OPCILLOSCOPE_INSTALL_DIR) +$InstallDir = if ($UsingCustomInstallDir) { + $env:OPCILLOSCOPE_INSTALL_DIR +} else { + Join-Path $env:LOCALAPPDATA "Programs\opcilloscope" +} +$LicenseDir = Join-Path $InstallDir "opcilloscope-licenses" + +# Releases before v1 installed here. On Windows this path is also the app's +# case-insensitive certificate-data parent, so migrate only the known exe. +$LegacyInstallDir = Join-Path $env:LOCALAPPDATA "Opcilloscope" function Write-Info { param($Message) Write-Host "[INFO] $Message" -ForegroundColor Green } function Write-Warn { param($Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow } -function Write-Err { param($Message) Write-Host "[ERROR] $Message" -ForegroundColor Red; exit 1 } +function Write-Err { param($Message) Write-Host "[ERROR] $Message" -ForegroundColor Red; throw $Message } + +function Normalize-PathEntry { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return "" } + $expanded = [Environment]::ExpandEnvironmentVariables($Path.Trim().Trim('"')) + return $expanded.TrimEnd('\').TrimEnd('/') +} + +function Test-UserPathEntry { + param([string]$UserPath, [string]$Entry) + $target = Normalize-PathEntry $Entry + return @($UserPath -split ";" | Where-Object { + (Normalize-PathEntry $_) -ieq $target + }).Count -gt 0 +} + +function Remove-UserPathEntry { + param([string]$Entry) + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ([string]::IsNullOrEmpty($userPath)) { return } + + $target = Normalize-PathEntry $Entry + $entries = @($userPath -split ";" | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) -and + (Normalize-PathEntry $_) -ine $target + }) + [Environment]::SetEnvironmentVariable("Path", ($entries -join ";"), "User") +} function Get-Platform { - $arch = if ([Environment]::Is64BitOperatingSystem) { - if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" } - } else { + if (-not [Environment]::Is64BitOperatingSystem) { Write-Err "32-bit Windows is not supported" } + + $architecture = if ($env:PROCESSOR_ARCHITEW6432) { + $env:PROCESSOR_ARCHITEW6432 + } else { + $env:PROCESSOR_ARCHITECTURE + } + $arch = if ($architecture -eq "ARM64") { "arm64" } else { "x64" } return "win-$arch" } @@ -28,6 +71,43 @@ function Get-LatestVersion { } } +function Install-LicenseMaterial { + param([string]$ExtractedRoot) + + # This app-specific directory is installer-owned. Replace it so notices + # removed by a package upgrade cannot linger from an older release. + if (Test-Path $LicenseDir) { + Remove-Item $LicenseDir -Recurse -Force + } + New-Item -ItemType Directory -Path $LicenseDir -Force | Out-Null + $copied = $false + + $projectLicense = Join-Path $ExtractedRoot "LICENSE" + if (Test-Path $projectLicense) { + Copy-Item $projectLicense (Join-Path $LicenseDir "LICENSE.txt") -Force + $copied = $true + } + + $aggregateNotices = Join-Path $ExtractedRoot "THIRD-PARTY-NOTICES.md" + if (Test-Path $aggregateNotices) { + Copy-Item $aggregateNotices $LicenseDir -Force + $copied = $true + } + + $noticeDirectory = Join-Path $ExtractedRoot "licenses" + if (Test-Path $noticeDirectory) { + Copy-Item (Join-Path $noticeDirectory "*") $LicenseDir -Recurse -Force + $copied = $true + } + + if ($copied) { + Write-Info "Installed license notices to $LicenseDir" + } else { + Write-Warn "This older release archive did not contain license notice files." + Remove-Item $LicenseDir -Force -ErrorAction SilentlyContinue + } +} + function Install-Opcilloscope { Write-Host "" Write-Host " +===================================+" -ForegroundColor Cyan @@ -44,78 +124,94 @@ function Install-Opcilloscope { $version = Get-LatestVersion Write-Info "Version: $version" - $downloadUrl = "https://github.com/$Repo/releases/download/$version/opcilloscope-$platform.zip" + $archiveName = "opcilloscope-$platform.zip" + $downloadUrl = "https://github.com/$Repo/releases/download/$version/$archiveName" Write-Info "Downloading from: $downloadUrl" - $tempDir = Join-Path $env:TEMP "opcilloscope-install" - $zipPath = Join-Path $tempDir "opcilloscope.zip" - - # Cleanup and create temp directory - if (Test-Path $tempDir) { Remove-Item -Recurse -Force $tempDir } - New-Item -ItemType Directory -Path $tempDir | Out-Null + $tempDir = Join-Path $env:TEMP "opcilloscope-install-$PID" + $zipPath = Join-Path $tempDir $archiveName + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null try { - # Download Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath -UseBasicParsing - # Verify the archive against the SHA256SUMS published with the release. - # Degrades gracefully (warning only) when SHA256SUMS is unavailable (older releases). - $archiveName = "opcilloscope-$platform.zip" + # The installer targets the latest release. Current releases are + # required to publish a checksum entry for every archive. $sumsUrl = "https://github.com/$Repo/releases/download/$version/SHA256SUMS" $sumsPath = Join-Path $tempDir "SHA256SUMS" - $haveSums = $true try { Invoke-WebRequest -Uri $sumsUrl -OutFile $sumsPath -UseBasicParsing } catch { - $haveSums = $false - Write-Warn "SHA256SUMS not found for $version (older release?). Skipping checksum verification." + Write-Err "Could not download SHA256SUMS for $version. Refusing an unverified install: $_" } - if ($haveSums) { - Write-Info "Verifying checksum..." - $entry = Get-Content $sumsPath | Where-Object { $_ -match ("\s" + [regex]::Escape($archiveName) + "$") } | Select-Object -First 1 - if (-not $entry) { - Write-Warn "No checksum entry for $archiveName in SHA256SUMS. Skipping checksum verification." - } else { - $expected = ($entry -split '\s+')[0].ToLowerInvariant() - $actual = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $expected) { - Write-Err "Checksum mismatch for ${archiveName}. Expected $expected but got $actual. The download may be corrupted or tampered with. Aborting." - } - Write-Info "Checksum verified (SHA-256)." - } + + Write-Info "Verifying checksum..." + $entry = Get-Content $sumsPath | + Where-Object { $_ -match ("\s" + [regex]::Escape($archiveName) + "$") } | + Select-Object -First 1 + if (-not $entry) { + Write-Err "SHA256SUMS has no checksum entry for $archiveName. Refusing an unverified install." } + $expected = ($entry -split '\s+')[0].ToLowerInvariant() + if ($expected -notmatch '^[0-9a-f]{64}$') { + Write-Err "SHA256SUMS contains an invalid checksum for $archiveName." + } + $actual = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + Write-Err "Checksum mismatch for $archiveName. Expected $expected but got $actual. Aborting." + } + Write-Info "Checksum verified (SHA-256)." + Write-Info "Extracting..." Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force + $exePath = Get-ChildItem -Path $tempDir -Filter "opcilloscope.exe" -File -Recurse | + Select-Object -First 1 + if (-not $exePath) { + Write-Err "Could not find opcilloscope.exe in archive" + } + Write-Info "Installing to $InstallDir..." - if (-not (Test-Path $InstallDir)) { - New-Item -ItemType Directory -Path $InstallDir | Out-Null + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + Copy-Item $exePath.FullName (Join-Path $InstallDir "opcilloscope.exe") -Force + Install-LicenseMaterial $tempDir + + # Safely migrate the legacy default: remove only the old executable. + if (-not $UsingCustomInstallDir) { + $legacyExe = Join-Path $LegacyInstallDir "opcilloscope.exe" + if (Test-Path $legacyExe) { + Remove-Item $legacyExe -Force + Write-Info "Removed legacy executable: $legacyExe" + } + Remove-UserPathEntry $LegacyInstallDir } - # Move executable - $exePath = Get-ChildItem -Path $tempDir -Filter "*.exe" -Recurse | Select-Object -First 1 - if ($exePath) { - Copy-Item -Path $exePath.FullName -Destination (Join-Path $InstallDir "opcilloscope.exe") -Force - } else { - Write-Err "Could not find executable in archive" + $installedExe = Join-Path $InstallDir "opcilloscope.exe" + & $installedExe --help | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Err "Installed executable failed its command-line smoke test" } - # Add to PATH if not already there $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - if ($userPath -notlike "*$InstallDir*") { - Write-Info "Adding $InstallDir to user PATH..." - [Environment]::SetEnvironmentVariable("Path", "$userPath;$InstallDir", "User") - $env:Path = "$env:Path;$InstallDir" + if (-not $UsingCustomInstallDir) { + if (-not (Test-UserPathEntry $userPath $InstallDir)) { + Write-Info "Adding $InstallDir to user PATH..." + $entries = @($userPath -split ";" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + [Environment]::SetEnvironmentVariable("Path", (@($entries) + $InstallDir) -join ";", "User") + } + if (-not (Test-UserPathEntry $env:Path $InstallDir)) { + $env:Path = "$env:Path;$InstallDir" + } + } elseif (-not (Test-UserPathEntry $env:Path $InstallDir)) { + Write-Warn "Custom install directory is not in PATH; PATH was left unchanged." } Write-Info "Opcilloscope $version installed successfully!" Write-Host "" Write-Host "Run 'opcilloscope' to start the application." -ForegroundColor White - Write-Host "(You may need to restart your terminal for PATH changes to take effect)" -ForegroundColor Gray - + Write-Host "(You may need to restart other terminals for PATH changes to take effect)" -ForegroundColor Gray } finally { - # Cleanup if (Test-Path $tempDir) { Remove-Item -Recurse -Force $tempDir } } } diff --git a/install.sh b/install.sh index 679dfe1..b4ec651 100644 --- a/install.sh +++ b/install.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -e +set -euo pipefail # Opcilloscope installer for Linux and macOS # Usage: curl -fsSL https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/install.sh | bash @@ -7,17 +7,24 @@ set -e REPO="SquareWaveSystems/opcilloscope" INSTALL_DIR="${OPCILLOSCOPE_INSTALL_DIR:-$HOME/.local/bin}" +case "$(uname -s)" in + Darwin*) APP_DATA_DIR="$HOME/Library/Application Support/opcilloscope" ;; + *) APP_DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/opcilloscope" ;; +esac +LICENSE_DIR="${APP_DATA_DIR}/licenses" +INSTALL_TMP_DIR="" +trap '[ -z "$INSTALL_TMP_DIR" ] || rm -rf "$INSTALL_TMP_DIR"' EXIT + # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' -NC='\033[0m' # No Color +NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $1"; } warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } -# Detect OS and architecture detect_platform() { local os arch @@ -36,29 +43,27 @@ detect_platform() { echo "${os}-${arch}" } -# Get latest release version get_latest_version() { curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' } -# Verify the downloaded archive against the SHA256SUMS published with the release. -# Degrades gracefully (warning only) when SHA256SUMS is unavailable (older releases). +# Verify the archive against SHA256SUMS published with the same release. +# The installer always targets the latest release, and current releases are +# required to publish a checksum for every archive. verify_checksum() { local version="$1" archive_name="$2" archive_path="$3" tmp_dir="$4" local sums_url="https://github.com/${REPO}/releases/download/${version}/SHA256SUMS" local expected actual - if ! curl -fsSL "$sums_url" -o "${tmp_dir}/SHA256SUMS" 2>/dev/null; then - warn "SHA256SUMS not found for ${version} (older release?). Skipping checksum verification." - return 0 + if ! curl -fsSL "$sums_url" -o "${tmp_dir}/SHA256SUMS"; then + error "Could not download SHA256SUMS for ${version}. Refusing an unverified install." fi - expected=$(grep " ${archive_name}\$" "${tmp_dir}/SHA256SUMS" | awk '{print $1}') - if [ -z "$expected" ]; then - warn "No checksum entry for ${archive_name} in SHA256SUMS. Skipping checksum verification." - return 0 + expected=$(awk -v archive="$archive_name" '$2 == archive { print $1; exit }' "${tmp_dir}/SHA256SUMS") + if [ "${#expected}" -ne 64 ] || [[ "$expected" == *[!0-9A-Fa-f]* ]]; then + error "SHA256SUMS has no valid checksum entry for ${archive_name}. Refusing an unverified install." fi if command -v sha256sum >/dev/null 2>&1; then @@ -66,8 +71,7 @@ verify_checksum() { elif command -v shasum >/dev/null 2>&1; then actual=$(shasum -a 256 "$archive_path" | awk '{print $1}') else - warn "Neither sha256sum nor shasum is available. Skipping checksum verification." - return 0 + error "Neither sha256sum nor shasum is available. Cannot verify the release archive." fi if [ "$actual" != "$expected" ]; then @@ -80,9 +84,38 @@ verify_checksum() { info "Checksum verified (SHA-256)." } -# Download and install -install() { - local platform version download_url tmp_dir +install_license_material() { + local tmp_dir="$1" + local copied=false + + # This directory is installer-owned. Replace it so notices removed by a + # package upgrade cannot linger and misdescribe the installed release. + rm -rf "$LICENSE_DIR" + mkdir -p "$LICENSE_DIR" + + if [ -f "${tmp_dir}/LICENSE" ]; then + cp "${tmp_dir}/LICENSE" "${LICENSE_DIR}/LICENSE.txt" + copied=true + fi + if [ -f "${tmp_dir}/THIRD-PARTY-NOTICES.md" ]; then + cp "${tmp_dir}/THIRD-PARTY-NOTICES.md" "$LICENSE_DIR/" + copied=true + fi + if [ -d "${tmp_dir}/licenses" ]; then + cp -R "${tmp_dir}/licenses/." "$LICENSE_DIR/" + copied=true + fi + + if [ "$copied" = true ]; then + info "Installed license notices to ${LICENSE_DIR}" + else + warn "This older release archive did not contain license notice files." + rmdir "$LICENSE_DIR" 2>/dev/null || true + fi +} + +install_opcilloscope() { + local platform version download_url archive_name tmp_dir binary info "Detecting platform..." platform=$(detect_platform) @@ -95,61 +128,57 @@ install() { fi info "Version: ${version}" - download_url="https://github.com/${REPO}/releases/download/${version}/opcilloscope-${platform}.tar.gz" + archive_name="opcilloscope-${platform}.tar.gz" + download_url="https://github.com/${REPO}/releases/download/${version}/${archive_name}" info "Downloading from: ${download_url}" tmp_dir=$(mktemp -d) - trap "rm -rf ${tmp_dir}" EXIT + INSTALL_TMP_DIR="$tmp_dir" - if ! curl -fsSL "$download_url" -o "${tmp_dir}/opcilloscope.tar.gz"; then + if ! curl -fsSL "$download_url" -o "${tmp_dir}/${archive_name}"; then error "Download failed. Check if the release exists for platform: ${platform}" fi info "Verifying checksum..." - verify_checksum "$version" "opcilloscope-${platform}.tar.gz" "${tmp_dir}/opcilloscope.tar.gz" "$tmp_dir" + verify_checksum "$version" "$archive_name" "${tmp_dir}/${archive_name}" "$tmp_dir" info "Extracting..." - tar -xzf "${tmp_dir}/opcilloscope.tar.gz" -C "${tmp_dir}" + tar -xzf "${tmp_dir}/${archive_name}" -C "$tmp_dir" - info "Installing to ${INSTALL_DIR}..." - mkdir -p "${INSTALL_DIR}" - - # Find the binary (handles both 'opcilloscope' and 'Opcilloscope' from different releases) - local binary - binary=$(find "${tmp_dir}" -maxdepth 1 -type f -iname 'opcilloscope' | head -n 1) + binary=$(find "$tmp_dir" -maxdepth 1 -type f -iname 'opcilloscope' | head -n 1) if [ -z "$binary" ]; then error "Could not find opcilloscope binary in archive" fi + info "Installing to ${INSTALL_DIR}..." + mkdir -p "$INSTALL_DIR" mv "$binary" "${INSTALL_DIR}/opcilloscope" chmod +x "${INSTALL_DIR}/opcilloscope" + install_license_material "$tmp_dir" - # Verify installation - if [ -x "${INSTALL_DIR}/opcilloscope" ]; then - info "Opcilloscope ${version} installed successfully!" - echo "" + if ! "${INSTALL_DIR}/opcilloscope" --help >/dev/null; then + error "Installed executable failed its command-line smoke test" + fi - # Check if install dir is in PATH - if [[ ":$PATH:" != *":${INSTALL_DIR}:"* ]]; then - warn "${INSTALL_DIR} is not in your PATH" - echo "" - echo "Add it to your shell profile:" - echo " echo 'export PATH=\"\$PATH:${INSTALL_DIR}\"' >> ~/.bashrc" - echo " # or for zsh:" - echo " echo 'export PATH=\"\$PATH:${INSTALL_DIR}\"' >> ~/.zshrc" - echo "" - fi - - echo "Run 'opcilloscope' to start the application." + info "Opcilloscope ${version} installed successfully!" + echo "" + + if [[ ":$PATH:" != *":${INSTALL_DIR}:"* ]]; then + warn "${INSTALL_DIR} is not in your PATH" + echo "" + echo "Add it to your shell profile:" + echo " echo 'export PATH=\"\$PATH:${INSTALL_DIR}\"' >> ~/.bashrc" + echo " # or for zsh:" + echo " echo 'export PATH=\"\$PATH:${INSTALL_DIR}\"' >> ~/.zshrc" echo "" - echo "To uninstall later:" - echo " curl -fsSL https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/uninstall.sh | bash" - else - error "Installation failed" fi + + echo "Run 'opcilloscope' to start the application." + echo "" + echo "To uninstall later:" + echo " curl -fsSL https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/uninstall.sh | bash" } -# Main main() { echo "" echo " ╔═══════════════════════════════════╗" @@ -158,11 +187,10 @@ main() { echo " ╚═══════════════════════════════════╝" echo "" - # Check for required commands command -v curl >/dev/null 2>&1 || error "curl is required but not installed" command -v tar >/dev/null 2>&1 || error "tar is required but not installed" - install + install_opcilloscope } main "$@" diff --git a/licenses/MARKDIG-LICENSE.txt b/licenses/MARKDIG-LICENSE.txt new file mode 100644 index 0000000..7d8cfda --- /dev/null +++ b/licenses/MARKDIG-LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) 2018-2019, Alexandre Mutel +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/NOTICE-SOURCES.md b/licenses/NOTICE-SOURCES.md new file mode 100644 index 0000000..440e8e3 --- /dev/null +++ b/licenses/NOTICE-SOURCES.md @@ -0,0 +1,48 @@ +# Third-party notice sources + +`THIRD-PARTY-PACKAGES.tsv` is the reviewed legal inventory for the dependency +graph in the six `../packages..lock.json` files. Run +`scripts/verify-third-party-inventory.sh` after changing any package. + +To deliberately regenerate all supported RID locks, run: + +```bash +for rid in linux-x64 linux-arm64 win-x64 win-arm64 osx-x64 osx-arm64; do + dotnet restore Opcilloscope.csproj -p:RuntimeIdentifier="$rid" \ + --use-lock-file --force-evaluate +done +``` + +Then review the resolved versions and licenses, update the TSV and notice +files, and rerun the validator. Ordinary restore selects the current SDK host +RID's lock. Release publishing passes its target RID explicitly and uses the +matching checked-in lock without restoring every platform pack at once. + +Exact committed notices are sourced as follows: + +- `ONIGWRAP-THIRD-PARTY-NOTICES.txt` is copied byte-for-byte from + `Onigwrap` 1.0.11's `THIRD-PARTY-NOTICES.TXT`. It includes the required + native Oniguruma notice. +- `OPC-FOUNDATION-LICENSE.txt` is copied verbatim from the + `OPCFoundation.NetStandard.Opc.Ua.Client` 1.5.378.156 package, with CRLF + normalized to repository-standard LF. +- `MARKDIG-LICENSE.txt` reproduces the Markdig upstream license with formatting + whitespace normalized; the Markdig 1.1.3 NuGet metadata identifies it as + BSD-2-Clause. + +Release archives also receive two files directly from the exact packages +resolved on the build runner: + +- `DOTNET-RUNTIME--LICENSE.txt` and + `DOTNET-RUNTIME--THIRD-PARTY-NOTICES.txt` from the self-contained + `Microsoft.NETCore.App.Runtime.` pack. +- `MICROSOFT-EXTENSIONS-THIRD-PARTY-NOTICES.txt` from + `Microsoft.Extensions.DependencyInjection` 10.0.8. The release validation + first confirms that all six resolved `Microsoft.Extensions.*` packages + carry the same notice; if they diverge, the build fails so each distinct + notice can be added deliberately. + +Do not paraphrase packaged notice text. Preserve package-provided files +verbatim in release archives and installed license directories; a +source-derived license may normalize formatting whitespace when documented +above without changing its wording. diff --git a/licenses/ONIGWRAP-THIRD-PARTY-NOTICES.txt b/licenses/ONIGWRAP-THIRD-PARTY-NOTICES.txt new file mode 100644 index 0000000..d63a53a --- /dev/null +++ b/licenses/ONIGWRAP-THIRD-PARTY-NOTICES.txt @@ -0,0 +1,102 @@ +License notice for oniguruma +------------------------------- + +Oniguruma LICENSE +----------------- + +Copyright (c) 2002-2021 K.Kosako +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + +License notice for fluentCODE/onigwrap +------------------------------- + +The MIT License (MIT) + +Copyright (c) 2015 Fluent Solutions + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +License notice for TextMateSharp +------------------------------- + +MIT License + +Copyright (c) 2021 Daniel Peñalba + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +License notice for mono/SkiaSharp +------------------------------- + +Copyright (c) 2015-2016 Xamarin, Inc. +Copyright (c) 2017-2018 Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/licenses/OPC-FOUNDATION-LICENSE.txt b/licenses/OPC-FOUNDATION-LICENSE.txt new file mode 100644 index 0000000..227060a --- /dev/null +++ b/licenses/OPC-FOUNDATION-LICENSE.txt @@ -0,0 +1,24 @@ +https://opcfoundation.org/license/mit.html + +MIT License + +OPC Foundation MIT License 1.00 + +Copyright (c) 2005-2025 OPC Foundation, Inc. Permission is hereby granted, +free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/licenses/THIRD-PARTY-PACKAGES.tsv b/licenses/THIRD-PARTY-PACKAGES.tsv new file mode 100644 index 0000000..9ab6cb4 --- /dev/null +++ b/licenses/THIRD-PARTY-PACKAGES.tsv @@ -0,0 +1,28 @@ +Package Version License Distribution +Microsoft.NET.ILLink.Tasks 10.0.9 MIT build-only +MinVer 7.0.0 Apache-2.0 build-only +OPCFoundation.NetStandard.Opc.Ua.Client 1.5.378.156 MIT runtime +Terminal.Gui 2.4.5 MIT runtime +BitFaster.Caching 2.6.0 MIT runtime +ColorHelper 1.8.1 MIT runtime +JetBrains.Annotations 2025.2.4 MIT runtime +Markdig 1.1.3 BSD-2-Clause runtime +Microsoft.Extensions.DependencyInjection 10.0.8 MIT runtime +Microsoft.Extensions.DependencyInjection.Abstractions 10.0.8 MIT runtime +Microsoft.Extensions.Logging 10.0.8 MIT runtime +Microsoft.Extensions.Logging.Abstractions 10.0.8 MIT runtime +Microsoft.Extensions.Options 10.0.8 MIT runtime +Microsoft.Extensions.Primitives 10.0.8 MIT runtime +Newtonsoft.Json 13.0.4 MIT runtime +Onigwrap 1.0.11 MIT AND BSD-style-Oniguruma runtime +OPCFoundation.NetStandard.Opc.Ua.Configuration 1.5.378.156 MIT runtime +OPCFoundation.NetStandard.Opc.Ua.Core 1.5.378.156 MIT runtime +OPCFoundation.NetStandard.Opc.Ua.Security.Certificates 1.5.378.156 MIT runtime +OPCFoundation.NetStandard.Opc.Ua.Types 1.5.378.156 MIT runtime +System.IO.Abstractions 22.1.1 MIT runtime +TestableIO.System.IO.Abstractions 22.1.1 MIT runtime +TestableIO.System.IO.Abstractions.Wrappers 22.1.1 MIT runtime +Testably.Abstractions.FileSystem.Interface 10.1.0 MIT runtime +TextMateSharp 2.0.4 MIT runtime +TextMateSharp.Grammars 2.0.4 MIT runtime +Wcwidth 4.0.1 MIT runtime diff --git a/packages.linux-arm64.lock.json b/packages.linux-arm64.lock.json new file mode 100644 index 0000000..ac7914f --- /dev/null +++ b/packages.linux-arm64.lock.json @@ -0,0 +1,214 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "4Iw41e2h7I4t70SJcX2GCmbyKJIlA273Cfm9RJMM050/3VBejGAG1KcthP5Z2L6SQcbfbf6BhNWO26+ZG+GzMg==" + }, + "MinVer": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Client": { + "type": "Direct", + "requested": "[1.5.378.156, )", + "resolved": "1.5.378.156", + "contentHash": "vQkaAX4JKaNxFERcp1ao9jo4358s5g7Ir58Ie55X/w0LuGTS12u6a2GoGsWSUPzjdAsdNLuPWUsFtO6WXO5ATA==", + "dependencies": { + "BitFaster.Caching": "2.6.0", + "OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "Terminal.Gui": { + "type": "Direct", + "requested": "[2.4.5, )", + "resolved": "2.4.5", + "contentHash": "gAZ7qNUuy5QBQA5XW+BAA8uREUKKaJN6HgwNYqTuPRcdKGQP8Or3fVsRFNo4IARknVxNI17YZrhzcUYJ/YVHTg==", + "dependencies": { + "ColorHelper": "1.8.1", + "JetBrains.Annotations": "2025.2.4", + "Markdig": "1.1.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "System.IO.Abstractions": "22.1.1", + "TextMateSharp": "2.0.4", + "TextMateSharp.Grammars": "2.0.4", + "Wcwidth": "4.0.1" + } + }, + "BitFaster.Caching": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "iF2dMiGpaya8Umwi0OfsGKtq3aMVgHVsBChwQL+NhShEsRorcoTinGf9FFNBnBMwJW2Vm0FqhtOaEBY0+74p6g==" + }, + "ColorHelper": { + "type": "Transitive", + "resolved": "1.8.1", + "contentHash": "rblWXd/02TfwJjX53xEfQnbnDRKxCm09VtavV8JaIcmIFWt9efNf6xTQ3WeXgy/she5R31eRTtnizFoUoDrs5w==" + }, + "JetBrains.Annotations": { + "type": "Transitive", + "resolved": "2025.2.4", + "contentHash": "TwbgxAkXxY+vNEhNVx/QXjJ4vqxmepOjsgRvvImQPbHkHMMb4W+ahL3laMsxXKtNT7iMy+E1B3xkqao2hf1n3A==" + }, + "Markdig": { + "type": "Transitive", + "resolved": "1.1.3", + "contentHash": "wboYc6YToID7koq+onej7rjd/kv4urZaZ4QMiJoF/jXt/ZlXe2Af9gWG4nOe5tk654UaOPYvKL64iFum7qWEYA==" + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Configuration": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "N2Wkmkp4bWgmq0eS9ZbHwD6A1hV2h0Nr0CMGO6EqkZs1Jh3ntNTnLJSR4MPrKifgY9Yz7/DnH6jQhJAUxrpq4g==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Core": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "gJz1u06tNlBFQY/fX1B7ajj+bFkGey5tD4EAZJhIvOi5BpOd+TUFIu/DBVijhnYc+YNyK0E+RZuoEyCu3N/l+w==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.8", + "Newtonsoft.Json": "13.0.4", + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "5GAhgHTantBMKAm8MdEFdMFF2tqpMOzDgwAt6yHa653rMw5ed3M0cr2uBsfBX7v6aX6jbBx0r0xum5E6tWNFyg==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Types": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "YFE42PxZvdZ09wNGwrGKoJwU05a1DJbOMVTBdeWoe1Nnb0RXjzTMzQ2I4uFK1ZDrxgNW+ykH6IqMTZUNl8IJKg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + } + }, + "System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "nm4sNhAoN1NyiIVT6MS3EEzNjnir3PC1HE0mKGGUFg6RBW1HmRvWKuRDPkij1etOoQkpyIFmjVGcM6TpBGF3qQ==", + "dependencies": { + "TestableIO.System.IO.Abstractions": "22.1.1", + "TestableIO.System.IO.Abstractions.Wrappers": "22.1.1" + } + }, + "TestableIO.System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "EJmXIKfwvJHvSHWP+NjA213avUdofV4SmBx2aWxc3SzPkrHHVKxUMC3fSa/epzk/SVITwuU2EAntgQTvobAqwQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "TestableIO.System.IO.Abstractions.Wrappers": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "J91zmSwuMY75ybtkqAFMC3vDNMkLJ/kiDDLhORITEwTKNhEwEYfxAQGv+f0jGYZg/H0YbnGcx5Ky0FgFJwrrWQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "Testably.Abstractions.FileSystem.Interface": { + "type": "Transitive", + "resolved": "10.1.0", + "contentHash": "0R+m7DvS2QuDcFOfDGtyGErRrgXOl4/z2tbNLX2RfvlYJjlVcKG+wMIBfZO8vxGtTNwNxffTG4ibFRwd0jkM7w==" + }, + "TextMateSharp": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "5Pvn+A4zb1IF+ACAVhgw1aGf9kTyx6j7v5hdup7nCS89Nzby8KQAlCfQ54VC/GAsEoQwh4zKa5MKlAaql8nkPg==", + "dependencies": { + "Onigwrap": "1.0.11" + } + }, + "TextMateSharp.Grammars": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "jzOY00q5u5Vs4L9n2m2PnRZDGwWt2uwkOLh7qlVdCx1kkGSn5eaW5XgnYCyTzEbsCwLV2xlEolnNkr/wuIMAbA==", + "dependencies": { + "TextMateSharp": "2.0.4" + } + }, + "Wcwidth": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "PMU8u3pGf9nUkee39KMrB8qqId7u7kyGWaZpOh9E4Mal3xChq+relS/hHQOicGAswyzi7F5DjXjac0mTnRBX8Q==" + } + }, + "net10.0/linux-arm64": { + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + } + } + } +} \ No newline at end of file diff --git a/packages.linux-x64.lock.json b/packages.linux-x64.lock.json new file mode 100644 index 0000000..67468ca --- /dev/null +++ b/packages.linux-x64.lock.json @@ -0,0 +1,214 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "4Iw41e2h7I4t70SJcX2GCmbyKJIlA273Cfm9RJMM050/3VBejGAG1KcthP5Z2L6SQcbfbf6BhNWO26+ZG+GzMg==" + }, + "MinVer": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Client": { + "type": "Direct", + "requested": "[1.5.378.156, )", + "resolved": "1.5.378.156", + "contentHash": "vQkaAX4JKaNxFERcp1ao9jo4358s5g7Ir58Ie55X/w0LuGTS12u6a2GoGsWSUPzjdAsdNLuPWUsFtO6WXO5ATA==", + "dependencies": { + "BitFaster.Caching": "2.6.0", + "OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "Terminal.Gui": { + "type": "Direct", + "requested": "[2.4.5, )", + "resolved": "2.4.5", + "contentHash": "gAZ7qNUuy5QBQA5XW+BAA8uREUKKaJN6HgwNYqTuPRcdKGQP8Or3fVsRFNo4IARknVxNI17YZrhzcUYJ/YVHTg==", + "dependencies": { + "ColorHelper": "1.8.1", + "JetBrains.Annotations": "2025.2.4", + "Markdig": "1.1.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "System.IO.Abstractions": "22.1.1", + "TextMateSharp": "2.0.4", + "TextMateSharp.Grammars": "2.0.4", + "Wcwidth": "4.0.1" + } + }, + "BitFaster.Caching": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "iF2dMiGpaya8Umwi0OfsGKtq3aMVgHVsBChwQL+NhShEsRorcoTinGf9FFNBnBMwJW2Vm0FqhtOaEBY0+74p6g==" + }, + "ColorHelper": { + "type": "Transitive", + "resolved": "1.8.1", + "contentHash": "rblWXd/02TfwJjX53xEfQnbnDRKxCm09VtavV8JaIcmIFWt9efNf6xTQ3WeXgy/she5R31eRTtnizFoUoDrs5w==" + }, + "JetBrains.Annotations": { + "type": "Transitive", + "resolved": "2025.2.4", + "contentHash": "TwbgxAkXxY+vNEhNVx/QXjJ4vqxmepOjsgRvvImQPbHkHMMb4W+ahL3laMsxXKtNT7iMy+E1B3xkqao2hf1n3A==" + }, + "Markdig": { + "type": "Transitive", + "resolved": "1.1.3", + "contentHash": "wboYc6YToID7koq+onej7rjd/kv4urZaZ4QMiJoF/jXt/ZlXe2Af9gWG4nOe5tk654UaOPYvKL64iFum7qWEYA==" + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Configuration": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "N2Wkmkp4bWgmq0eS9ZbHwD6A1hV2h0Nr0CMGO6EqkZs1Jh3ntNTnLJSR4MPrKifgY9Yz7/DnH6jQhJAUxrpq4g==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Core": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "gJz1u06tNlBFQY/fX1B7ajj+bFkGey5tD4EAZJhIvOi5BpOd+TUFIu/DBVijhnYc+YNyK0E+RZuoEyCu3N/l+w==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.8", + "Newtonsoft.Json": "13.0.4", + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "5GAhgHTantBMKAm8MdEFdMFF2tqpMOzDgwAt6yHa653rMw5ed3M0cr2uBsfBX7v6aX6jbBx0r0xum5E6tWNFyg==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Types": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "YFE42PxZvdZ09wNGwrGKoJwU05a1DJbOMVTBdeWoe1Nnb0RXjzTMzQ2I4uFK1ZDrxgNW+ykH6IqMTZUNl8IJKg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + } + }, + "System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "nm4sNhAoN1NyiIVT6MS3EEzNjnir3PC1HE0mKGGUFg6RBW1HmRvWKuRDPkij1etOoQkpyIFmjVGcM6TpBGF3qQ==", + "dependencies": { + "TestableIO.System.IO.Abstractions": "22.1.1", + "TestableIO.System.IO.Abstractions.Wrappers": "22.1.1" + } + }, + "TestableIO.System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "EJmXIKfwvJHvSHWP+NjA213avUdofV4SmBx2aWxc3SzPkrHHVKxUMC3fSa/epzk/SVITwuU2EAntgQTvobAqwQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "TestableIO.System.IO.Abstractions.Wrappers": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "J91zmSwuMY75ybtkqAFMC3vDNMkLJ/kiDDLhORITEwTKNhEwEYfxAQGv+f0jGYZg/H0YbnGcx5Ky0FgFJwrrWQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "Testably.Abstractions.FileSystem.Interface": { + "type": "Transitive", + "resolved": "10.1.0", + "contentHash": "0R+m7DvS2QuDcFOfDGtyGErRrgXOl4/z2tbNLX2RfvlYJjlVcKG+wMIBfZO8vxGtTNwNxffTG4ibFRwd0jkM7w==" + }, + "TextMateSharp": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "5Pvn+A4zb1IF+ACAVhgw1aGf9kTyx6j7v5hdup7nCS89Nzby8KQAlCfQ54VC/GAsEoQwh4zKa5MKlAaql8nkPg==", + "dependencies": { + "Onigwrap": "1.0.11" + } + }, + "TextMateSharp.Grammars": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "jzOY00q5u5Vs4L9n2m2PnRZDGwWt2uwkOLh7qlVdCx1kkGSn5eaW5XgnYCyTzEbsCwLV2xlEolnNkr/wuIMAbA==", + "dependencies": { + "TextMateSharp": "2.0.4" + } + }, + "Wcwidth": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "PMU8u3pGf9nUkee39KMrB8qqId7u7kyGWaZpOh9E4Mal3xChq+relS/hHQOicGAswyzi7F5DjXjac0mTnRBX8Q==" + } + }, + "net10.0/linux-x64": { + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + } + } + } +} \ No newline at end of file diff --git a/packages.osx-arm64.lock.json b/packages.osx-arm64.lock.json new file mode 100644 index 0000000..9119304 --- /dev/null +++ b/packages.osx-arm64.lock.json @@ -0,0 +1,214 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "4Iw41e2h7I4t70SJcX2GCmbyKJIlA273Cfm9RJMM050/3VBejGAG1KcthP5Z2L6SQcbfbf6BhNWO26+ZG+GzMg==" + }, + "MinVer": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Client": { + "type": "Direct", + "requested": "[1.5.378.156, )", + "resolved": "1.5.378.156", + "contentHash": "vQkaAX4JKaNxFERcp1ao9jo4358s5g7Ir58Ie55X/w0LuGTS12u6a2GoGsWSUPzjdAsdNLuPWUsFtO6WXO5ATA==", + "dependencies": { + "BitFaster.Caching": "2.6.0", + "OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "Terminal.Gui": { + "type": "Direct", + "requested": "[2.4.5, )", + "resolved": "2.4.5", + "contentHash": "gAZ7qNUuy5QBQA5XW+BAA8uREUKKaJN6HgwNYqTuPRcdKGQP8Or3fVsRFNo4IARknVxNI17YZrhzcUYJ/YVHTg==", + "dependencies": { + "ColorHelper": "1.8.1", + "JetBrains.Annotations": "2025.2.4", + "Markdig": "1.1.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "System.IO.Abstractions": "22.1.1", + "TextMateSharp": "2.0.4", + "TextMateSharp.Grammars": "2.0.4", + "Wcwidth": "4.0.1" + } + }, + "BitFaster.Caching": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "iF2dMiGpaya8Umwi0OfsGKtq3aMVgHVsBChwQL+NhShEsRorcoTinGf9FFNBnBMwJW2Vm0FqhtOaEBY0+74p6g==" + }, + "ColorHelper": { + "type": "Transitive", + "resolved": "1.8.1", + "contentHash": "rblWXd/02TfwJjX53xEfQnbnDRKxCm09VtavV8JaIcmIFWt9efNf6xTQ3WeXgy/she5R31eRTtnizFoUoDrs5w==" + }, + "JetBrains.Annotations": { + "type": "Transitive", + "resolved": "2025.2.4", + "contentHash": "TwbgxAkXxY+vNEhNVx/QXjJ4vqxmepOjsgRvvImQPbHkHMMb4W+ahL3laMsxXKtNT7iMy+E1B3xkqao2hf1n3A==" + }, + "Markdig": { + "type": "Transitive", + "resolved": "1.1.3", + "contentHash": "wboYc6YToID7koq+onej7rjd/kv4urZaZ4QMiJoF/jXt/ZlXe2Af9gWG4nOe5tk654UaOPYvKL64iFum7qWEYA==" + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Configuration": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "N2Wkmkp4bWgmq0eS9ZbHwD6A1hV2h0Nr0CMGO6EqkZs1Jh3ntNTnLJSR4MPrKifgY9Yz7/DnH6jQhJAUxrpq4g==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Core": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "gJz1u06tNlBFQY/fX1B7ajj+bFkGey5tD4EAZJhIvOi5BpOd+TUFIu/DBVijhnYc+YNyK0E+RZuoEyCu3N/l+w==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.8", + "Newtonsoft.Json": "13.0.4", + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "5GAhgHTantBMKAm8MdEFdMFF2tqpMOzDgwAt6yHa653rMw5ed3M0cr2uBsfBX7v6aX6jbBx0r0xum5E6tWNFyg==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Types": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "YFE42PxZvdZ09wNGwrGKoJwU05a1DJbOMVTBdeWoe1Nnb0RXjzTMzQ2I4uFK1ZDrxgNW+ykH6IqMTZUNl8IJKg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + } + }, + "System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "nm4sNhAoN1NyiIVT6MS3EEzNjnir3PC1HE0mKGGUFg6RBW1HmRvWKuRDPkij1etOoQkpyIFmjVGcM6TpBGF3qQ==", + "dependencies": { + "TestableIO.System.IO.Abstractions": "22.1.1", + "TestableIO.System.IO.Abstractions.Wrappers": "22.1.1" + } + }, + "TestableIO.System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "EJmXIKfwvJHvSHWP+NjA213avUdofV4SmBx2aWxc3SzPkrHHVKxUMC3fSa/epzk/SVITwuU2EAntgQTvobAqwQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "TestableIO.System.IO.Abstractions.Wrappers": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "J91zmSwuMY75ybtkqAFMC3vDNMkLJ/kiDDLhORITEwTKNhEwEYfxAQGv+f0jGYZg/H0YbnGcx5Ky0FgFJwrrWQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "Testably.Abstractions.FileSystem.Interface": { + "type": "Transitive", + "resolved": "10.1.0", + "contentHash": "0R+m7DvS2QuDcFOfDGtyGErRrgXOl4/z2tbNLX2RfvlYJjlVcKG+wMIBfZO8vxGtTNwNxffTG4ibFRwd0jkM7w==" + }, + "TextMateSharp": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "5Pvn+A4zb1IF+ACAVhgw1aGf9kTyx6j7v5hdup7nCS89Nzby8KQAlCfQ54VC/GAsEoQwh4zKa5MKlAaql8nkPg==", + "dependencies": { + "Onigwrap": "1.0.11" + } + }, + "TextMateSharp.Grammars": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "jzOY00q5u5Vs4L9n2m2PnRZDGwWt2uwkOLh7qlVdCx1kkGSn5eaW5XgnYCyTzEbsCwLV2xlEolnNkr/wuIMAbA==", + "dependencies": { + "TextMateSharp": "2.0.4" + } + }, + "Wcwidth": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "PMU8u3pGf9nUkee39KMrB8qqId7u7kyGWaZpOh9E4Mal3xChq+relS/hHQOicGAswyzi7F5DjXjac0mTnRBX8Q==" + } + }, + "net10.0/osx-arm64": { + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + } + } + } +} \ No newline at end of file diff --git a/packages.osx-x64.lock.json b/packages.osx-x64.lock.json new file mode 100644 index 0000000..81572d6 --- /dev/null +++ b/packages.osx-x64.lock.json @@ -0,0 +1,214 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "4Iw41e2h7I4t70SJcX2GCmbyKJIlA273Cfm9RJMM050/3VBejGAG1KcthP5Z2L6SQcbfbf6BhNWO26+ZG+GzMg==" + }, + "MinVer": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Client": { + "type": "Direct", + "requested": "[1.5.378.156, )", + "resolved": "1.5.378.156", + "contentHash": "vQkaAX4JKaNxFERcp1ao9jo4358s5g7Ir58Ie55X/w0LuGTS12u6a2GoGsWSUPzjdAsdNLuPWUsFtO6WXO5ATA==", + "dependencies": { + "BitFaster.Caching": "2.6.0", + "OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "Terminal.Gui": { + "type": "Direct", + "requested": "[2.4.5, )", + "resolved": "2.4.5", + "contentHash": "gAZ7qNUuy5QBQA5XW+BAA8uREUKKaJN6HgwNYqTuPRcdKGQP8Or3fVsRFNo4IARknVxNI17YZrhzcUYJ/YVHTg==", + "dependencies": { + "ColorHelper": "1.8.1", + "JetBrains.Annotations": "2025.2.4", + "Markdig": "1.1.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "System.IO.Abstractions": "22.1.1", + "TextMateSharp": "2.0.4", + "TextMateSharp.Grammars": "2.0.4", + "Wcwidth": "4.0.1" + } + }, + "BitFaster.Caching": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "iF2dMiGpaya8Umwi0OfsGKtq3aMVgHVsBChwQL+NhShEsRorcoTinGf9FFNBnBMwJW2Vm0FqhtOaEBY0+74p6g==" + }, + "ColorHelper": { + "type": "Transitive", + "resolved": "1.8.1", + "contentHash": "rblWXd/02TfwJjX53xEfQnbnDRKxCm09VtavV8JaIcmIFWt9efNf6xTQ3WeXgy/she5R31eRTtnizFoUoDrs5w==" + }, + "JetBrains.Annotations": { + "type": "Transitive", + "resolved": "2025.2.4", + "contentHash": "TwbgxAkXxY+vNEhNVx/QXjJ4vqxmepOjsgRvvImQPbHkHMMb4W+ahL3laMsxXKtNT7iMy+E1B3xkqao2hf1n3A==" + }, + "Markdig": { + "type": "Transitive", + "resolved": "1.1.3", + "contentHash": "wboYc6YToID7koq+onej7rjd/kv4urZaZ4QMiJoF/jXt/ZlXe2Af9gWG4nOe5tk654UaOPYvKL64iFum7qWEYA==" + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Configuration": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "N2Wkmkp4bWgmq0eS9ZbHwD6A1hV2h0Nr0CMGO6EqkZs1Jh3ntNTnLJSR4MPrKifgY9Yz7/DnH6jQhJAUxrpq4g==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Core": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "gJz1u06tNlBFQY/fX1B7ajj+bFkGey5tD4EAZJhIvOi5BpOd+TUFIu/DBVijhnYc+YNyK0E+RZuoEyCu3N/l+w==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.8", + "Newtonsoft.Json": "13.0.4", + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "5GAhgHTantBMKAm8MdEFdMFF2tqpMOzDgwAt6yHa653rMw5ed3M0cr2uBsfBX7v6aX6jbBx0r0xum5E6tWNFyg==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Types": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "YFE42PxZvdZ09wNGwrGKoJwU05a1DJbOMVTBdeWoe1Nnb0RXjzTMzQ2I4uFK1ZDrxgNW+ykH6IqMTZUNl8IJKg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + } + }, + "System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "nm4sNhAoN1NyiIVT6MS3EEzNjnir3PC1HE0mKGGUFg6RBW1HmRvWKuRDPkij1etOoQkpyIFmjVGcM6TpBGF3qQ==", + "dependencies": { + "TestableIO.System.IO.Abstractions": "22.1.1", + "TestableIO.System.IO.Abstractions.Wrappers": "22.1.1" + } + }, + "TestableIO.System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "EJmXIKfwvJHvSHWP+NjA213avUdofV4SmBx2aWxc3SzPkrHHVKxUMC3fSa/epzk/SVITwuU2EAntgQTvobAqwQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "TestableIO.System.IO.Abstractions.Wrappers": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "J91zmSwuMY75ybtkqAFMC3vDNMkLJ/kiDDLhORITEwTKNhEwEYfxAQGv+f0jGYZg/H0YbnGcx5Ky0FgFJwrrWQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "Testably.Abstractions.FileSystem.Interface": { + "type": "Transitive", + "resolved": "10.1.0", + "contentHash": "0R+m7DvS2QuDcFOfDGtyGErRrgXOl4/z2tbNLX2RfvlYJjlVcKG+wMIBfZO8vxGtTNwNxffTG4ibFRwd0jkM7w==" + }, + "TextMateSharp": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "5Pvn+A4zb1IF+ACAVhgw1aGf9kTyx6j7v5hdup7nCS89Nzby8KQAlCfQ54VC/GAsEoQwh4zKa5MKlAaql8nkPg==", + "dependencies": { + "Onigwrap": "1.0.11" + } + }, + "TextMateSharp.Grammars": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "jzOY00q5u5Vs4L9n2m2PnRZDGwWt2uwkOLh7qlVdCx1kkGSn5eaW5XgnYCyTzEbsCwLV2xlEolnNkr/wuIMAbA==", + "dependencies": { + "TextMateSharp": "2.0.4" + } + }, + "Wcwidth": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "PMU8u3pGf9nUkee39KMrB8qqId7u7kyGWaZpOh9E4Mal3xChq+relS/hHQOicGAswyzi7F5DjXjac0mTnRBX8Q==" + } + }, + "net10.0/osx-x64": { + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + } + } + } +} \ No newline at end of file diff --git a/packages.win-arm64.lock.json b/packages.win-arm64.lock.json new file mode 100644 index 0000000..829f2a9 --- /dev/null +++ b/packages.win-arm64.lock.json @@ -0,0 +1,214 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "4Iw41e2h7I4t70SJcX2GCmbyKJIlA273Cfm9RJMM050/3VBejGAG1KcthP5Z2L6SQcbfbf6BhNWO26+ZG+GzMg==" + }, + "MinVer": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Client": { + "type": "Direct", + "requested": "[1.5.378.156, )", + "resolved": "1.5.378.156", + "contentHash": "vQkaAX4JKaNxFERcp1ao9jo4358s5g7Ir58Ie55X/w0LuGTS12u6a2GoGsWSUPzjdAsdNLuPWUsFtO6WXO5ATA==", + "dependencies": { + "BitFaster.Caching": "2.6.0", + "OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "Terminal.Gui": { + "type": "Direct", + "requested": "[2.4.5, )", + "resolved": "2.4.5", + "contentHash": "gAZ7qNUuy5QBQA5XW+BAA8uREUKKaJN6HgwNYqTuPRcdKGQP8Or3fVsRFNo4IARknVxNI17YZrhzcUYJ/YVHTg==", + "dependencies": { + "ColorHelper": "1.8.1", + "JetBrains.Annotations": "2025.2.4", + "Markdig": "1.1.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "System.IO.Abstractions": "22.1.1", + "TextMateSharp": "2.0.4", + "TextMateSharp.Grammars": "2.0.4", + "Wcwidth": "4.0.1" + } + }, + "BitFaster.Caching": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "iF2dMiGpaya8Umwi0OfsGKtq3aMVgHVsBChwQL+NhShEsRorcoTinGf9FFNBnBMwJW2Vm0FqhtOaEBY0+74p6g==" + }, + "ColorHelper": { + "type": "Transitive", + "resolved": "1.8.1", + "contentHash": "rblWXd/02TfwJjX53xEfQnbnDRKxCm09VtavV8JaIcmIFWt9efNf6xTQ3WeXgy/she5R31eRTtnizFoUoDrs5w==" + }, + "JetBrains.Annotations": { + "type": "Transitive", + "resolved": "2025.2.4", + "contentHash": "TwbgxAkXxY+vNEhNVx/QXjJ4vqxmepOjsgRvvImQPbHkHMMb4W+ahL3laMsxXKtNT7iMy+E1B3xkqao2hf1n3A==" + }, + "Markdig": { + "type": "Transitive", + "resolved": "1.1.3", + "contentHash": "wboYc6YToID7koq+onej7rjd/kv4urZaZ4QMiJoF/jXt/ZlXe2Af9gWG4nOe5tk654UaOPYvKL64iFum7qWEYA==" + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Configuration": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "N2Wkmkp4bWgmq0eS9ZbHwD6A1hV2h0Nr0CMGO6EqkZs1Jh3ntNTnLJSR4MPrKifgY9Yz7/DnH6jQhJAUxrpq4g==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Core": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "gJz1u06tNlBFQY/fX1B7ajj+bFkGey5tD4EAZJhIvOi5BpOd+TUFIu/DBVijhnYc+YNyK0E+RZuoEyCu3N/l+w==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.8", + "Newtonsoft.Json": "13.0.4", + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "5GAhgHTantBMKAm8MdEFdMFF2tqpMOzDgwAt6yHa653rMw5ed3M0cr2uBsfBX7v6aX6jbBx0r0xum5E6tWNFyg==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Types": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "YFE42PxZvdZ09wNGwrGKoJwU05a1DJbOMVTBdeWoe1Nnb0RXjzTMzQ2I4uFK1ZDrxgNW+ykH6IqMTZUNl8IJKg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + } + }, + "System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "nm4sNhAoN1NyiIVT6MS3EEzNjnir3PC1HE0mKGGUFg6RBW1HmRvWKuRDPkij1etOoQkpyIFmjVGcM6TpBGF3qQ==", + "dependencies": { + "TestableIO.System.IO.Abstractions": "22.1.1", + "TestableIO.System.IO.Abstractions.Wrappers": "22.1.1" + } + }, + "TestableIO.System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "EJmXIKfwvJHvSHWP+NjA213avUdofV4SmBx2aWxc3SzPkrHHVKxUMC3fSa/epzk/SVITwuU2EAntgQTvobAqwQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "TestableIO.System.IO.Abstractions.Wrappers": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "J91zmSwuMY75ybtkqAFMC3vDNMkLJ/kiDDLhORITEwTKNhEwEYfxAQGv+f0jGYZg/H0YbnGcx5Ky0FgFJwrrWQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "Testably.Abstractions.FileSystem.Interface": { + "type": "Transitive", + "resolved": "10.1.0", + "contentHash": "0R+m7DvS2QuDcFOfDGtyGErRrgXOl4/z2tbNLX2RfvlYJjlVcKG+wMIBfZO8vxGtTNwNxffTG4ibFRwd0jkM7w==" + }, + "TextMateSharp": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "5Pvn+A4zb1IF+ACAVhgw1aGf9kTyx6j7v5hdup7nCS89Nzby8KQAlCfQ54VC/GAsEoQwh4zKa5MKlAaql8nkPg==", + "dependencies": { + "Onigwrap": "1.0.11" + } + }, + "TextMateSharp.Grammars": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "jzOY00q5u5Vs4L9n2m2PnRZDGwWt2uwkOLh7qlVdCx1kkGSn5eaW5XgnYCyTzEbsCwLV2xlEolnNkr/wuIMAbA==", + "dependencies": { + "TextMateSharp": "2.0.4" + } + }, + "Wcwidth": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "PMU8u3pGf9nUkee39KMrB8qqId7u7kyGWaZpOh9E4Mal3xChq+relS/hHQOicGAswyzi7F5DjXjac0mTnRBX8Q==" + } + }, + "net10.0/win-arm64": { + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + } + } + } +} \ No newline at end of file diff --git a/packages.win-x64.lock.json b/packages.win-x64.lock.json new file mode 100644 index 0000000..af287bf --- /dev/null +++ b/packages.win-x64.lock.json @@ -0,0 +1,214 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "4Iw41e2h7I4t70SJcX2GCmbyKJIlA273Cfm9RJMM050/3VBejGAG1KcthP5Z2L6SQcbfbf6BhNWO26+ZG+GzMg==" + }, + "MinVer": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Client": { + "type": "Direct", + "requested": "[1.5.378.156, )", + "resolved": "1.5.378.156", + "contentHash": "vQkaAX4JKaNxFERcp1ao9jo4358s5g7Ir58Ie55X/w0LuGTS12u6a2GoGsWSUPzjdAsdNLuPWUsFtO6WXO5ATA==", + "dependencies": { + "BitFaster.Caching": "2.6.0", + "OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "Terminal.Gui": { + "type": "Direct", + "requested": "[2.4.5, )", + "resolved": "2.4.5", + "contentHash": "gAZ7qNUuy5QBQA5XW+BAA8uREUKKaJN6HgwNYqTuPRcdKGQP8Or3fVsRFNo4IARknVxNI17YZrhzcUYJ/YVHTg==", + "dependencies": { + "ColorHelper": "1.8.1", + "JetBrains.Annotations": "2025.2.4", + "Markdig": "1.1.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "System.IO.Abstractions": "22.1.1", + "TextMateSharp": "2.0.4", + "TextMateSharp.Grammars": "2.0.4", + "Wcwidth": "4.0.1" + } + }, + "BitFaster.Caching": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "iF2dMiGpaya8Umwi0OfsGKtq3aMVgHVsBChwQL+NhShEsRorcoTinGf9FFNBnBMwJW2Vm0FqhtOaEBY0+74p6g==" + }, + "ColorHelper": { + "type": "Transitive", + "resolved": "1.8.1", + "contentHash": "rblWXd/02TfwJjX53xEfQnbnDRKxCm09VtavV8JaIcmIFWt9efNf6xTQ3WeXgy/she5R31eRTtnizFoUoDrs5w==" + }, + "JetBrains.Annotations": { + "type": "Transitive", + "resolved": "2025.2.4", + "contentHash": "TwbgxAkXxY+vNEhNVx/QXjJ4vqxmepOjsgRvvImQPbHkHMMb4W+ahL3laMsxXKtNT7iMy+E1B3xkqao2hf1n3A==" + }, + "Markdig": { + "type": "Transitive", + "resolved": "1.1.3", + "contentHash": "wboYc6YToID7koq+onej7rjd/kv4urZaZ4QMiJoF/jXt/ZlXe2Af9gWG4nOe5tk654UaOPYvKL64iFum7qWEYA==" + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + }, + "OPCFoundation.NetStandard.Opc.Ua.Configuration": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "N2Wkmkp4bWgmq0eS9ZbHwD6A1hV2h0Nr0CMGO6EqkZs1Jh3ntNTnLJSR4MPrKifgY9Yz7/DnH6jQhJAUxrpq4g==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Core": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "gJz1u06tNlBFQY/fX1B7ajj+bFkGey5tD4EAZJhIvOi5BpOd+TUFIu/DBVijhnYc+YNyK0E+RZuoEyCu3N/l+w==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.8", + "Newtonsoft.Json": "13.0.4", + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": "1.5.378.156", + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "5GAhgHTantBMKAm8MdEFdMFF2tqpMOzDgwAt6yHa653rMw5ed3M0cr2uBsfBX7v6aX6jbBx0r0xum5E6tWNFyg==", + "dependencies": { + "OPCFoundation.NetStandard.Opc.Ua.Types": "1.5.378.156" + } + }, + "OPCFoundation.NetStandard.Opc.Ua.Types": { + "type": "Transitive", + "resolved": "1.5.378.156", + "contentHash": "YFE42PxZvdZ09wNGwrGKoJwU05a1DJbOMVTBdeWoe1Nnb0RXjzTMzQ2I4uFK1ZDrxgNW+ykH6IqMTZUNl8IJKg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + } + }, + "System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "nm4sNhAoN1NyiIVT6MS3EEzNjnir3PC1HE0mKGGUFg6RBW1HmRvWKuRDPkij1etOoQkpyIFmjVGcM6TpBGF3qQ==", + "dependencies": { + "TestableIO.System.IO.Abstractions": "22.1.1", + "TestableIO.System.IO.Abstractions.Wrappers": "22.1.1" + } + }, + "TestableIO.System.IO.Abstractions": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "EJmXIKfwvJHvSHWP+NjA213avUdofV4SmBx2aWxc3SzPkrHHVKxUMC3fSa/epzk/SVITwuU2EAntgQTvobAqwQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "TestableIO.System.IO.Abstractions.Wrappers": { + "type": "Transitive", + "resolved": "22.1.1", + "contentHash": "J91zmSwuMY75ybtkqAFMC3vDNMkLJ/kiDDLhORITEwTKNhEwEYfxAQGv+f0jGYZg/H0YbnGcx5Ky0FgFJwrrWQ==", + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + } + }, + "Testably.Abstractions.FileSystem.Interface": { + "type": "Transitive", + "resolved": "10.1.0", + "contentHash": "0R+m7DvS2QuDcFOfDGtyGErRrgXOl4/z2tbNLX2RfvlYJjlVcKG+wMIBfZO8vxGtTNwNxffTG4ibFRwd0jkM7w==" + }, + "TextMateSharp": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "5Pvn+A4zb1IF+ACAVhgw1aGf9kTyx6j7v5hdup7nCS89Nzby8KQAlCfQ54VC/GAsEoQwh4zKa5MKlAaql8nkPg==", + "dependencies": { + "Onigwrap": "1.0.11" + } + }, + "TextMateSharp.Grammars": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "jzOY00q5u5Vs4L9n2m2PnRZDGwWt2uwkOLh7qlVdCx1kkGSn5eaW5XgnYCyTzEbsCwLV2xlEolnNkr/wuIMAbA==", + "dependencies": { + "TextMateSharp": "2.0.4" + } + }, + "Wcwidth": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "PMU8u3pGf9nUkee39KMrB8qqId7u7kyGWaZpOh9E4Mal3xChq+relS/hHQOicGAswyzi7F5DjXjac0mTnRBX8Q==" + } + }, + "net10.0/win-x64": { + "Onigwrap": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "5/WAxSYWfiiPzp1X13qqdqjFmYLKDD/U7Vh3WCkKxWG9BjqdrXnQE5fCKCFJX+xWvuYLTxwZIVWBwBWObViE8g==" + } + } + } +} \ No newline at end of file diff --git a/scripts/verify-third-party-inventory.sh b/scripts/verify-third-party-inventory.sh new file mode 100755 index 0000000..d202d30 --- /dev/null +++ b/scripts/verify-third-party-inventory.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +inventory="$repo_root/licenses/THIRD-PARTY-PACKAGES.tsv" +package_root="${NUGET_PACKAGES:-$HOME/.nuget/packages}" +supported_rids=(linux-x64 linux-arm64 win-x64 win-arm64 osx-x64 osx-arm64) + +command -v jq >/dev/null 2>&1 || { + echo "jq is required to validate the third-party inventory" >&2 + exit 1 +} + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT + +awk -F '\t' 'NR > 1 { print $1 "\t" $2 }' "$inventory" | sort \ + > "$tmp_dir/inventory.tsv" + +for rid in "${supported_rids[@]}"; do + lock_file="$repo_root/packages.$rid.lock.json" + target="net10.0/$rid" + + if [ ! -f "$lock_file" ]; then + echo "Missing dependency lock: $lock_file" >&2 + exit 1 + fi + + jq -e --arg target "$target" \ + '(.dependencies | keys | sort) == (["net10.0", $target] | sort)' \ + "$lock_file" >/dev/null + + jq -r '.dependencies["net10.0"] | to_entries[] | [.key, .value.resolved] | @tsv' \ + "$lock_file" | sort > "$tmp_dir/locked-$rid.tsv" + if ! diff -u "$tmp_dir/inventory.tsv" "$tmp_dir/locked-$rid.tsv"; then + echo "Third-party inventory does not match packages.$rid.lock.json" >&2 + exit 1 + fi + + jq -r --arg target "$target" \ + '.dependencies[$target] | to_entries[] | [.key, .value.resolved] | @tsv' \ + "$lock_file" | sort > "$tmp_dir/runtime-$rid.tsv" + if ! diff -u <(printf 'Onigwrap\t1.0.11\n') "$tmp_dir/runtime-$rid.tsv"; then + echo "Unexpected RID-specific dependency graph in packages.$rid.lock.json" >&2 + exit 1 + fi +done + +cmp "$repo_root/licenses/ONIGWRAP-THIRD-PARTY-NOTICES.txt" \ + "$package_root/onigwrap/1.0.11/THIRD-PARTY-NOTICES.TXT" +tr -d '\r' < "$package_root/opcfoundation.netstandard.opc.ua.client/1.5.378.156/LICENSE.txt" \ + > "$tmp_dir/opc-foundation-license.txt" +cmp "$repo_root/licenses/OPC-FOUNDATION-LICENSE.txt" \ + "$tmp_dir/opc-foundation-license.txt" + +reference_notice="$package_root/microsoft.extensions.dependencyinjection/10.0.8/THIRD-PARTY-NOTICES.TXT" +for package_notice in \ + "$package_root/microsoft.extensions.dependencyinjection.abstractions/10.0.8/THIRD-PARTY-NOTICES.TXT" \ + "$package_root/microsoft.extensions.logging/10.0.8/THIRD-PARTY-NOTICES.TXT" \ + "$package_root/microsoft.extensions.logging.abstractions/10.0.8/THIRD-PARTY-NOTICES.TXT" \ + "$package_root/microsoft.extensions.options/10.0.8/THIRD-PARTY-NOTICES.TXT" \ + "$package_root/microsoft.extensions.primitives/10.0.8/THIRD-PARTY-NOTICES.TXT" +do + cmp "$reference_notice" "$package_notice" +done + +echo "Third-party package inventory and exact packaged notices are current." diff --git a/uninstall.ps1 b/uninstall.ps1 index b7d77fc..6163c3f 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -3,13 +3,59 @@ $ErrorActionPreference = "Stop" -$InstallDir = if ($env:OPCILLOSCOPE_INSTALL_DIR) { $env:OPCILLOSCOPE_INSTALL_DIR } else { "$env:LOCALAPPDATA\Opcilloscope" } -$ConfigDir = "$env:APPDATA\opcilloscope" -$CertDir = "$env:LOCALAPPDATA\opcilloscope" +$UsingCustomInstallDir = -not [string]::IsNullOrWhiteSpace($env:OPCILLOSCOPE_INSTALL_DIR) +$InstallDir = if ($UsingCustomInstallDir) { + $env:OPCILLOSCOPE_INSTALL_DIR +} else { + Join-Path $env:LOCALAPPDATA "Programs\opcilloscope" +} +$LicenseDir = Join-Path $InstallDir "opcilloscope-licenses" +$LegacyInstallDir = Join-Path $env:LOCALAPPDATA "Opcilloscope" +$ConfigDir = Join-Path $env:APPDATA "opcilloscope" +$CertificateDir = Join-Path $env:LOCALAPPDATA "opcilloscope\pki" function Write-Info { param($Message) Write-Host "[INFO] $Message" -ForegroundColor Green } function Write-Warn { param($Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow } +function Normalize-PathEntry { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return "" } + $expanded = [Environment]::ExpandEnvironmentVariables($Path.Trim().Trim('"')) + return $expanded.TrimEnd('\').TrimEnd('/') +} + +function Remove-UserPathEntry { + param([string]$Entry) + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ([string]::IsNullOrEmpty($userPath)) { return $false } + + $target = Normalize-PathEntry $Entry + $originalEntries = @($userPath -split ";" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $newEntries = @($originalEntries | Where-Object { (Normalize-PathEntry $_) -ine $target }) + if ($newEntries.Count -eq $originalEntries.Count) { return $false } + + [Environment]::SetEnvironmentVariable("Path", ($newEntries -join ";"), "User") + return $true +} + +function Confirm-Removal { + param([string]$Prompt, [bool]$Interactive) + if (-not $Interactive) { + Write-Info "$Prompt skipped (run interactively to remove retained user data)" + return $false + } + + $answer = Read-Host "$Prompt [y/N]" + return $answer -eq "y" -or $answer -eq "Y" +} + +function Remove-DirectoryIfEmpty { + param([string]$Path) + if ((Test-Path $Path) -and -not (Get-ChildItem -Path $Path -Force | Select-Object -First 1)) { + Remove-Item $Path -Force + } +} + function Uninstall-Opcilloscope { Write-Host "" Write-Host " +===================================+" -ForegroundColor Cyan @@ -19,65 +65,68 @@ function Uninstall-Opcilloscope { Write-Host "" $removedSomething = $false + $interactive = [Environment]::UserInteractive -and -not ([Console]::IsInputRedirected) $exePath = Join-Path $InstallDir "opcilloscope.exe" - # Remove binary / install directory + # InstallDir may be a shared custom directory. Remove only known app files. if (Test-Path $exePath) { - Remove-Item $InstallDir -Recurse -Force - Write-Info "Removed install directory: $InstallDir" - $removedSomething = $true - } elseif (Test-Path $InstallDir) { - Remove-Item $InstallDir -Recurse -Force - Write-Info "Removed install directory: $InstallDir" + Remove-Item $exePath -Force + Write-Info "Removed executable: $exePath" $removedSomething = $true } else { - Write-Warn "Install directory not found at $InstallDir" + Write-Warn "Executable not found at $exePath" } - # Remove from PATH - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - if ($userPath -and $userPath -like "*$InstallDir*") { - $newPath = ($userPath -split ";" | Where-Object { $_ -ne $InstallDir }) -join ";" - [Environment]::SetEnvironmentVariable("Path", $newPath, "User") - Write-Info "Removed $InstallDir from user PATH" + if (Test-Path $LicenseDir) { + Remove-Item $LicenseDir -Recurse -Force + Write-Info "Removed license notices: $LicenseDir" + $removedSomething = $true + } + + if (-not $UsingCustomInstallDir) { + if (Remove-UserPathEntry $InstallDir) { + Write-Info "Removed $InstallDir from user PATH" + } + + # Legacy releases put the executable in the same case-insensitive parent + # used for certificates. Remove only the known executable and PATH entry. + $legacyExe = Join-Path $LegacyInstallDir "opcilloscope.exe" + if (Test-Path $legacyExe) { + Remove-Item $legacyExe -Force + Write-Info "Removed legacy executable: $legacyExe" + $removedSomething = $true + } + if (Remove-UserPathEntry $LegacyInstallDir) { + Write-Info "Removed legacy path entry: $LegacyInstallDir" + } + Remove-DirectoryIfEmpty $InstallDir } - # Prompt to remove config directory if (Test-Path $ConfigDir) { - $interactive = [Environment]::UserInteractive -and -not ([Console]::IsInputRedirected) - if ($interactive) { - $answer = Read-Host "Remove configuration directory $ConfigDir? [y/N]" - if ($answer -eq "y" -or $answer -eq "Y") { - Remove-Item $ConfigDir -Recurse -Force - Write-Info "Removed config directory: $ConfigDir" - } else { - Write-Info "Kept config directory: $ConfigDir" - } + if (Confirm-Removal "Remove configuration directory $ConfigDir?" $interactive) { + Remove-Item $ConfigDir -Recurse -Force + Write-Info "Removed configuration directory: $ConfigDir" + $removedSomething = $true } else { - Write-Info "Kept config directory: $ConfigDir (run interactively to remove)" + Write-Info "Kept configuration directory: $ConfigDir" } } - # Prompt to remove certificate directory - if (Test-Path $CertDir) { - $interactive = [Environment]::UserInteractive -and -not ([Console]::IsInputRedirected) - if ($interactive) { - $answer = Read-Host "Remove OPC UA certificates directory $CertDir? [y/N]" - if ($answer -eq "y" -or $answer -eq "Y") { - Remove-Item $CertDir -Recurse -Force - Write-Info "Removed certificates directory: $CertDir" - } else { - Write-Info "Kept certificates directory: $CertDir" - } + if (Test-Path $CertificateDir) { + if (Confirm-Removal "Remove OPC UA certificate store $CertificateDir?" $interactive) { + Remove-Item $CertificateDir -Recurse -Force + Write-Info "Removed certificate store: $CertificateDir" + $removedSomething = $true + Remove-DirectoryIfEmpty $LegacyInstallDir } else { - Write-Info "Kept certificates directory: $CertDir (run interactively to remove)" + Write-Info "Kept certificate store: $CertificateDir" } } Write-Host "" if ($removedSomething) { Write-Info "Opcilloscope has been uninstalled." - Write-Host "(You may need to restart your terminal for PATH changes to take effect)" -ForegroundColor Gray + Write-Host "(Restart open terminals to pick up PATH changes.)" -ForegroundColor Gray } else { Write-Warn "Opcilloscope does not appear to be installed at $InstallDir." Write-Host "" diff --git a/uninstall.sh b/uninstall.sh index 5f55b11..f3acc70 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -5,20 +5,44 @@ set -e # Usage: curl -fsSL https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/uninstall.sh | bash INSTALL_DIR="${OPCILLOSCOPE_INSTALL_DIR:-$HOME/.local/bin}" -CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opcilloscope" -DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/opcilloscope" -# Colors +if [ "$(uname -s)" = "Darwin" ]; then + # .NET 8+ maps ApplicationData and LocalApplicationData to this directory. + CONFIG_DIR="$HOME/Library/Application Support/opcilloscope" + DATA_DIR="$CONFIG_DIR" + MACOS=true +else + CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opcilloscope" + DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/opcilloscope" + MACOS=false +fi + +CERT_DIR="${DATA_DIR}/pki" +LICENSE_DIR="${DATA_DIR}/licenses" + RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' -NC='\033[0m' # No Color +NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $1"; } warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } -error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } -uninstall() { +confirm_removal() { + local prompt="$1" answer + + echo -n "$prompt [y/N] " + if [ -t 0 ]; then + read -r answer + else + answer="n" + echo "(skipped — run interactively to remove retained user data)" + fi + + [ "$answer" = "y" ] || [ "$answer" = "Y" ] +} + +uninstall_opcilloscope() { echo "" echo " ╔═══════════════════════════════════╗" echo " ║ Opcilloscope Uninstaller ║" @@ -28,8 +52,9 @@ uninstall() { local binary="${INSTALL_DIR}/opcilloscope" local removed_something=false + local has_config=false - # Remove binary + # INSTALL_DIR may be a shared custom bin directory. Remove only our file. if [ -f "$binary" ]; then rm "$binary" info "Removed binary: ${binary}" @@ -38,46 +63,58 @@ uninstall() { warn "Binary not found at ${binary}" fi - # Remove config directory - if [ -d "$CONFIG_DIR" ]; then - echo "" - echo -n "Remove configuration directory ${CONFIG_DIR}? [y/N] " - # When piped from curl, stdin is the script itself, so default to no - if [ -t 0 ]; then - read -r answer - else - answer="n" - echo "(skipped — run interactively to remove config files)" - fi + # License material is installer-owned and safe to remove automatically. + if [ -d "$LICENSE_DIR" ]; then + rm -rf "$LICENSE_DIR" + info "Removed license notices: ${LICENSE_DIR}" + removed_something=true + fi - if [ "$answer" = "y" ] || [ "$answer" = "Y" ]; then - rm -rf "$CONFIG_DIR" - info "Removed config directory: ${CONFIG_DIR}" - else - info "Kept config directory: ${CONFIG_DIR}" + if [ "$MACOS" = true ]; then + # Config and certificates share one macOS Application Support parent. + # Treat only the known config entries as configuration data so a user + # can retain the PKI store independently. + if [ -d "${CONFIG_DIR}/configs" ] || [ -f "${CONFIG_DIR}/recent-files.json" ]; then + has_config=true fi + elif [ -d "$CONFIG_DIR" ]; then + has_config=true fi - # Remove data directory (OPC UA certificate stores) - if [ -d "$DATA_DIR" ]; then + if [ "$has_config" = true ]; then echo "" - echo -n "Remove OPC UA certificates directory ${DATA_DIR}? [y/N] " - # When piped from curl, stdin is the script itself, so default to no - if [ -t 0 ]; then - read -r answer + if confirm_removal "Remove opcilloscope configuration data at ${CONFIG_DIR}?"; then + if [ "$MACOS" = true ]; then + rm -rf "${CONFIG_DIR}/configs" + rm -f "${CONFIG_DIR}/recent-files.json" + else + rm -rf "$CONFIG_DIR" + fi + info "Removed configuration data: ${CONFIG_DIR}" + removed_something=true else - answer="n" - echo "(skipped — run interactively to remove certificate files)" + info "Kept configuration data: ${CONFIG_DIR}" fi + fi - if [ "$answer" = "y" ] || [ "$answer" = "Y" ]; then - rm -rf "$DATA_DIR" - info "Removed certificates directory: ${DATA_DIR}" + if [ -d "$CERT_DIR" ]; then + echo "" + if confirm_removal "Remove OPC UA certificate store ${CERT_DIR}?"; then + rm -rf "$CERT_DIR" + info "Removed certificate store: ${CERT_DIR}" + removed_something=true else - info "Kept certificates directory: ${DATA_DIR}" + info "Kept certificate store: ${CERT_DIR}" fi fi + # Remove app-owned parents only when they are empty. Never remove a shared + # custom install directory. + rmdir "$DATA_DIR" 2>/dev/null || true + if [ "$CONFIG_DIR" != "$DATA_DIR" ]; then + rmdir "$CONFIG_DIR" 2>/dev/null || true + fi + echo "" if [ "$removed_something" = true ]; then info "Opcilloscope has been uninstalled." @@ -89,4 +126,4 @@ uninstall() { fi } -uninstall +uninstall_opcilloscope