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/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..88c89ac 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; @@ -27,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, @@ -44,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); @@ -121,7 +128,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..31a6925 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; @@ -20,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. @@ -161,7 +164,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 +201,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 +216,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 +224,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 +232,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,15 +246,18 @@ 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; } // 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 = 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..8207444 100644 --- a/App/Dialogs/WriteValueDialog.cs +++ b/App/Dialogs/WriteValueDialog.cs @@ -148,15 +148,15 @@ 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"); - + 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 @@ -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; @@ -210,7 +212,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; } @@ -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/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..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 @@ -185,7 +203,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) @@ -245,7 +263,7 @@ private void RunStatusBarStartup() }); UpdateConnectionStatusLabelPosition(); - _startupStatusTimer = Application.AddTimeout(TimeSpan.FromSeconds(1), () => + _startupStatusTimer = TerminalUi.AddTimeout(TimeSpan.FromSeconds(1), () => { step++; if (step == 1) @@ -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( @@ -404,7 +425,7 @@ private void ShowConnectDialog() currentInterval, currentCredentials.Type, currentCredentials.Username); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (dialog.Confirmed) { @@ -415,45 +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; - _addressSpaceView.Initialize(_connectionManager.NodeBrowser); + 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(); @@ -461,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)) { @@ -471,16 +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) { - _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 @@ -490,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(); @@ -524,6 +643,8 @@ private void UnsubscribeSelected() private void WriteSelected() { + if (RejectInteractiveMutationWhileBusy("write a value")) return; + if (!_connectionManager.IsConnected) { _logger.Warning("Not connected"); @@ -554,14 +675,21 @@ 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 (!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}"); - 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; } @@ -570,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) @@ -607,37 +752,69 @@ 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(() => 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 (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}"); - 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; } - 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); - Application.Run(dialog); + TerminalUi.RunModal(dialog); 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}"); @@ -655,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 @@ -747,7 +926,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 @@ -773,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)) { @@ -782,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"); @@ -798,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); @@ -835,23 +1040,41 @@ private void OnConnectionError(string message) { UiThread.Run(() => { - MessageBox.ErrorQuery(Application.Instance, "Connection Error", message, "OK"); + TerminalUi.ErrorQuery("Connection Error", message, "OK"); }); } - 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; @@ -892,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(); @@ -906,7 +1138,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 +1157,7 @@ private void StopConnectingAnimation() _isConnecting = false; if (_connectingAnimationTimer != null) { - Application.RemoveTimeout(_connectingAnimationTimer); + TerminalUi.RemoveTimeout(_connectingAnimationTimer); _connectingAnimationTimer = null; } } @@ -957,7 +1189,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 +1197,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 +1216,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 +1224,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 +1238,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,28 +1249,85 @@ private void OnRecordRequested() } else { - MessageBox.ErrorQuery(Application.Instance, "Recording Error", "Failed to start recording", "OK"); + TerminalUi.ErrorQuery("Recording Error", "Failed to start recording", "OK"); } } } private void OnStopRecordingRequested() + => StopRecordingAndReportAsync().FireAndForget(_logger); + + private Task StopRecordingAndReportAsync() + { + lock (_recordingStopLock) + { + if (_recordingStopTask is { IsCompleted: false }) + { + return _recordingStopTask; + } + + _recordingStopTask = StopRecordingAndReportCoreAsync(); + return _recordingStopTask; + } + } + + private async Task StopRecordingAndReportCoreAsync() { - if (!_csvRecordingManager.IsRecording) + if (!_csvRecordingManager.IsRecording && !_csvRecordingManager.IsStopping) { return; } - StopRecordingStatusUpdates(); - _csvRecordingManager.StopRecording(); - _monitoredVariablesView.UpdateRecordingStatus("", false); - MessageBox.Query(Application.Instance, "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() { - // 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 +1343,7 @@ private void StopRecordingStatusUpdates() { if (_recordingStatusTimer != null) { - Application.RemoveTimeout(_recordingStatusTimer); + TerminalUi.RemoveTimeout(_recordingStatusTimer); _recordingStatusTimer = null; } } @@ -1062,7 +1351,7 @@ private void StopRecordingStatusUpdates() private void ShowHelp() { using var dialog = new HelpDialog(_keybindingManager); - Application.Run(dialog); + TerminalUi.RunModal(dialog); } private void ShowAbout() @@ -1093,7 +1382,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"); } /// @@ -1125,12 +1414,14 @@ private static string GetDisplayVersion() /// private void OpenConfig() { + if (RejectInteractiveMutationWhileBusy("open a configuration")) return; + if (_configService.HasUnsavedChanges && !ConfirmDiscardChanges()) return; using var dialog = new Dialogs.OpenConfigDialog(); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (dialog.Confirmed && dialog.SelectedFilePath != null) { @@ -1143,6 +1434,8 @@ private void OpenConfig() /// private void SaveConfig() { + if (RejectInteractiveMutationWhileBusy("save the configuration")) return; + if (string.IsNullOrEmpty(_configService.CurrentFilePath)) { SaveConfigAs(); @@ -1159,13 +1452,15 @@ 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); using var dialog = new Dialogs.SaveConfigDialog(defaultDir, defaultFilename); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (dialog.Confirmed) { @@ -1176,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); @@ -1194,18 +1501,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); + TerminalUi.RunModal(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,48 +1526,78 @@ 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 // 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) { _lastEndpoint = config.Server.EndpointUrl; _currentMetadata = config.Metadata; - _addressSpaceView.Initialize(_connectionManager.NodeBrowser); + 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); - UpdateWindowTitle(); + if (allSubscriptionsRestored) + { + _configService.MarkClean(); + } + else + { + _configService.MarkDirty(); + } + UiThread.Run(UpdateWindowTitle); var nodeCount = config.MonitoredNodes.Count(n => n.Enabled); _logger.Info($"Configuration loaded: {nodeCount} nodes"); @@ -1265,55 +1608,129 @@ 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(); + 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"); + }); } } else { // 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); - UpdateWindowTitle(); + _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}"); - MessageBox.ErrorQuery(Application.Instance, "Error", $"Failed to load configuration:\n{ex.Message}", "OK"); + 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 @@ -1326,18 +1743,19 @@ 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(() => + TerminalUi.ErrorQuery("Error", $"Failed to save:\n{ex.Message}", "OK")); } finally { - UiThread.Run(HideActivity); + await UiThread.RunAsync(HideActivity); } } @@ -1362,7 +1780,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", @@ -1371,13 +1789,145 @@ 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. /// /// 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; @@ -1402,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 @@ -1414,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) - { - Application.RemoveTimeout(_startupStatusTimer); - _startupStatusTimer = null; - } + CancelStartupStatus(); _csvRecordingManager.Dispose(); ThemeManager.ThemeChanged -= OnThemeChanged; @@ -1431,7 +1977,7 @@ protected override void Dispose(bool disposing) _focusManager.FocusChanged -= OnPanelFocusChanged; } - Application.KeyDown -= OnApplicationKeyDown; + TerminalUi.RemoveKeyDownHandler(OnApplicationKeyDown); _connectionManager.Dispose(); } 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/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/App/Views/AddressSpaceView.cs b/App/Views/AddressSpaceView.cs index b81dc4d..de1ad1a 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; @@ -17,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; @@ -80,7 +82,7 @@ public AddressSpaceView() private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { _emptyStateLabel.SetScheme(new Scheme { @@ -93,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 { @@ -120,19 +126,25 @@ private async Task RefreshAsync() } // Update UI on main thread - Application.Invoke(() => + 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; @@ -141,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) @@ -149,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 - Application.Invoke(() => + UiThread.Run(() => { + if (!IsCurrentView(nodeBrowser, viewGeneration, connectionGeneration)) + return; + _treeView.RefreshObject(node); if (node.ChildrenLoaded && node.Children.Count > 0) { @@ -178,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/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..2325d5c 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; @@ -25,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; @@ -92,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; @@ -221,7 +236,7 @@ private void UpdateEmptyState() private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { BorderStyle = theme.EmphasizedBorderStyle; @@ -282,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 = Application.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)) @@ -396,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) @@ -532,7 +693,7 @@ private void ToggleScopeSelectionForVariable(MonitoredNode variable) _ = Task.Run(async () => { await Task.Delay(2000); - Application.Invoke(() => _selectionFeedback.Visible = false); + UiThread.Run(() => _selectionFeedback.Visible = false); }); return; } @@ -584,17 +745,21 @@ protected override void Dispose(bool disposing) { if (disposing) { - // Stop the update timer + object? updateTimer; lock (_timerLock) { - if (_updateTimer != null) - { - Application.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 7fc5aba..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; @@ -60,7 +61,7 @@ public NodeDetailsView() private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { // Update copy button styling _copyButton.SetScheme(theme.ButtonColorScheme); @@ -96,12 +97,15 @@ 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; - Application.Invoke(() => + _currentConnectionGeneration = 0; + UiThread.Run(() => { _detailsLabel.Text = "Select a node to view details"; _copyButton.Enabled = false; @@ -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); - 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. - if (!Equals(_currentNodeId, nodeId)) + if (!Equals(_currentNodeId, nodeId) + || _currentConnectionGeneration != generation + || _nodeBrowser?.IsConnectionGenerationActive(generation) != true) return; if (attrs == null) @@ -156,7 +164,8 @@ public async Task ShowNodeAsync(BrowsedNode? node) if (node == null || _nodeBrowser == null) { _currentNodeId = null; - Application.Invoke(() => + _currentConnectionGeneration = 0; + UiThread.Run(() => { _detailsLabel.Text = ""; _copyButton.Enabled = false; @@ -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); - 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. - 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,13 +289,18 @@ 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; - Application.Invoke(() => + UiThread.Run(() => { if (attributes == null || attributes.Count == 0) { @@ -291,7 +310,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 +326,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 +336,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..ab81f6c 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; @@ -133,7 +134,7 @@ private void OnThemeChanged(AppTheme newTheme) try { - Application.Invoke(() => + UiThread.Run(() => { ApplyTheme(); SetNeedsLayout(); @@ -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); @@ -278,14 +279,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; } } @@ -908,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; @@ -965,7 +964,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 +987,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/CLAUDE.md b/CLAUDE.md index a9daada..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 ``` @@ -124,7 +138,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) @@ -195,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, @@ -218,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 @@ -251,27 +271,56 @@ 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 - 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: @@ -301,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) { @@ -309,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) { @@ -319,7 +370,7 @@ var monitoredItem = new MonitoredItem(subscription.DefaultItem) { }; monitoredItem.Notification += OnNotification; subscription.AddItem(monitoredItem); -subscription.ApplyChanges(); +await subscription.ApplyChangesAsync(); ``` ### ConnectionManager Pattern @@ -339,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 @@ -414,13 +465,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: @@ -433,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 -3. **UI thread exceptions**: Always use `Application.Invoke()` or `UiThread.Run()` for UI updates from background threads +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 b1a5ae3..f213c9c 100644 --- a/Opcilloscope.csproj +++ b/Opcilloscope.csproj @@ -9,6 +9,12 @@ opcilloscope true + + true + v Brett Kinny @@ -22,12 +28,23 @@ true + + true true false + + + true + packages.$(NETCoreSdkPortableRuntimeIdentifier).lock.json + packages.$(RuntimeIdentifier).lock.json @@ -36,8 +53,8 @@ - - + + diff --git a/Program.cs b/Program.cs index acaab5b..c677370 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,89 +9,68 @@ 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 - // 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; } -#pragma warning disable IL2026 // Terminal.Gui Application.Init uses reflection and is not AOT-compatible - Application.Init(); -#pragma warning restore IL2026 - initialized = true; + // Warn about unimplemented auto-connect before initializing the terminal; + // once the alternate screen buffer is active the message would be lost. + if (string.IsNullOrEmpty(options.ConfigPath) && !string.IsNullOrEmpty(options.AutoConnectUrl)) + { + Console.Error.WriteLine( + $"Warning: Auto-connect via command-line URL ('{options.AutoConnectUrl}') is not currently implemented. " + + "Please use a configuration file with an endpoint URL instead."); + } + + app = Application.Create(); + TerminalUi.App = app; + app.Init(); var mainWindow = new MainWindow(); try { // Load config file if specified (takes precedence over URL) - if (!string.IsNullOrEmpty(configPath)) - { - mainWindow.LoadConfigFromCommandLine(configPath); - } - // Otherwise, if auto-connect URL provided, show warning (not yet implemented) - else if (!string.IsNullOrEmpty(autoConnectUrl)) + if (!string.IsNullOrEmpty(options.ConfigPath)) { - 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."); + mainWindow.LoadConfigFromCommandLine(options.ConfigPath); } - Application.Run(mainWindow); + 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(); } } @@ -102,10 +82,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; @@ -119,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/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/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 891a16f..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; @@ -280,8 +281,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 +633,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); }); } @@ -829,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 new file mode 100644 index 0000000..926dda4 --- /dev/null +++ b/Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs @@ -0,0 +1,214 @@ +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(); + app.Setup(a => a.Invoke(It.IsAny())) + .Callback(callback => callback()); + TerminalUi.App = app.Object; + var ran = false; + Action action = () => ran = true; + + TerminalUi.Invoke(action); + + Assert.True(ran); + app.Verify(a => a.Invoke(It.IsAny()), 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..b1bd88d --- /dev/null +++ b/Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs @@ -0,0 +1,107 @@ +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(); + app.Setup(a => a.Invoke(It.IsAny())) + .Callback(callback => callback()); + TerminalUi.App = app.Object; + var ran = false; + Action action = () => ran = true; + + UiThread.Run(action); + + 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 a191f2d..1174ced 100644 --- a/Utilities/CsvRecordingManager.cs +++ b/Utilities/CsvRecordingManager.cs @@ -1,16 +1,29 @@ -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. -/// 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 { @@ -137,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; @@ -157,27 +181,114 @@ public bool IsRecording { lock (_lock) { - return _isRecording; + return _session is not null; + } + } + } + + 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 string? FilePath => _filePath; - public long RecordCount => Interlocked.Read(ref _recordCount); 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; } /// @@ -187,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(); - - // Discard any stale snapshots left over from a previous session - // so they cannot cross-contaminate the new recording. - while (_recordQueue.TryDequeue(out _)) { } + writer.WriteLine("Timestamp,DisplayName,NodeId,Value,Status"); + writer.Flush(); - _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; } } @@ -236,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 @@ -341,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 + { + session.Writer.Dispose(); + } + catch (Exception ex) { - WriteRecord(item); + RegisterSessionError(session, "Recording file close failed", ex); + } + + lock (_lock) + { + if (ReferenceEquals(_stoppingSession, session)) + { + _stoppingSession = null; + } } } } @@ -404,51 +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. - var timestamp = item.Timestamp?.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture) - ?? DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fff", 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) @@ -505,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 new file mode 100644 index 0000000..35aaf99 --- /dev/null +++ b/Utilities/TerminalUi.cs @@ -0,0 +1,233 @@ +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. +/// +/// +/// 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 +{ + 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 + { + 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)."); + + /// + /// 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) + { + if (!TryGetDispatchContext(out var app, out var shutdownToken)) + { + return; + } + + app!.Invoke(() => + { + if (!shutdownToken.IsCancellationRequested) + { + 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 , or null when no + /// application is running. + /// + public static object? AddTimeout(TimeSpan interval, Func callback) + { + return App?.AddTimeout(interval, callback); + } + + /// + /// Removes a timeout previously added with . + /// + public static void RemoveTimeout(object token) + { + App?.RemoveTimeout(token); + } + + /// + /// Runs a view (dialog) modally, blocking until it requests stop. + /// + public static void RunModal(IRunnable view) + { + RequireApp().Run(view); + } + + /// + /// Requests that the currently running (top) view stop, closing a modal dialog. + /// + public static void RequestStop() + { + RequireApp().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(RequireApp(), 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(RequireApp(), title, message, buttons); + } + + /// + /// Copies text to the OS clipboard. Returns true on success. + /// + public static bool TrySetClipboardData(string 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 => App?.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 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..cb34da5 100644 --- a/Utilities/UiThread.cs +++ b/Utilities/UiThread.cs @@ -1,5 +1,3 @@ -using Terminal.Gui; - namespace Opcilloscope.Utilities; /// @@ -8,10 +6,74 @@ 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); + } + + /// + /// 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) + { + 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); + 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 + { + app!.Invoke(() => + { + 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 new file mode 100644 index 0000000..21f92b2 --- /dev/null +++ b/docs/deep-repo-review-2026-07-07.md @@ -0,0 +1,173 @@ +# 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. + +**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. + +> **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 + +### 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`. 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