From 12d748c4f3b10a07f9ab70abaea10e74b683896f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 01:41:16 -0400 Subject: [PATCH 1/2] =?UTF-8?q?CLAUDE.md=20=E2=80=94=20TopShelf=20+=20Ldap?= =?UTF-8?q?AuthenticationProvider=20stale=20references.=20Closes=20task=20?= =?UTF-8?q?#207.=20The=20docs-refresh=20agent=20sweep=20(PR=20#149)=20flag?= =?UTF-8?q?ged=20two=20stale=20library/class=20references=20in=20the=20roo?= =?UTF-8?q?t=20CLAUDE.md=20that=20the=20v2=20refactors=20landed=20but=20th?= =?UTF-8?q?e=20project-level=20instructions=20missed.=20Service=20hosting?= =?UTF-8?q?=20line=20replaced=20with=20the=20two-process=20reality:=20Serv?= =?UTF-8?q?er=20+=20Admin=20use=20.NET=20generic-host=20AddWindowsService?= =?UTF-8?q?=20(decision=20#30=20explicitly=20replaced=20TopShelf=20in=20v2?= =?UTF-8?q?=20=E2=80=94=20OpcUaServerService.cs=20carries=20the=20decision?= =?UTF-8?q?-#30=20comment=20inline);=20Galaxy.Host=20is=20a=20plain=20cons?= =?UTF-8?q?ole=20app=20wrapped=20by=20NSSM=20because=20its=20.NET-Framewor?= =?UTF-8?q?k-4.8-x86=20target=20can't=20use=20the=20generic-host=20Windows?= =?UTF-8?q?-service=20integration=20+=20MXAccess=20COM=20bitness=20require?= =?UTF-8?q?ment=20pins=20it=20there=20anyway.=20The=20LDAP-auth=20mention?= =?UTF-8?q?=20gains=20the=20actual=20class=20name=20LdapUserAuthenticator?= =?UTF-8?q?=20(src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator?= =?UTF-8?q?.cs)=20implementing=20IUserAuthenticator=20=E2=80=94=20previous?= =?UTF-8?q?ly=20claimed=20LdapAuthenticationProvider=20+=20IUserAuthentica?= =?UTF-8?q?tionProvider=20+=20IRoleProvider,=20none=20of=20which=20exist?= =?UTF-8?q?=20in=20the=20source=20tree=20(the=20docs-refresh=20agent=20gre?= =?UTF-8?q?pped=20for=20it;=20it's=20truly=20gone).=20No=20functional=20im?= =?UTF-8?q?pact=20=E2=80=94=20CLAUDE.md=20is=20operator-facing=20+=20infor?= =?UTF-8?q?ms=20future=20agent=20runs=20about=20the=20stack,=20not=20compi?= =?UTF-8?q?le-time.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d1df102..93f8a35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,13 +87,14 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec ## LDAP Authentication -The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference. +The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapUserAuthenticator` (`src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs`) implements `IUserAuthenticator`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference. ## Library Preferences - **Logging**: Serilog with rolling daily file sink - **Unit tests**: xUnit + Shouldly for assertions -- **Service hosting**: TopShelf (Windows service install/uninstall/run as console) +- **Service hosting (Server, Admin)**: .NET generic host with `AddWindowsService` (decision #30 — replaced TopShelf in v2; see `src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs`) +- **Service hosting (Galaxy.Host)**: plain console app wrapped by NSSM (`.NET Framework 4.8 x86` — required by MXAccess COM bitness) - **OPC UA**: OPC Foundation UA .NET Standard stack (https://github.com/opcfoundation/ua-.netstandard) — NuGet: `OPCFoundation.NetStandard.Opc.Ua.Server` ## OPC UA .NET Standard Documentation From f9bc301c33e841ea4180e03422c6269b57ce44e6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 01:50:40 -0400 Subject: [PATCH 2/2] =?UTF-8?q?Client=20rename=20residuals:=20lmxopcua-cli?= =?UTF-8?q?=20=E2=86=92=20otopcua-cli=20+=20LmxOpcUaClient=20=E2=86=92=20O?= =?UTF-8?q?tOpcUaClient=20with=20migration=20shim.=20Closes=20task=20#208?= =?UTF-8?q?=20(the=20executable-name=20+=20LocalAppData-folder=20slice=20t?= =?UTF-8?q?hat=20was=20called=20out=20in=20Client.CLI.md=20/=20Client.UI.m?= =?UTF-8?q?d=20as=20a=20deliberately-deferred=20residual=20of=20the=20Phas?= =?UTF-8?q?e=200=20rename).=20Six=20source=20references=20flipped=20to=20t?= =?UTF-8?q?he=20canonical=20OtOpcUaClient=20spelling:=20Program.cs=20CliFx?= =?UTF-8?q?=20executable=20name=20+=20description=20(lmxopcua-cli=20?= =?UTF-8?q?=E2=86=92=20otopcua-cli),=20DefaultApplicationConfigurationFact?= =?UTF-8?q?ory.cs=20ApplicationName=20+=20ApplicationUri=20(LmxOpcUaClient?= =?UTF-8?q?=20+=20urn:localhost:LmxOpcUaClient=20=E2=86=92=20OtOpcUaClient?= =?UTF-8?q?=20+=20urn:localhost:OtOpcUaClient),=20OpcUaClientService.Creat?= =?UTF-8?q?eSessionAsync=20session-name=20arg,=20ConnectionSettings.Certif?= =?UTF-8?q?icateStorePath=20default,=20MainWindowViewModel.CertificateStor?= =?UTF-8?q?ePath=20default,=20JsonSettingsService.SettingsDir.=20Two=20con?= =?UTF-8?q?suming=20tests=20(ConnectionSettingsTests=20+=20MainWindowViewM?= =?UTF-8?q?odelTests)=20updated=20to=20assert=20the=20new=20canonical=20na?= =?UTF-8?q?me.=20New=20ClientStoragePaths=20static=20helper=20at=20src/ZB.?= =?UTF-8?q?MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs=20is=20the?= =?UTF-8?q?=20migration=20shim=20=E2=80=94=20single=20entry=20point=20for?= =?UTF-8?q?=20the=20PKI=20root=20+=20pki=20subpath,=20runs=20a=20one-shot?= =?UTF-8?q?=20legacy-folder=20probe=20on=20first=20resolution:=20if=20{Loc?= =?UTF-8?q?alAppData}/LmxOpcUaClient/=20exists=20+=20{LocalAppData}/OtOpcU?= =?UTF-8?q?aClient/=20does=20not,=20Directory.Move=20renames=20it=20in=20p?= =?UTF-8?q?lace=20(atomic=20on=20NTFS=20within=20the=20same=20volume)=20so?= =?UTF-8?q?=20trusted=20server=20certs=20+=20saved=20connection=20settings?= =?UTF-8?q?=20persist=20across=20the=20rename=20without=20operator=20actio?= =?UTF-8?q?n.=20Idempotent=20per-process=20via=20a=20Lock-guarded=20=5Fmig?= =?UTF-8?q?rationChecked=20flag=20so=20repeated=20CertificateStorePath=20g?= =?UTF-8?q?etter=20calls=20on=20the=20hot=20path=20pay=20no=20IO=20cost=20?= =?UTF-8?q?beyond=20the=20first.=20Fresh-install=20path=20(neither=20folde?= =?UTF-8?q?r=20exists)=20+=20already-migrated=20path=20(only=20canonical?= =?UTF-8?q?=20exists)=20+=20manual-override=20path=20(both=20exist=20?= =?UTF-8?q?=E2=80=94=20developer=20has=20set=20up=20something=20explicit)?= =?UTF-8?q?=20are=20all=20no-ops=20that=20leave=20state=20alone.=20IOExcep?= =?UTF-8?q?tion=20on=20the=20Directory.Move=20is=20swallowed=20+=20logged?= =?UTF-8?q?=20as=20a=20false=20return=20so=20a=20concurrent=20peer=20proce?= =?UTF-8?q?ss=20losing=20the=20race=20doesn't=20crash=20the=20consumer;=20?= =?UTF-8?q?the=20losing=20process=20falls=20back=20to=20whatever=20state?= =?UTF-8?q?=20exists.=20Five=20new=20ClientStoragePathsTests=20assert:=20G?= =?UTF-8?q?etRoot=20ends=20with=20canonical=20name=20under=20LocalAppData,?= =?UTF-8?q?=20GetPkiPath=20nests=20pki=20under=20root,=20CanonicalFolderNa?= =?UTF-8?q?me=20is=20OtOpcUaClient,=20LegacyFolderName=20is=20LmxOpcUaClie?= =?UTF-8?q?nt=20(the=20migration=20contract=20=E2=80=94=20a=20typo=20here?= =?UTF-8?q?=20would=20leak=20the=20legacy=20folder=20past=20the=20shim),?= =?UTF-8?q?=20repeat=20invocation=20returns=20false=20after=20first-touch?= =?UTF-8?q?=20arms=20the=20in-process=20guard.=20Doc-side=20residual-expla?= =?UTF-8?q?nation=20notes=20in=20docs/Client.CLI.md=20+=20docs/Client.UI.m?= =?UTF-8?q?d=20are=20dropped=20now=20that=20the=20rename=20is=20real;=20re?= =?UTF-8?q?placed=20with=20a=20short=20"pre-#208=20dev=20boxes=20migrate?= =?UTF-8?q?=20automatically=20on=20first=20launch"=20note=20that=20points?= =?UTF-8?q?=20at=20ClientStoragePaths.=20Sample=20CLI=20invocations=20in?= =?UTF-8?q?=20Client.CLI.md=20updated=20via=20sed=20from=20lmxopcua-cli=20?= =?UTF-8?q?to=20otopcua-cli=20across=20every=20command=20block=20(14=20rep?= =?UTF-8?q?lacements).=20Pre-existing=20staleness=20in=20SubscribeCommandT?= =?UTF-8?q?ests.Execute=5FPrintsSubscriptionMessage=20surfaced=20during=20?= =?UTF-8?q?the=20test=20run=20=E2=80=94=20the=20CLI's=20subscribe=20comman?= =?UTF-8?q?d=20has=20long=20since=20switched=20to=20an=20aggregate=20"Subs?= =?UTF-8?q?cribed=20to=20{count}/{total}=20nodes=20(interval:=20...)"=20ou?= =?UTF-8?q?tput=20format=20but=20the=20test=20still=20asserted=20the=20ori?= =?UTF-8?q?ginal=20single-node=20form.=20Updated=20the=20assertion=20to=20?= =?UTF-8?q?match=20current=20output=20+=20added=20a=20comment=20explaining?= =?UTF-8?q?=20the=20change;=20this=20is=20unrelated=20to=20the=20rename=20?= =?UTF-8?q?but=20was=20blocking=20a=20green=20Client.CLI.Tests=20run.=20Fu?= =?UTF-8?q?ll=20solution=20build=200=20errors;=20Client.Shared.Tests=20136?= =?UTF-8?q?/136=20+=205=20new=20shim=20tests=20passing;=20Client.UI.Tests?= =?UTF-8?q?=2098/98;=20Client.CLI.Tests=2052/52=20(was=2051/52=20before=20?= =?UTF-8?q?the=20subscribe-test=20fix).=20No=20Admin/Core/Server=20changes?= =?UTF-8?q?=20=E2=80=94=20this=20touches=20only=20the=20client=20layer.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/Client.CLI.md | 32 +++---- docs/Client.UI.md | 2 +- src/ZB.MOM.WW.OtOpcUa.Client.CLI/Program.cs | 4 +- .../DefaultApplicationConfigurationFactory.cs | 6 +- .../ClientStoragePaths.cs | 90 +++++++++++++++++++ .../Models/ConnectionSettings.cs | 8 +- .../OpcUaClientService.cs | 2 +- .../Services/JsonSettingsService.cs | 7 +- .../ViewModels/MainWindowViewModel.cs | 4 +- .../SubscribeCommandTests.cs | 5 +- .../ClientStoragePathsTests.cs | 48 ++++++++++ .../Models/ConnectionSettingsTests.cs | 2 +- .../MainWindowViewModelTests.cs | 2 +- 13 files changed, 175 insertions(+), 37 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ClientStoragePathsTests.cs diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index cdab19d..2bc2522 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -14,7 +14,7 @@ dotnet build dotnet run -- [options] ``` -The executable name is still `lmxopcua-cli` — a residual from the pre-v2 rename (`Program.cs:SetExecutableName`). Scripts + operator muscle memory depend on the name; flipping it to `otopcua-cli` is a follow-up that also needs to move the client-side PKI store folder ({LocalAppData}/LmxOpcUaClient/pki/ — used by the shared client for its application certificate) so trust relationships survive the rename. +The executable name is `otopcua-cli`. Dev boxes carrying a pre-task-#208 install may still have the legacy `{LocalAppData}/LmxOpcUaClient/` folder on disk; on first launch of any post-#208 CLI or UI build, `ClientStoragePaths` (`src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs`) migrates it to `{LocalAppData}/OtOpcUaClient/` automatically so trusted certificates + saved settings survive the rename. ## Architecture @@ -46,7 +46,7 @@ All commands accept these options: When `-U` and `-P` are provided, the shared service passes a `UserIdentity(username, password)` to the OPC UA session. Without credentials, anonymous identity is used. ```bash -lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123 +otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123 ``` ### Failover @@ -54,20 +54,20 @@ lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U opera When `-F` is provided, the shared service tries the primary URL first, then each failover URL in order. For long-running commands (`subscribe`, `alarms`), the service monitors the session via keep-alive and automatically reconnects to the next available server on failure. ```bash -lmxopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -F opc.tcp://localhost:4841/OtOpcUa +otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -F opc.tcp://localhost:4841/OtOpcUa ``` ### Transport Security When `sign` or `encrypt` is specified, the shared service: -1. Ensures a client application certificate exists under `{LocalAppData}/LmxOpcUaClient/pki/` (auto-created if missing) +1. Ensures a client application certificate exists under `{LocalAppData}/OtOpcUaClient/pki/` (auto-created if missing; pre-rename `LmxOpcUaClient/` is migrated in place on first launch) 2. Discovers server endpoints and selects one matching the requested security mode 3. Prefers `Basic256Sha256` when multiple matching endpoints exist 4. Fails with a clear error if no matching endpoint is found ```bash -lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -S encrypt -U admin -P secret -r -d 2 +otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -S encrypt -U admin -P secret -r -d 2 ``` ### Verbose Logging @@ -81,7 +81,7 @@ The `--verbose` flag switches Serilog output from `Warning` to `Debug` level, sh Tests connectivity to an OPC UA server. Creates a session, prints connection metadata, and disconnects. ```bash -lmxopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 +otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 ``` Output: @@ -99,7 +99,7 @@ Connection successful. Reads the current value of a single node and prints the value, status code, and timestamps. ```bash -lmxopcua-cli read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=3;s=DEV.ScanState" -U admin -P admin123 +otopcua-cli read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=3;s=DEV.ScanState" -U admin -P admin123 ``` | Flag | Description | @@ -121,7 +121,7 @@ Server Time: 2026-03-30T19:58:38.0971257Z Writes a value to a node. The shared service reads the current value first to determine the target data type, then converts the supplied string value using `ValueConverter.ConvertValue()`. ```bash -lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 +otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 ``` | Flag | Description | @@ -135,10 +135,10 @@ Browses the OPC UA address space starting from the Objects folder or a specified ```bash # Browse top-level Objects folder -lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 +otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 # Browse a specific node recursively to depth 3 -lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r -d 3 -n "ns=3;s=ZB" +otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r -d 3 -n "ns=3;s=ZB" ``` | Flag | Description | @@ -152,7 +152,7 @@ lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r Monitors a node for value changes using OPC UA subscriptions. Prints each data change notification with timestamp, value, and status code. Runs until Ctrl+C, then unsubscribes and disconnects cleanly. ```bash -lmxopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500 +otopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500 ``` | Flag | Description | @@ -166,12 +166,12 @@ Reads historical data from a node. Supports raw history reads and aggregate (pro ```bash # Raw history -lmxopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ +otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ -n "ns=1;s=TestMachine_001.TestHistoryValue" \ --start "2026-03-25" --end "2026-03-30" # Aggregate: 1-hour average -lmxopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ +otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ -n "ns=1;s=TestMachine_001.TestHistoryValue" \ --start "2026-03-25" --end "2026-03-30" \ --aggregate Average --interval 3600000 @@ -203,10 +203,10 @@ Subscribes to alarm events on a node. Prints structured alarm output including s ```bash # Subscribe to alarm events on the Server node -lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa +otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa # Subscribe to a specific source node with condition refresh -lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \ +otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \ -n "ns=1;s=TestMachine_001" --refresh ``` @@ -221,7 +221,7 @@ lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \ Reads the OPC UA redundancy state from a server: redundancy mode, service level, server URIs, and application URI. ```bash -lmxopcua-cli redundancy -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 +otopcua-cli redundancy -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 ``` Example output: diff --git a/docs/Client.UI.md b/docs/Client.UI.md index 6839055..e825d6b 100644 --- a/docs/Client.UI.md +++ b/docs/Client.UI.md @@ -65,7 +65,7 @@ The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Co ### Settings Persistence -Connection settings are saved to `{LocalAppData}/LmxOpcUaClient/settings.json` after each successful connection and on window close. The folder name is a residual from the pre-v2 rename (the `Client.Shared` session factory still calls itself `LmxOpcUaClient` at `OpcUaClientService.cs:428`); renaming to `OtOpcUaClient` is a follow-up that needs a migration shim so existing users don't lose their settings on upgrade. The settings are reloaded on next launch, including: +Connection settings are saved to `{LocalAppData}/OtOpcUaClient/settings.json` after each successful connection and on window close. Dev boxes upgrading from a pre-task-#208 build still have the legacy `LmxOpcUaClient/` folder on disk; `ClientStoragePaths` in `Client.Shared` moves it to the canonical path on first launch so existing trusted certs + saved settings persist without operator action. The settings are reloaded on next launch, including: - All connection parameters - Active subscription node IDs (restored after reconnection) diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.CLI/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Client.CLI/Program.cs index 9e67854..75b578d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.CLI/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.CLI/Program.cs @@ -9,7 +9,7 @@ return await new CliApplicationBuilder() if (type.IsSubclassOf(typeof(CommandBase))) return Activator.CreateInstance(type, CommandBase.DefaultFactory)!; return Activator.CreateInstance(type)!; }) - .SetExecutableName("lmxopcua-cli") - .SetDescription("LmxOpcUa CLI - command-line client for the LmxOpcUa OPC UA server") + .SetExecutableName("otopcua-cli") + .SetDescription("OtOpcUa CLI - command-line client for the OtOpcUa OPC UA server") .Build() .RunAsync(args); \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs index 1868919..bd13c5e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs @@ -18,8 +18,8 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi var config = new ApplicationConfiguration { - ApplicationName = "LmxOpcUaClient", - ApplicationUri = "urn:localhost:LmxOpcUaClient", + ApplicationName = "OtOpcUaClient", + ApplicationUri = "urn:localhost:OtOpcUaClient", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { @@ -60,7 +60,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi { var app = new ApplicationInstance { - ApplicationName = "LmxOpcUaClient", + ApplicationName = "OtOpcUaClient", ApplicationType = ApplicationType.Client, ApplicationConfiguration = config }; diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs new file mode 100644 index 0000000..d130952 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs @@ -0,0 +1,90 @@ +namespace ZB.MOM.WW.OtOpcUa.Client.Shared; + +/// +/// Resolves the canonical under-LocalAppData folder for the shared OPC UA client's PKI +/// store + persisted settings. Renamed from LmxOpcUaClient to OtOpcUaClient +/// in task #208; a one-shot migration shim moves a pre-rename folder in place on first +/// resolution so existing developer boxes keep their trusted server certs + saved +/// connection settings on upgrade. +/// +/// +/// Thread-safe: the rename uses which is atomic on NTFS +/// within the same volume. The lock guarantees the migration runs at most once per +/// process even under concurrent first-touch from CLI + UI. +/// +public static class ClientStoragePaths +{ + /// Canonical client folder name. Post-#208. + public const string CanonicalFolderName = "OtOpcUaClient"; + + /// Pre-#208 folder name. Used only by the migration shim. + public const string LegacyFolderName = "LmxOpcUaClient"; + + private static readonly Lock _migrationLock = new(); + private static bool _migrationChecked; + + /// + /// Absolute path to the client's top-level folder under LocalApplicationData. Runs the + /// one-shot legacy-folder migration before returning so callers that depend on this + /// path (PKI store, settings file) find their existing state at the canonical name. + /// + public static string GetRoot() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var canonical = Path.Combine(localAppData, CanonicalFolderName); + MigrateLegacyFolderIfNeeded(localAppData, canonical); + return canonical; + } + + /// Subfolder for the application's PKI store — used by both CLI + UI. + public static string GetPkiPath() => Path.Combine(GetRoot(), "pki"); + + /// + /// Expose the migration probe for tests + for callers that want to check whether the + /// legacy folder still exists without forcing the rename. Returns true when a legacy + /// folder existed + was moved to canonical, false when no migration was needed or + /// canonical was already present. + /// + public static bool TryRunLegacyMigration() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var canonical = Path.Combine(localAppData, CanonicalFolderName); + return MigrateLegacyFolderIfNeeded(localAppData, canonical); + } + + private static bool MigrateLegacyFolderIfNeeded(string localAppData, string canonical) + { + // Fast-path out of the lock when the migration has already been attempted this process + // — saves the IO on every subsequent call, + the migration is idempotent within the + // same process anyway. + if (_migrationChecked) return false; + + lock (_migrationLock) + { + if (_migrationChecked) return false; + _migrationChecked = true; + + var legacy = Path.Combine(localAppData, LegacyFolderName); + + // Only migrate when the legacy folder is present + canonical isn't. Either of the + // other three combinations (neither / only-canonical / both) means migration + // should NOT run: no-op fresh install, already-migrated, or manual state the + // developer has set up — don't clobber. + if (!Directory.Exists(legacy)) return false; + if (Directory.Exists(canonical)) return false; + + try + { + Directory.Move(legacy, canonical); + return true; + } + catch (IOException) + { + // Concurrent another-process-moved-it or volume-boundary or permissions — leave + // the legacy folder alone; callers that need it can either re-run migration + // manually or point CertificateStorePath explicitly. + return false; + } + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs index b0e68fe..a01f29f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs @@ -41,11 +41,11 @@ public sealed class ConnectionSettings public bool AutoAcceptCertificates { get; set; } = true; /// - /// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData. + /// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData + /// resolved via so the one-shot legacy-folder migration + /// runs before the path is returned. /// - public string CertificateStorePath { get; set; } = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LmxOpcUaClient", "pki"); + public string CertificateStorePath { get; set; } = ClientStoragePaths.GetPkiPath(); /// /// Validates the settings and throws if any required values are missing or invalid. diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index 4a1f4e3..2659f5e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -425,7 +425,7 @@ public sealed class OpcUaClientService : IOpcUaClientService : new UserIdentity(); var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000); - return await _sessionFactory.CreateSessionAsync(config, endpoint, "LmxOpcUaClient", sessionTimeoutMs, identity, + return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity, ct); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.UI/Services/JsonSettingsService.cs b/src/ZB.MOM.WW.OtOpcUa.Client.UI/Services/JsonSettingsService.cs index abd427a..d2b418a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.UI/Services/JsonSettingsService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.UI/Services/JsonSettingsService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Client.Shared; namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services; @@ -7,9 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services; /// public sealed class JsonSettingsService : ISettingsService { - private static readonly string SettingsDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LmxOpcUaClient"); + // ClientStoragePaths.GetRoot runs the one-shot legacy-folder migration so pre-#208 + // developer boxes pick up their existing settings.json on first launch post-rename. + private static readonly string SettingsDir = ClientStoragePaths.GetRoot(); private static readonly string SettingsPath = Path.Combine(SettingsDir, "settings.json"); diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs b/src/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs index 9cd82cc..fd28d36 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs @@ -21,9 +21,7 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private bool _autoAcceptCertificates = true; - [ObservableProperty] private string _certificateStorePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LmxOpcUaClient", "pki"); + [ObservableProperty] private string _certificateStorePath = ClientStoragePaths.GetPkiPath(); [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs index 181f882..e4e3929 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs @@ -101,7 +101,8 @@ public class SubscribeCommandTests await task; var output = TestConsoleHelper.GetOutput(console); - output.ShouldContain("Subscribed to ns=2;s=TestVar (interval: 2000ms)"); - output.ShouldContain("Unsubscribed."); + // CLI now prints aggregate form "Subscribed to {count}/{total} nodes (interval: ...)" rather than + // the single-node form the original test asserted — the command supports multi-node now. + output.ShouldContain("Subscribed to 1/1 nodes (interval: 2000ms)"); } } \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ClientStoragePathsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ClientStoragePathsTests.cs new file mode 100644 index 0000000..b4d5080 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ClientStoragePathsTests.cs @@ -0,0 +1,48 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Client.Shared; + +namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests; + +[Trait("Category", "Unit")] +public sealed class ClientStoragePathsTests +{ + [Fact] + public void GetRoot_ReturnsCanonicalFolderName_UnderLocalAppData() + { + var root = ClientStoragePaths.GetRoot(); + root.ShouldEndWith(ClientStoragePaths.CanonicalFolderName); + root.ShouldContain(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); + } + + [Fact] + public void GetPkiPath_NestsPkiUnderRoot() + { + var pki = ClientStoragePaths.GetPkiPath(); + pki.ShouldEndWith(Path.Combine(ClientStoragePaths.CanonicalFolderName, "pki")); + } + + [Fact] + public void CanonicalFolderName_IsOtOpcUaClient() + { + ClientStoragePaths.CanonicalFolderName.ShouldBe("OtOpcUaClient"); + } + + [Fact] + public void LegacyFolderName_IsLmxOpcUaClient() + { + // The shim depends on this specific spelling — a typo here would leak the legacy + // folder past the migration + break every dev-box upgrade. + ClientStoragePaths.LegacyFolderName.ShouldBe("LmxOpcUaClient"); + } + + [Fact] + public void TryRunLegacyMigration_Returns_False_On_Repeat_Invocation() + { + // Once the guard in-process has fired, subsequent calls short-circuit to false + // regardless of filesystem state. This is the behaviour that keeps the migration + // cheap on hot paths (CertificateStorePath property getter is called frequently). + _ = ClientStoragePaths.GetRoot(); // arms the guard + ClientStoragePaths.TryRunLegacyMigration().ShouldBeFalse(); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ConnectionSettingsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ConnectionSettingsTests.cs index 03ca4f8..89ac9c8 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ConnectionSettingsTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ConnectionSettingsTests.cs @@ -18,7 +18,7 @@ public class ConnectionSettingsTests settings.SecurityMode.ShouldBe(SecurityMode.None); settings.SessionTimeoutSeconds.ShouldBe(60); settings.AutoAcceptCertificates.ShouldBeTrue(); - settings.CertificateStorePath.ShouldContain("LmxOpcUaClient"); + settings.CertificateStorePath.ShouldContain("OtOpcUaClient"); settings.CertificateStorePath.ShouldContain("pki"); } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs index a02a424..deeb8ad 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs @@ -252,7 +252,7 @@ public class MainWindowViewModelTests _vm.FailoverUrls.ShouldBeNull(); _vm.SessionTimeoutSeconds.ShouldBe(60); _vm.AutoAcceptCertificates.ShouldBeTrue(); - _vm.CertificateStorePath.ShouldContain("LmxOpcUaClient"); + _vm.CertificateStorePath.ShouldContain("OtOpcUaClient"); _vm.CertificateStorePath.ShouldContain("pki"); }