@aryacode/dsh-engineering 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/cordis.patch.yml +3 -2
  3. package/package.json +3 -2
  4. package/vendor/optix/bridge/LICENSE +35 -0
  5. package/vendor/optix/bridge/StudioMCPBridge.cs +7022 -0
  6. package/vendor/optix/bridge/source.json +10 -0
  7. package/vendor/optix/icon.svg +13 -0
  8. package/vendor/optix/lib/index.js +1531 -0
  9. package/vendor/optix/lib/types/bridge.d.ts +77 -0
  10. package/vendor/optix/lib/types/index.d.ts +52 -0
  11. package/vendor/optix/lib/types/ocr.d.ts +46 -0
  12. package/vendor/optix/lib/types/operations.d.ts +42 -0
  13. package/vendor/optix/lib/types/preview.d.ts +33 -0
  14. package/vendor/optix/lib/types/templates.d.ts +18 -0
  15. package/vendor/optix/locale/en.json +6 -0
  16. package/vendor/optix/locale/zh.json +6 -0
  17. package/vendor/optix/templates/arya-optix-ipc/manifest.json +260 -0
  18. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix +81 -0
  19. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix.design +38 -0
  20. package/vendor/optix/templates/arya-optix-ipc/project/IDEVersion.txt +1 -0
  21. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Alarms/Alarms.yaml +3 -0
  22. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/AryaNewHMIProject_IPC.yaml +67 -0
  23. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/CommDrivers/CommDrivers.yaml +2 -0
  24. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Converters/Converters.yaml +2 -0
  25. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/DataStores/DataStores.yaml +2 -0
  26. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Loggers/Loggers.yaml +2 -0
  27. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/MQTT/MQTT.yaml +2 -0
  28. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Model/Model.yaml +2 -0
  29. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/NetLogic/NetLogic.yaml +28 -0
  30. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/OPC-UA/OPC-UA.yaml +2 -0
  31. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Recipes/Recipes.yaml +2 -0
  32. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Reports/Reports.yaml +2 -0
  33. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Retentivity/Retentivity.yaml +57 -0
  34. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Groups/Groups.yaml +3 -0
  35. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Roles/Roles.yaml +3 -0
  36. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Security.yaml +7 -0
  37. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Users/Users.yaml +3 -0
  38. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/System/System.yaml +2 -0
  39. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Translations/Translations.yaml +16 -0
  40. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -0
  41. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Screens/Screens.yaml +2 -0
  42. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/UI.yaml +306 -0
  43. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/.vscode/launch.json +19 -0
  44. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.code-workspace +10 -0
  45. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.csproj +19 -0
  46. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.references +172 -0
  47. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.sln +21 -0
  48. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +20 -0
  49. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +11 -0
  50. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4555 -0
  51. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/UserDefinedModule.xml +26 -0
  52. package/vendor/optix/templates/arya-optix-panel/manifest.json +411 -0
  53. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix +97 -0
  54. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix.design +38 -0
  55. package/vendor/optix/templates/arya-optix-panel/project/IDEVersion.txt +1 -0
  56. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Alarms/Alarms.yaml +3 -0
  57. package/vendor/optix/templates/arya-optix-panel/project/Nodes/AryaNewHMIProject_OptixPanel.yaml +67 -0
  58. package/vendor/optix/templates/arya-optix-panel/project/Nodes/CommDrivers/CommDrivers.yaml +2 -0
  59. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Converters/Converters.yaml +2 -0
  60. package/vendor/optix/templates/arya-optix-panel/project/Nodes/DataStores/DataStores.yaml +2 -0
  61. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Loggers/Loggers.yaml +2 -0
  62. package/vendor/optix/templates/arya-optix-panel/project/Nodes/MQTT/MQTT.yaml +2 -0
  63. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Model/Model.yaml +2 -0
  64. package/vendor/optix/templates/arya-optix-panel/project/Nodes/NetLogic/NetLogic.yaml +28 -0
  65. package/vendor/optix/templates/arya-optix-panel/project/Nodes/OPC-UA/OPC-UA.yaml +2 -0
  66. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Recipes/Recipes.yaml +2 -0
  67. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Reports/Reports.yaml +2 -0
  68. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Retentivity/Retentivity.yaml +57 -0
  69. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Groups/Groups.yaml +3 -0
  70. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Roles/Roles.yaml +3 -0
  71. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Security.yaml +7 -0
  72. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Users/Users.yaml +3 -0
  73. package/vendor/optix/templates/arya-optix-panel/project/Nodes/System/System.yaml +444 -0
  74. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Translations/Translations.yaml +81 -0
  75. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -0
  76. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Screens/Screens.yaml +2 -0
  77. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidget.yaml +163 -0
  78. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidgetComponents/DeviceSettingsWidgetComponents.yaml +1663 -0
  79. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FTRemoteAccessWidgetComponents/FTRemoteAccessWidgetComponents.yaml +2471 -0
  80. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FactoryTalkRemoteAccessWidget.yaml +218 -0
  81. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidget.yaml +524 -0
  82. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidgetComponents/NetworkSettingsWidgetComponents.yaml +690 -0
  83. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/SettingsWidgets.yaml +1265 -0
  84. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/UI.yaml +308 -0
  85. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/.vscode/launch.json +19 -0
  86. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ApplicationNameLogic.cs +19 -0
  87. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.code-workspace +10 -0
  88. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.csproj +19 -0
  89. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.references +172 -0
  90. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.sln +21 -0
  91. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ConnectionSwitchLogic.cs +74 -0
  92. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DateAndTimeWidgetLogic.cs +420 -0
  93. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DeviceSettingsWidgetLogic.cs +34 -0
  94. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTOptixStudioVersionLogic.cs +47 -0
  95. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTRemoteAccessWidgetSetupLogic.cs +153 -0
  96. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionApprovalLogic.cs +118 -0
  97. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionReceiverLogic.cs +166 -0
  98. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkConfiguratorEditModelsLogic.cs +56 -0
  99. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfaceLogic.cs +138 -0
  100. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelReaderLogic.cs +87 -0
  101. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelToSystemWriterLogic.cs +139 -0
  102. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkSettingsLogic.cs +168 -0
  103. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/FTRemoteAccessWidgetDataObject.cs +106 -0
  104. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +37 -0
  105. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +91 -0
  106. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/RequestAssistanceSwitchLogic.cs +93 -0
  107. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4555 -0
  108. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/SystemToNetworkInterfacesEditModelWriterLogic.cs +77 -0
  109. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/UserDefinedModule.xml +111 -0
  110. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/WarningIcon.svg +4 -0
@@ -0,0 +1,4555 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.Linq;
4
+ using System.Net;
5
+ using System.Net.Sockets;
6
+ using System.Reflection;
7
+ using System.Text;
8
+ using System.Threading;
9
+ using UAManagedCore;
10
+ using OpcUa = UAManagedCore.OpcUa;
11
+ using FTOptix.HMIProject;
12
+ using FTOptix.NetLogic;
13
+ using FTOptix.CoreBase;
14
+
15
+ // StudioMCPBridge - a design-time NetLogic that hosts a loopback HTTP bridge into
16
+ // the live Optix model (read + author) for the ftx-mcp service.
17
+ //
18
+ // How it runs: [ExportMethod] StartBridge (right-click) spawns a background
19
+ // TcpListener on loopback (avoids the http.sys URL-ACL/admin wall for a
20
+ // non-elevated Studio); Project.Current resolves at design time. StopBridge signals
21
+ // the loop via a NAMED KERNEL EVENT, because Studio isolates each ExportMethod in
22
+ // its own AssemblyLoadContext - so shared managed state never reaches the listener
23
+ // (see StopListener / Loop).
24
+ //
25
+ // The NetLogic CLASS NAME must equal its node name in Optix. Shipped as the
26
+ // "MCPBridge" Optix library (component "StudioMCPBridge") for drag-in reuse.
27
+ public class StudioMCPBridge : BaseNetLogic
28
+ {
29
+ // kept in sync with the ftx-mcp service version (pyproject.toml /
30
+ // service/__init__.py / server.json) rather than left to lag behind it. Bump
31
+ // this alongside those whenever a change to THIS FILE actually alters bridge
32
+ // behavior; leave it alone for service/dashboard-only releases that never
33
+ // touch StudioMCPBridge.cs, since those genuinely don't change what the
34
+ // bridge does (a mismatch there is cosmetic - the string is just stale, not
35
+ // wrong about behavior). Last bumped for the StopBridge port-sweep + bind
36
+ // retry fix, which IS a real behavior change to this file.
37
+ private const string BridgeVersion = "1.0.7";
38
+ // multi-instance support (v1.0.7). Port is no longer a single
39
+ // fixed const - each Studio instance self-assigns the first free port in
40
+ // BasePort..BasePort+PortRangeSize-1, so up to PortRangeSize projects can
41
+ // run this NetLogic (StartBridge'd) AT THE SAME TIME, each on its own port,
42
+ // instead of exclusively fighting over :8768. The Python service discovers
43
+ // which project lives on which port by probing the whole range (see
44
+ // service/core.py's bridge registry). PortRangeSize=4 covers 4 simultaneous
45
+ // Studio instances; raise it if you routinely run more.
46
+ private const int BasePort = 8768;
47
+ private const int PortRangeSize = 4; // ports 8768..8771
48
+
49
+ // Cross-ALC stop signal: at design time Studio runs each [ExportMethod] in an
50
+ // ISOLATED AssemblyLoadContext, so StartBridge and StopBridge share NO managed
51
+ // state (neither instance NOR static - both were tried and failed).
52
+ // A named kernel event IS shared across ALCs in the process; StopBridge sets it and
53
+ // the accept loop (in whichever ALC owns the listener) polls it and closes the socket.
54
+ //
55
+ // the event name USED TO be a single compile-time constant
56
+ // (".._p8768"), which worked precisely because it needed no runtime state -
57
+ // every ALC agreed on the same literal string. Now that the port is chosen
58
+ // at runtime (per instance), the name has to be too - but StopBridge runs in
59
+ // a FRESH ALC that never ran StartListener, so it cannot read a static field
60
+ // to learn which port THIS Studio process bound. Environment.SetEnvironmentVariable
61
+ // (default target = Process) is the one piece of state that DOES survive an
62
+ // ALC reload within the same process without being visible to *other*
63
+ // Studio.exe processes - so it's what carries the bound port from
64
+ // StartListener to a later StopBridge, keeping StopBridge scoped to only
65
+ // this instance's own bridge, never a sibling Studio's.
66
+ //
67
+ // this USED TO hold a single port number (whatever
68
+ // StartListener bound most recently), which meant StopBridge could only ever
69
+ // recover ONE listener. That broke down for a very ordinary sequence: edit
70
+ // this NetLogic's code (or just fire StartBridge again quickly after a
71
+ // StopBridge, before the old listener thread noticed the stop signal and
72
+ // actually closed its socket - see Loop's ~50ms poll) - the new StartBridge
73
+ // lands on a DIFFERENT port and overwrites this value, and the OLD port
74
+ // becomes permanently un-signallable: nothing remembers it exists anymore,
75
+ // even though the underlying per-port named stop-event (OpenStopEvent) would
76
+ // happily have stopped it if only something had asked. Confirmed live: an
77
+ // edited+rebuilt copy of this NetLogic left the PREVIOUS compiled listener
78
+ // thread serving the same project on its old port indefinitely, invisible to
79
+ // StopBridge, fixable only by closing Studio outright.
80
+ //
81
+ // Now holds a COMMA-SEPARATED LIST of every port this Studio PROCESS has
82
+ // bound and not yet confirmed torn down - appended to by StartListener,
83
+ // with each port removing ONLY ITSELF (never the whole list) once its own
84
+ // Loop() thread actually exits (see Loop's finally). StopBridge sweeps and
85
+ // signals every port in the list, not just one - see ReadBoundPorts /
86
+ // AddBoundPort / RemoveBoundPort below, and StopListener.
87
+ private const string BoundPortEnvVar = "FTX_STUDIOBRIDGE_BOUND_PORT";
88
+ // Named kernel mutex guarding the list's read-modify-write cycle (parse,
89
+ // mutate, re-serialize) against concurrent AddBoundPort/RemoveBoundPort
90
+ // calls from OTHER ALCs' threads - a plain C# lock only protects callers
91
+ // within the SAME ALC (statics, including lock objects, don't cross ALC
92
+ // boundaries; see the class-level comment on _listener), so this needs the
93
+ // same named-kernel-object trick OpenStopEvent already relies on.
94
+ private const string PortListMutexName = "Local\\StudioMCPBridge_PortList_Mutex";
95
+ private static string ListenerLeaseName => "Local\\StudioMCPBridge_Listener_" +
96
+ System.Diagnostics.Process.GetCurrentProcess().Id;
97
+
98
+ private static string StopEventNameFor(int port) => "Local\\StudioMCPBridge_Stop_p" + port;
99
+
100
+ private const int MaxItems = 500;
101
+ // unlike the bridge's own TCP port (self-assigned per instance,
102
+ // see BasePort/PortRangeSize above), the Web presentation engine port is a
103
+ // PROJECT setting, persisted into the project's own model by SetupProject -
104
+ // it does NOT auto-negotiate at bridge-start time. Every project SetupProject
105
+ // has ever been run against defaults to this SAME port, so running more than
106
+ // one project's EMULATOR at once (not just bridging them) needs each project
107
+ // reconfigured to a distinct port first, e.g. by pairing it with the bridge
108
+ // port: 8768<->8081 (this default), 8769<->8082, 8770<->8083, 8771<->8084.
109
+ // Pass an explicit port to SetupProject/EnsureWebEngineCore (or POST
110
+ // /bridge/setup/web-engine?port=N) to assign one of those to a project.
111
+ private const int WebEnginePort = 8081; // default Web presentation engine port
112
+ private const int WebEngineMaxConnections = 5; // Studio's default; 0/absent = no serve
113
+ // Studio's default AllowedLocalSources allow-list; a fresh MakeObject leaves it
114
+ // empty, and the runtime then blocks images/fonts/css/js from serving.
115
+ private static readonly string[] WebEngineAllowedSources = {
116
+ "*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp", "*.svg", "*.avi", "*.mov",
117
+ "*.mkv", "*.mpg", "*.mp4", "*.wmv", "*.pdf", "*.ttf", "*.otf", "*.html",
118
+ "*.css", "*.js", "*.mjs" };
119
+
120
+ // Static so the loop and same-ALC callers share one listener reference. NOTE: this
121
+ // does NOT fix cross-ALC StopBridge (instance AND static were tried, 0.9.3/0.9.4,
122
+ // and neither crosses the ALC) - the named event above is what does. Cross-assembly
123
+ // reload orphans still require the Studio closed.
124
+ private static TcpListener _listener;
125
+ private static volatile bool _running;
126
+ // the port THIS running listener bound to (or -1 if none is
127
+ // running). Valid within the ALC/thread epoch that started it; StopBridge
128
+ // (a fresh ALC) uses BoundPortEnvVar instead, not this field, to learn it.
129
+ private static volatile int _boundPort = -1;
130
+
131
+ // Opportunistic probe: design-time Start() is NOT expected to auto-fire.
132
+ public override void Start()
133
+ {
134
+ StartListener("Start()");
135
+ }
136
+
137
+ public override void Stop()
138
+ {
139
+ StopListener();
140
+ }
141
+
142
+ // PRIMARY entry. Right-click this NetLogic node in Studio -> StartBridge.
143
+ [ExportMethod]
144
+ public void StartBridge()
145
+ {
146
+ StartListener("StartBridge()");
147
+ }
148
+
149
+ [ExportMethod]
150
+ public void StopBridge()
151
+ {
152
+ StopListener();
153
+ }
154
+
155
+ // Right-click this node -> Execute CheckFormula to syntax-check an
156
+ // ExpressionEvaluator formula BEFORE wiring it (Optix only validates at runtime,
157
+ // where a bad formula silently no-ops). Result goes to the Studio Output. The
158
+ // bridge's attach-expression endpoint + POST /bridge/expr/validate run the SAME
159
+ // ValidateExpressionSyntax check, so the operator and the model client agree.
160
+ // DELIBERATELY NOT named "ValidateExpression": that name collided with FTOptix's
161
+ // own expression subsystem, which invoked this ExportMethod with a mismatched arg
162
+ // count (TargetParameterCountException, observed live 2026-07-26 while editing
163
+ // converter expressions), breaking design-time expression validation. Do not rename
164
+ // back to ValidateExpression.
165
+ [ExportMethod]
166
+ public void CheckFormula(string expression, string sources)
167
+ {
168
+ int n = CountSources(sources);
169
+ var err = ValidateExpressionSyntax(expression, n);
170
+ if (err == null)
171
+ Log.Info("StudioBridge", "CheckFormula OK (" + n + " source(s)): " + expression);
172
+ else
173
+ Log.Error("StudioBridge", "CheckFormula INVALID: " + err + " [" + expression + "]");
174
+ }
175
+
176
+ // Visible setup action: right-click this NetLogic node in Studio -> SetupProject.
177
+ // Ensures a Web presentation engine exists (Port/Protocol/StartWindow/StyleSheet/
178
+ // MaxNumberOfConnections) so the deployed runtime can serve a browser canvas. Runs
179
+ // directly against Project.Current at design time - does NOT need the bridge started.
180
+ [ExportMethod]
181
+ public void SetupProject()
182
+ {
183
+ Log.Info("StudioBridge", "SetupProject: " + EnsureWebEngineCore(WebEnginePort, "0.0.0.0"));
184
+ // this instance's bridge landed on a non-default port (i.e.
185
+ // :8768 was already taken by another project's bridge), which is a
186
+ // reasonable signal you're running more than one project at once - flag
187
+ // that the just-assigned web-engine port (8081) will collide with any
188
+ // OTHER project also left at 8081 if their emulators run simultaneously,
189
+ // and name this project's paired suggestion (see the WebEnginePort
190
+ // comment above for the full 8768<->8081.. table).
191
+ if (_boundPort > BasePort)
192
+ {
193
+ int suggested = WebEnginePort + (_boundPort - BasePort);
194
+ Log.Warning("StudioBridge", "SetupProject: this project's bridge is on port " +
195
+ _boundPort + " (not the default " + BasePort + "), suggesting more than one " +
196
+ "project is in play - the web-engine port just set is still the default " +
197
+ WebEnginePort + ", which will COLLIDE with any other project also left at " +
198
+ WebEnginePort + " if both emulators run at the same time. Re-run with an " +
199
+ "explicit port (POST /bridge/setup/web-engine?port=" + suggested +
200
+ ") to give this project its own.");
201
+ }
202
+ }
203
+
204
+ // Create-or-open the process-global stop event (ManualReset) for a specific
205
+ // port. Shared across ALCs by name, so a StopBridge in one ALC can signal a
206
+ // listener loop in another - as long as both agree on the port, which is why
207
+ // StartListener/StopListener resolve it through BoundPortEnvVar rather than a
208
+ // static field (see the comment on BoundPortEnvVar above).
209
+ private static EventWaitHandle OpenStopEvent(int port)
210
+ {
211
+ return new EventWaitHandle(false, EventResetMode.ManualReset, StopEventNameFor(port));
212
+ }
213
+
214
+ // BoundPortEnvVar port-list helpers. See the
215
+ // comment on BoundPortEnvVar's declaration for why this exists (a single
216
+ // remembered port silently orphaned any PREVIOUS listener once a second
217
+ // StartBridge landed on a different port).
218
+ //
219
+ // ReadBoundPorts is an unlocked snapshot read - fine for StopListener's
220
+ // sweep, which just needs "recent enough" (worst case it misses a port
221
+ // added a moment ago and cleans it up on the next Stop). AddBoundPort /
222
+ // RemoveBoundPort do a locked read-modify-write via PortListMutexName so
223
+ // two concurrent mutations (e.g. a new StartListener adding its port at
224
+ // the same moment an old Loop() thread is removing its own) can't clobber
225
+ // each other.
226
+ private static List<int> ParsePortList(string raw)
227
+ {
228
+ var result = new List<int>();
229
+ if (string.IsNullOrEmpty(raw)) return result;
230
+ foreach (var tok in raw.Split(','))
231
+ {
232
+ int p;
233
+ if (int.TryParse(tok.Trim(), out p) && !result.Contains(p)) result.Add(p);
234
+ }
235
+ return result;
236
+ }
237
+
238
+ private static List<int> ReadBoundPorts()
239
+ {
240
+ return ParsePortList(Environment.GetEnvironmentVariable(BoundPortEnvVar));
241
+ }
242
+
243
+ private static void MutateBoundPorts(Action<List<int>> mutate)
244
+ {
245
+ using (var mtx = new Mutex(false, PortListMutexName))
246
+ {
247
+ bool owned = false;
248
+ try
249
+ {
250
+ // Bounded wait, not indefinite - a stuck/abandoned mutex must never
251
+ // hang a StartBridge/StopBridge click forever.
252
+ owned = mtx.WaitOne(TimeSpan.FromSeconds(2));
253
+ var ports = ParsePortList(Environment.GetEnvironmentVariable(BoundPortEnvVar));
254
+ mutate(ports);
255
+ Environment.SetEnvironmentVariable(BoundPortEnvVar, ports.Count == 0 ? null : string.Join(",", ports));
256
+ }
257
+ catch (AbandonedMutexException)
258
+ {
259
+ // The mutex's previous owner exited (e.g. Studio crashed) mid-update -
260
+ // we still got ownership; the list may be slightly stale but that's
261
+ // self-healing (next Add/Remove or StopBridge sweep corrects it).
262
+ var ports = ParsePortList(Environment.GetEnvironmentVariable(BoundPortEnvVar));
263
+ mutate(ports);
264
+ Environment.SetEnvironmentVariable(BoundPortEnvVar, ports.Count == 0 ? null : string.Join(",", ports));
265
+ owned = true;
266
+ }
267
+ finally
268
+ {
269
+ if (owned) { try { mtx.ReleaseMutex(); } catch { /* ignore */ } }
270
+ }
271
+ }
272
+ }
273
+
274
+ private static void AddBoundPort(int port)
275
+ {
276
+ MutateBoundPorts(ports => { if (!ports.Contains(port)) ports.Add(port); });
277
+ }
278
+
279
+ private static void RemoveBoundPort(int port)
280
+ {
281
+ MutateBoundPorts(ports => ports.Remove(port));
282
+ }
283
+
284
+ // tries each port in BasePort..BasePort+PortRangeSize-1 in turn
285
+ // and binds the first free one, instead of exclusively owning a single fixed
286
+ // port. This is what lets PortRangeSize Studio instances each run their own
287
+ // armed bridge simultaneously (no more manual StopBridge-on-one-to-free-it-
288
+ // for-another). The bound port is recorded in BoundPortEnvVar (process-scoped,
289
+ // survives the ALC reload) so a later StopBridge call - which runs in a fresh
290
+ // ALC with none of this method's static state - can recover exactly which
291
+ // port THIS Studio instance is using and signal only that one.
292
+ // binds `port` with a few short retries before giving
293
+ // up on it. Absorbs the window between a StopBridge signal and the OLD
294
+ // listener thread actually noticing it and closing its socket (Loop polls
295
+ // the stop event roughly every 50ms, longer if it's mid-HandleClient) - so
296
+ // an ordinary "StopBridge, then StartBridge again a moment later" reclaims
297
+ // ITS OWN port instead of creeping down the range because of a false "port
298
+ // in use" from a teardown that just hadn't finished yet. Only retries on
299
+ // SocketException (genuinely still bound / still tearing down); anything
300
+ // else propagates immediately, unchanged from before this fix.
301
+ private bool TryBindWithRetry(int port, out TcpListener listener, out SocketException bindError)
302
+ {
303
+ bindError = null;
304
+ for (int attempt = 0; attempt < 4; attempt++)
305
+ {
306
+ try
307
+ {
308
+ var l = new TcpListener(IPAddress.Loopback, port);
309
+ l.Start(); // exclusive bind (no SO_REUSEADDR - a clear
310
+ // "in use" beats a silent double-bind).
311
+ listener = l;
312
+ return true;
313
+ }
314
+ catch (SocketException ex)
315
+ {
316
+ bindError = ex;
317
+ if (attempt < 3) Thread.Sleep(150); // ~450ms worst case before this port's given up on
318
+ }
319
+ }
320
+ listener = null;
321
+ return false;
322
+ }
323
+
324
+ private void StartListener(string via)
325
+ {
326
+ // ExportMethods run in separate AssemblyLoadContexts, so the static
327
+ // _running flag cannot make StartBridge idempotent. A named semaphore
328
+ // scoped by Studio's process ID gives every ALC the same single-listener
329
+ // lease while still allowing separate Studio processes to bridge in
330
+ // parallel. The listener thread holds the lease until Loop exits.
331
+ Semaphore listenerLease = null;
332
+ try
333
+ {
334
+ listenerLease = new Semaphore(1, 1, ListenerLeaseName);
335
+ if (!listenerLease.WaitOne(0))
336
+ {
337
+ listenerLease.Dispose();
338
+ Log.Info("StudioBridge", "already listening for this Studio process; StartBridge is idempotent");
339
+ return;
340
+ }
341
+ }
342
+ catch (Exception ex)
343
+ {
344
+ try { listenerLease?.Dispose(); } catch { /* ignore */ }
345
+ Log.Error("StudioBridge", "failed to acquire listener lease: " + ex.Message);
346
+ return;
347
+ }
348
+ for (int port = BasePort; port < BasePort + PortRangeSize; port++)
349
+ {
350
+ // Clear any prior stop-signal for THIS port so a fresh loop on it
351
+ // doesn't exit immediately.
352
+ try { using (var ev = OpenStopEvent(port)) ev.Reset(); } catch { /* ignore */ }
353
+ TcpListener listener;
354
+ SocketException bindError;
355
+ bool bound;
356
+ try
357
+ {
358
+ bound = TryBindWithRetry(port, out listener, out bindError);
359
+ }
360
+ catch (Exception ex)
361
+ {
362
+ _running = false; _listener = null; _boundPort = -1;
363
+ try { listenerLease.Release(); listenerLease.Dispose(); } catch { /* ignore */ }
364
+ Log.Error("StudioBridge", "failed to start on port " + port + ": " + ex.Message);
365
+ return;
366
+ }
367
+ if (!bound)
368
+ {
369
+ _running = false; _listener = null; _boundPort = -1;
370
+ continue; // still taken after the retries above; try the next port in the range
371
+ }
372
+ _listener = listener;
373
+ _boundPort = port;
374
+ // ADD to the process-wide port list rather than
375
+ // overwriting it - see BoundPortEnvVar's comment. A prior port left
376
+ // bound by an orphaned/edited-and-rebuilt listener stays remembered
377
+ // here until ITS OWN Loop() thread removes it (or a StopBridge sweep
378
+ // signals it to exit and it removes itself), instead of being silently
379
+ // forgotten the moment this new port gets recorded.
380
+ AddBoundPort(port);
381
+ _running = true;
382
+ new Thread(() => Loop(port, listenerLease)) { IsBackground = true, Name = "StudioBridge" }.Start();
383
+ Log.Info("StudioBridge", "listening on http://127.0.0.1:" + port +
384
+ " (started via " + via + ")");
385
+ return;
386
+ }
387
+ try { listenerLease.Release(); listenerLease.Dispose(); } catch { /* ignore */ }
388
+ Log.Error("StudioBridge", "cannot bind any port in " + BasePort + "-" +
389
+ (BasePort + PortRangeSize - 1) + " - " + PortRangeSize +
390
+ " bridges are already running, or a stale one holds a port. StopBridge now sweeps " +
391
+ "EVERY port this process has ever bound (not just the most recent), so clicking it " +
392
+ "once more should free any orphan left by an edited-and-rebuilt NetLogic - if it " +
393
+ "still doesn't, close+reopen this Studio to drop a listener from before this fix was " +
394
+ "applied. Raise PortRangeSize in StudioMCPBridge.cs if you routinely need more than " +
395
+ PortRangeSize + " simultaneous bridges.");
396
+ }
397
+
398
+ private void StopListener() { StopListener(false); }
399
+
400
+ private void StopListener(bool quiet)
401
+ {
402
+ // Recover EVERY port THIS Studio process's bridge is (or might still be)
403
+ // bound to. Can't read _boundPort here - StopBridge runs in a fresh ALC
404
+ // where that static was never set - so read the process-env var
405
+ // StartListener/AddBoundPort maintain instead (see BoundPortEnvVar).
406
+ //
407
+ // sweep ALL of them, not just one - a NetLogic
408
+ // recompile (edit this file, rebuild, StartBridge again) leaves the
409
+ // PREVIOUS compiled listener thread alive on its own port, which used to
410
+ // be silently forgotten the moment the new port overwrote this value.
411
+ // Signalling every recorded port's named stop-event means one StopBridge
412
+ // click cleans up all of them - including that kind of orphan - without
413
+ // needing Studio closed.
414
+ var ports = ReadBoundPorts();
415
+ if (ports.Count > 0)
416
+ {
417
+ foreach (var port in ports)
418
+ {
419
+ // The cross-ALC signal (the only thing that reliably reaches each
420
+ // running loop, whichever ALC generation it belongs to).
421
+ try { using (var ev = OpenStopEvent(port)) ev.Set(); }
422
+ catch (Exception ex) { Log.Warning("StudioBridge", "stop-signal failed for port " + port + ": " + ex.Message); }
423
+ }
424
+ if (!quiet) Log.Info("StudioBridge", "stop signalled (port(s) " + string.Join(", ", ports) + " releasing)");
425
+ }
426
+ else if (!quiet)
427
+ {
428
+ Log.Info("StudioBridge", "stop signalled (no bridge port on record for this " +
429
+ "instance - was it ever started via StartBridge?)");
430
+ }
431
+ // Best-effort same-ALC teardown too (harmless when state isn't shared).
432
+ _running = false;
433
+ var l = _listener; _listener = null; _boundPort = -1;
434
+ try { l?.Stop(); } catch { /* ignore */ }
435
+ // NOTE: do NOT blank-clear BoundPortEnvVar here. Each Loop() thread removes
436
+ // ONLY ITS OWN port (see Loop's finally / RemoveBoundPort) once it actually
437
+ // exits, so the list stays accurate for any port that hasn't finished
438
+ // tearing down yet by the time this method returns, instead of being wiped
439
+ // out from under it.
440
+ }
441
+
442
+ // NOTE: main-thread marshaling via DelayedTask(0, node) was tried and REMOVED -
443
+ // at DESIGN TIME Studio does not pump the async-task queue, so the task never
444
+ // runs and a blocking Wait() HANGS the listener (confirmed live). The
445
+ // fresh-instance materialization instead uses node.GetOrCreateVariable (node-model
446
+ // ops, off-thread-safe) - see SetPropertyInline. No marshaling is needed.
447
+
448
+ // Non-blocking accept loop: poll the named stop-event AND Pending() each ~50ms so
449
+ // the loop stays responsive to a StopBridge from another ALC (a blocking
450
+ // AcceptTcpClient could only be broken by our own ALC's Stop(), which StopBridge
451
+ // can't reach). On stop, close the listener so the port is freed.
452
+ // takes `port` as a parameter (closed over by the lambda
453
+ // StartListener spawns this thread with) rather than reading the static
454
+ // _boundPort - Loop always runs within the SAME ALC/thread epoch as the
455
+ // StartListener call that spawned it, so either would work here, but the
456
+ // parameter makes that independence from ALC-surviving state explicit and
457
+ // matches OpenStopEvent's new per-port signature.
458
+ private void Loop(int port, Semaphore listenerLease)
459
+ {
460
+ EventWaitHandle stopEv = null;
461
+ try { stopEv = OpenStopEvent(port); } catch { /* poll _running only */ }
462
+ try
463
+ {
464
+ while (_running)
465
+ {
466
+ if (stopEv != null && stopEv.WaitOne(0)) break; // StopBridge signalled
467
+ var lst = _listener;
468
+ if (lst == null) break;
469
+ bool pending;
470
+ try { pending = lst.Pending(); }
471
+ catch { break; } // listener disposed
472
+ if (!pending) { Thread.Sleep(50); continue; }
473
+ TcpClient client = null;
474
+ try { client = lst.AcceptTcpClient(); HandleClient(client); }
475
+ catch (SocketException) { break; }
476
+ catch (Exception ex) { Log.Warning("StudioBridge", "request error: " + ex.Message); }
477
+ finally { try { client?.Close(); } catch { /* ignore */ } }
478
+ }
479
+ }
480
+ finally
481
+ {
482
+ _running = false;
483
+ try { _listener?.Stop(); } catch { /* ignore */ }
484
+ _listener = null;
485
+ _boundPort = -1;
486
+ // remove ONLY this port from the shared list, not
487
+ // the whole thing - a blanket clear here would erase the record of any
488
+ // OTHER port this process still has a live (or still-orphaned) listener
489
+ // on, which is exactly the bug this release fixes. See BoundPortEnvVar's
490
+ // comment and RemoveBoundPort.
491
+ try { RemoveBoundPort(port); } catch { /* ignore */ }
492
+ try { stopEv?.Dispose(); } catch { /* ignore */ }
493
+ try { listenerLease?.Release(); listenerLease?.Dispose(); } catch { /* ignore */ }
494
+ Log.Info("StudioBridge", "listener loop exited; port " + port + " released");
495
+ }
496
+ }
497
+
498
+ private void HandleClient(TcpClient client)
499
+ {
500
+ using (var stream = client.GetStream())
501
+ {
502
+ var buf = new byte[4096];
503
+ int n = stream.Read(buf, 0, buf.Length);
504
+ string req = n > 0 ? System.Text.Encoding.ASCII.GetString(buf, 0, n) : "";
505
+ string firstLine = req.Split('\n').FirstOrDefault() ?? "";
506
+
507
+ string body;
508
+ string status;
509
+ try
510
+ {
511
+ if (firstLine.StartsWith("POST /bridge/arya/library/import?"))
512
+ { body = AryaImportLibraryItem(firstLine); status = "200 OK"; }
513
+ else if (firstLine.StartsWith("GET /bridge/arya/library/result?"))
514
+ { body = AryaLibraryResult(firstLine); status = "200 OK"; }
515
+ else if (firstLine.StartsWith("POST /bridge/arya/security?"))
516
+ { body = AryaSecurity(firstLine); status = "200 OK"; }
517
+ else if (firstLine.StartsWith("GET /bridge/health"))
518
+ {
519
+ body = HealthJson();
520
+ status = "200 OK";
521
+ }
522
+ else if (firstLine.StartsWith("GET /bridge/nodes"))
523
+ {
524
+ string path = QueryParam(firstLine, "path");
525
+ if (string.IsNullOrEmpty(path))
526
+ {
527
+ body = ErrorJson("bad_query", "missing required query param: path");
528
+ status = "400 Bad Request";
529
+ }
530
+ else
531
+ {
532
+ var node = ResolveNode(path);
533
+ if (node == null)
534
+ {
535
+ body = ErrorJson("node_not_found", "no node at path: " + path);
536
+ status = "404 Not Found";
537
+ }
538
+ else
539
+ {
540
+ body = NodeJson(path, node);
541
+ status = "200 OK";
542
+ }
543
+ }
544
+ }
545
+ else if (firstLine.StartsWith("GET /bridge/map"))
546
+ {
547
+ string mPath = QueryParam(firstLine, "path");
548
+ int mDepth = 3, mMax = 800;
549
+ int.TryParse(QueryParam(firstLine, "depth") ?? "", out mDepth);
550
+ if (mDepth <= 0) mDepth = 3;
551
+ int.TryParse(QueryParam(firstLine, "max") ?? "", out mMax);
552
+ if (mMax <= 0) mMax = 800;
553
+ bool mIds = (QueryParam(firstLine, "ids") ?? "0") == "1";
554
+ string mMode = QueryParam(firstLine, "mode") ?? "detail";
555
+ string mMatch = QueryParam(firstLine, "match");
556
+ body = string.IsNullOrEmpty(mMatch)
557
+ ? ProjectMapJson(mPath, mDepth, mMax, mIds, mMode)
558
+ : MapSearchJson(mPath, mMatch, mMax);
559
+ if (body == null)
560
+ {
561
+ body = ErrorJson("node_not_found", "no node at path: " + mPath);
562
+ status = "404 Not Found";
563
+ }
564
+ else status = "200 OK";
565
+ }
566
+ else if (firstLine.StartsWith("GET /bridge/screens"))
567
+ {
568
+ body = ScreensJson();
569
+ status = "200 OK";
570
+ }
571
+ else if (firstLine.StartsWith("GET /bridge/types/ui"))
572
+ {
573
+ body = TypesUiJson();
574
+ status = "200 OK";
575
+ }
576
+ else if (firstLine.StartsWith("GET /bridge/schema/dump"))
577
+ {
578
+ body = SchemaDumpJson();
579
+ status = "200 OK";
580
+ }
581
+ else if (firstLine.StartsWith("GET /bridge/types/schema"))
582
+ {
583
+ string typeName = QueryParam(firstLine, "type");
584
+ if (string.IsNullOrEmpty(typeName))
585
+ {
586
+ body = ErrorJson("bad_query", "missing required query param: type");
587
+ status = "400 Bad Request";
588
+ }
589
+ else
590
+ {
591
+ body = TypeSchemaJson(typeName);
592
+ status = body == null ? "404 Not Found" : "200 OK";
593
+ if (body == null)
594
+ body = ErrorJson("type_not_found", "no builtin UI type: " + typeName);
595
+ }
596
+ }
597
+ else if (firstLine.StartsWith("GET /bridge/node/typeinfo"))
598
+ {
599
+ string tp = QueryParam(firstLine, "path");
600
+ if (string.IsNullOrEmpty(tp))
601
+ {
602
+ body = ErrorJson("bad_query", "missing required query param: path");
603
+ status = "400 Bad Request";
604
+ }
605
+ else { body = TypeInfoJson(tp); status = "200 OK"; }
606
+ }
607
+ else if (firstLine.StartsWith("POST /bridge/node/reorder"))
608
+ {
609
+ body = ReorderInline(firstLine);
610
+ status = "200 OK";
611
+ }
612
+ else if (firstLine.StartsWith("POST /bridge/node/attach-expression"))
613
+ {
614
+ body = AttachExpressionInline(firstLine);
615
+ status = "200 OK";
616
+ }
617
+ else if (firstLine.StartsWith("GET /bridge/node/varmembers"))
618
+ {
619
+ body = VarMembersJson(QueryParam(firstLine, "path"), QueryParam(firstLine, "name"));
620
+ status = "200 OK";
621
+ }
622
+ else if (firstLine.StartsWith("GET /bridge/diag/clrtype"))
623
+ {
624
+ body = DiagClrTypeJson(QueryParam(firstLine, "name"));
625
+ status = "200 OK";
626
+ }
627
+ else if (firstLine.StartsWith("POST /bridge/validate_ops"))
628
+ {
629
+ // U16: the ONLY handler that takes a POST BODY. Every other
630
+ // write route passes params on the query string (see
631
+ // WriteVariableInline's note) - an op LIST does not fit
632
+ // there, so this one reads the body after the headers.
633
+ body = ValidateOpsJson(ReadRequestBody(stream, buf, n, req));
634
+ status = "200 OK";
635
+ }
636
+ else if (firstLine.StartsWith("POST /bridge/expr/validate"))
637
+ {
638
+ body = ValidateExprJson(firstLine);
639
+ status = "200 OK";
640
+ }
641
+ else if (firstLine.StartsWith("POST /bridge/model/variable"))
642
+ {
643
+ // Create a Model variable inline on the HTTP thread (node-model
644
+ // ops are off-thread-safe at design time; no marshaling).
645
+ body = WriteVariableInline(firstLine);
646
+ status = "200 OK";
647
+ }
648
+ else if (firstLine.StartsWith("POST /bridge/model/folder"))
649
+ {
650
+ body = CreateFolderInline(firstLine);
651
+ status = "200 OK";
652
+ }
653
+ else if (firstLine.StartsWith("POST /bridge/model/object"))
654
+ {
655
+ body = CreateObjectInline(firstLine);
656
+ status = "200 OK";
657
+ }
658
+ else if (firstLine.StartsWith("POST /bridge/model/netlogic-method"))
659
+ {
660
+ body = DeclareNetLogicMethodInline(firstLine);
661
+ status = "200 OK";
662
+ }
663
+ else if (firstLine.StartsWith("POST /bridge/model/netlogic"))
664
+ {
665
+ body = CreateNetLogicInline(firstLine);
666
+ status = "200 OK";
667
+ }
668
+ else if (firstLine.StartsWith("POST /bridge/model/type"))
669
+ {
670
+ body = CreateTypeInline(firstLine);
671
+ status = "200 OK";
672
+ }
673
+ else if (firstLine.StartsWith("POST /bridge/node/convert-to-type"))
674
+ {
675
+ body = ConvertToTypeInline(firstLine);
676
+ status = "200 OK";
677
+ }
678
+ else if (firstLine.StartsWith("POST /bridge/node/move"))
679
+ {
680
+ body = MoveNodeInline(firstLine);
681
+ status = "200 OK";
682
+ }
683
+ else if (firstLine.StartsWith("POST /bridge/ui/widget"))
684
+ {
685
+ // Add a UI object to a screen inline (touches the presentation
686
+ // engine; still node-model, off-thread-safe).
687
+ body = WriteWidgetInline(firstLine);
688
+ status = "200 OK";
689
+ }
690
+ else if (firstLine.StartsWith("POST /bridge/node/displayname"))
691
+ {
692
+ // DisplayName ATTRIBUTE set - deliberately its own route, not a
693
+ // set_property branch: attributes are not UA child variables and
694
+ // must never touch the GetOrCreateVariable path (orphan crash).
695
+ body = SetDisplayNameInline(firstLine);
696
+ status = "200 OK";
697
+ }
698
+ else if (firstLine.StartsWith("POST /bridge/node/property"))
699
+ {
700
+ // GENERIC property set - NOT per-component plumbing. Every
701
+ // property is an IUAVariable (node.GetVariable(name)); set
702
+ // .Value coerced by the property's own DataType. LocalizedText
703
+ // is just one coercion branch alongside Bool/Int/Double/String.
704
+ body = SetPropertyInline(firstLine);
705
+ status = "200 OK";
706
+ }
707
+ else if (firstLine.StartsWith("POST /bridge/node/bind"))
708
+ {
709
+ // Bind a property -> model variable (DynamicLink). Node-model op.
710
+ body = BindPropertyInline(firstLine);
711
+ status = "200 OK";
712
+ }
713
+ else if (firstLine.StartsWith("POST /bridge/node/alias"))
714
+ {
715
+ body = CreateAliasInline(firstLine);
716
+ status = "200 OK";
717
+ }
718
+ else if (firstLine.StartsWith("POST /bridge/i18n/translation"))
719
+ {
720
+ body = AddTranslationInline(firstLine);
721
+ status = "200 OK";
722
+ }
723
+ else if (firstLine.StartsWith("POST /bridge/node/delete"))
724
+ {
725
+ body = DeleteNodeInline(firstLine);
726
+ status = "200 OK";
727
+ }
728
+ else if (firstLine.StartsWith("POST /bridge/node/event"))
729
+ {
730
+ body = WireEventInline(firstLine);
731
+ status = "200 OK";
732
+ }
733
+ // new dispatcher branch, paired with
734
+ // InvokeMethodInline() below -- the generic UAMethod-invoke endpoint.
735
+ else if (firstLine.StartsWith("POST /bridge/node/invoke"))
736
+ {
737
+ body = InvokeMethodInline(firstLine);
738
+ status = "200 OK";
739
+ }
740
+ else if (firstLine.StartsWith("POST /bridge/setup/web-engine"))
741
+ {
742
+ // Ensure a Web presentation engine exists so the runtime serves a
743
+ // canvas (the manual "add Web presentation engine" setup step).
744
+ body = EnsureWebEngineInline(firstLine);
745
+ status = "200 OK";
746
+ }
747
+ else
748
+ {
749
+ body = ErrorJson("not_found", "unknown route");
750
+ status = "404 Not Found";
751
+ }
752
+ }
753
+ catch (Exception ex)
754
+ {
755
+ body = ErrorJson("internal", ex.Message);
756
+ status = "500 Internal Server Error";
757
+ }
758
+
759
+ // U21: one Output line per MUTATION (never for reads — they burst).
760
+ // Sited here, after the dispatch chain and the catch, because this
761
+ // is the single point where every route's FINAL body passes on its
762
+ // way to the one WriteResponse — including the internal-error path.
763
+ MaybeLogMutation(firstLine, body);
764
+
765
+ WriteResponse(stream, status, body);
766
+ }
767
+ }
768
+
769
+ // ---- U21: per-mutation Output logging -----------------------------------
770
+
771
+ // Routes that MUTATE the model — the only ones that log. Keep in sync with
772
+ // the POST branches in the dispatcher (model/*, ui/widget, i18n/translation,
773
+ // setup/web-engine, and node/{property,bind,alias,move,convert-to-type,
774
+ // reorder,delete,event,attach-expression}). Read routes are deliberately
775
+ // absent, and so is POST /bridge/expr/validate — a POST by shape but a pure
776
+ // read (it validates, mutates nothing).
777
+ private static readonly string[] _MutationRoutes = {
778
+ "POST /bridge/model/", "POST /bridge/ui/widget",
779
+ "POST /bridge/i18n/translation", "POST /bridge/setup/web-engine",
780
+ "POST /bridge/node/property", "POST /bridge/node/bind",
781
+ "POST /bridge/node/alias", "POST /bridge/node/move",
782
+ "POST /bridge/node/convert-to-type", "POST /bridge/node/reorder",
783
+ "POST /bridge/node/delete", "POST /bridge/node/event",
784
+ "POST /bridge/node/attach-expression",
785
+ // added for the generic invoke endpoint below.
786
+ "POST /bridge/node/invoke",
787
+ };
788
+
789
+ private void MaybeLogMutation(string firstLine, string body)
790
+ {
791
+ try
792
+ {
793
+ bool isMutation = false;
794
+ foreach (var r in _MutationRoutes)
795
+ if (firstLine.StartsWith(r)) { isMutation = true; break; }
796
+ if (!isMutation) return;
797
+ // ErrorJson (and the did_you_mean builder) always start
798
+ // {"error":{"code":... ; anything else is a success body
799
+ // (PropOkJson / create-node builders).
800
+ bool ok = !string.IsNullOrEmpty(body) && !body.StartsWith("{\"error\"");
801
+ Log.Info("StudioBridge", (ok ? "OK " : "FAIL ") + OpLabel(firstLine));
802
+ }
803
+ catch { /* logging must NEVER break a write */ }
804
+ }
805
+
806
+ private string OpLabel(string firstLine)
807
+ {
808
+ // "<VERB> <route>" + path=/name= query params. NEVER the `value` param —
809
+ // it can be large and is untrusted content.
810
+ var parts = firstLine.Split(' ');
811
+ string route = parts.Length >= 2 ? parts[0] + " " + parts[1].Split('?')[0]
812
+ : firstLine;
813
+ var sb = new StringBuilder(route);
814
+ string p = QueryParam(firstLine, "path");
815
+ string nm = QueryParam(firstLine, "name");
816
+ if (!string.IsNullOrEmpty(p)) sb.Append(" path=").Append(p);
817
+ if (!string.IsNullOrEmpty(nm)) sb.Append(" name=").Append(nm);
818
+ return sb.ToString();
819
+ }
820
+
821
+ // ---- endpoint bodies ----------------------------------------------------
822
+
823
+ private string HealthJson()
824
+ {
825
+ string project = "unknown";
826
+ bool modelLoaded = false;
827
+ try
828
+ {
829
+ var p = Project.Current;
830
+ if (p != null) { project = p.BrowseName; modelLoaded = true; }
831
+ }
832
+ catch (Exception ex)
833
+ {
834
+ Log.Warning("StudioBridge", "Project.Current unavailable: " + ex.Message);
835
+ }
836
+ // "port" lets the Python service, which now probes a whole
837
+ // port RANGE (see StartListener), confirm which port answered rather
838
+ // than assuming the well-known 8768 - and is handy for a human reading
839
+ // the raw JSON while debugging which Studio instance is which.
840
+ return "{\"bridge_version\":\"" + BridgeVersion +
841
+ "\",\"project\":\"" + JsonEscape(project) +
842
+ "\",\"model_loaded\":" + Bool(modelLoaded) +
843
+ ",\"port\":" + _boundPort + "}";
844
+ }
845
+
846
+ private string NodeJson(string path, IUANode node)
847
+ {
848
+ var children = new StringBuilder();
849
+ var props = new StringBuilder();
850
+ int childCount = 0, propCount = 0;
851
+ bool childTrunc = false, propTrunc = false;
852
+
853
+ foreach (var child in node.Children)
854
+ {
855
+ if (child is IUAVariable v)
856
+ {
857
+ if (propCount >= MaxItems) { propTrunc = true; continue; }
858
+ if (propCount++ > 0) props.Append(",");
859
+ props.Append("{\"name\":\"" + JsonEscape(child.BrowseName) +
860
+ "\",\"datatype\":\"" + JsonEscape(DataTypeNameFull(v)) +
861
+ "\",\"value\":\"" + JsonEscape(ValueString(v)) + "\"}");
862
+ }
863
+ else
864
+ {
865
+ if (childCount >= MaxItems) { childTrunc = true; continue; }
866
+ if (childCount++ > 0) children.Append(",");
867
+ children.Append("{\"browse_name\":\"" + JsonEscape(child.BrowseName) +
868
+ "\",\"node_class\":\"" + child.NodeClass +
869
+ "\",\"dotnet_type\":\"" + JsonEscape(child.GetType().Name) + "\"}");
870
+ }
871
+ }
872
+
873
+ return "{\"path\":\"" + JsonEscape(path) +
874
+ "\",\"browse_name\":\"" + JsonEscape(node.BrowseName) +
875
+ "\",\"node_class\":\"" + node.NodeClass +
876
+ "\",\"dotnet_type\":\"" + JsonEscape(node.GetType().Name) +
877
+ "\",\"children\":[" + children + "]" +
878
+ ",\"properties\":[" + props + "]" +
879
+ ",\"truncated\":" + Bool(childTrunc || propTrunc) + "}";
880
+ }
881
+
882
+ // Parity with the file-path list_screens (optix_model.SCREEN_TYPES) plus the
883
+ // window type the live model reports (MainWindow -> WindowType, validated
884
+ // Calibrate against real screen/dialog nodes on the next Studio run.
885
+ private static readonly string[] ScreenTypes = { "Screen", "Panel", "Dialog", "WindowType" };
886
+
887
+ private string ScreensJson()
888
+ {
889
+ var sb = new StringBuilder();
890
+ int count = 0;
891
+ bool trunc = false;
892
+ var ui = ResolveNode("UI");
893
+ if (ui != null)
894
+ CollectScreens(ui, "UI", sb, ref count, ref trunc, 0);
895
+ return "{\"screens\":[" + sb + "],\"count\":" + count +
896
+ ",\"truncated\":" + Bool(trunc) + "}";
897
+ }
898
+
899
+ private void CollectScreens(IUANode node, string path, StringBuilder sb,
900
+ ref int count, ref bool trunc, int depth)
901
+ {
902
+ if (depth > 5) return;
903
+ foreach (var child in node.Children)
904
+ {
905
+ string childPath = path + "/" + child.BrowseName;
906
+ string tn = child.GetType().Name;
907
+ if (Array.IndexOf(ScreenTypes, tn) >= 0)
908
+ {
909
+ if (count >= MaxItems) { trunc = true; return; }
910
+ if (count++ > 0) sb.Append(",");
911
+ sb.Append("{\"name\":\"" + JsonEscape(child.BrowseName) +
912
+ "\",\"type\":\"" + JsonEscape(tn) +
913
+ "\",\"node_class\":\"" + child.NodeClass +
914
+ "\",\"path\":\"" + JsonEscape(childPath) +
915
+ "\",\"child_count\":" + child.Children.Count() + "}");
916
+ }
917
+ // Recurse into folders (e.g. UI/Screens) to find nested screens.
918
+ if (tn == "Folder")
919
+ CollectScreens(child, childPath, sb, ref count, ref trunc, depth + 1);
920
+ }
921
+ }
922
+
923
+ // Enumerate the builtin UI type catalog by reflecting FTOptix.UI.ObjectTypes
924
+ // (NodeId constants) and resolving each to its model type. This is the
925
+ // resource-map / type-discovery surface - the answer to "what controls
926
+ // exist?" without the model guessing. NOTE: ObjectTypes also carries event
927
+ // types (MouseClickEvent, ...); a future pass could filter to BaseUIObject
928
+ // subtypes.
929
+ private string TypesUiJson()
930
+ {
931
+ var sb = new StringBuilder();
932
+ int count = 0;
933
+ bool trunc = false;
934
+ var fields = typeof(FTOptix.UI.ObjectTypes)
935
+ .GetFields(BindingFlags.Public | BindingFlags.Static);
936
+ foreach (var f in fields)
937
+ {
938
+ if (count >= MaxItems) { trunc = true; break; }
939
+ string browse = f.Name;
940
+ try
941
+ {
942
+ if (f.GetValue(null) is NodeId nid)
943
+ {
944
+ var t = InformationModel.Get(nid);
945
+ if (t != null) browse = t.BrowseName;
946
+ }
947
+ }
948
+ catch { /* unresolved type id - fall back to the field name */ }
949
+ if (count++ > 0) sb.Append(",");
950
+ sb.Append("{\"name\":\"" + JsonEscape(f.Name) +
951
+ "\",\"browse_name\":\"" + JsonEscape(browse) + "\"}");
952
+ }
953
+ return "{\"types\":[" + sb + "],\"count\":" + count +
954
+ ",\"truncated\":" + Bool(trunc) + "}";
955
+ }
956
+
957
+ // GET /bridge/schema/dump - the WHOLE builtin type catalog x per-type property
958
+ // reflection, in one call, so the service can cache it offline (keyed by Studio
959
+ // version) and diff it across Studio upgrades.
960
+ //
961
+ // Deliberately NOT capped at MaxItems, unlike TypesUiJson/TypeSchemaJson: those
962
+ // serve a human/LLM reading one answer, where 500 is a sane ceiling. A dump that
963
+ // silently dropped types or properties would cache as a schema that looks
964
+ // complete and would then show up as phantom additions/removals in the next
965
+ // version diff. Size is fine - WriteResponse sets a real Content-Length and
966
+ // writes the whole body.
967
+ //
968
+ // Reuses the same two reflectors as the single-type routes so the dump can never
969
+ // disagree with describe_type: the ObjectTypes field set for the catalog, and the
970
+ // ResolveWidgetClrType + IsLegendProp loop for properties. Emits only the three
971
+ // contract fields {name, datatype, settable}; the placeholder-collection extras
972
+ // TypeSchemaJson adds are omitted (the Python schema_diff ignores unknown keys,
973
+ // so the narrow shape is the safer default).
974
+ //
975
+ // NOTE: ObjectTypes also carries EVENT types (MouseClickEvent...), which appear
976
+ // in the dump with an empty property list. Harmless for diffing; filtering to
977
+ // BaseUIObject subtypes is a future nicety.
978
+ private string SchemaDumpJson()
979
+ {
980
+ string version = "unknown";
981
+ try
982
+ {
983
+ var v = typeof(FTOptix.UI.ObjectTypes).Assembly.GetName().Version;
984
+ if (v != null && !string.IsNullOrEmpty(v.ToString())) version = v.ToString();
985
+ }
986
+ catch { /* keep "unknown" - the Python _version_key sanitizes whatever it gets */ }
987
+
988
+ var types = new StringBuilder();
989
+ int typeCount = 0;
990
+ var fields = typeof(FTOptix.UI.ObjectTypes)
991
+ .GetFields(BindingFlags.Public | BindingFlags.Static);
992
+ foreach (var f in fields)
993
+ {
994
+ // Per-type guard: one unresolvable type must not abort the whole dump.
995
+ try
996
+ {
997
+ string browse = f.Name;
998
+ if (f.GetValue(null) is NodeId nid)
999
+ {
1000
+ var t = InformationModel.Get(nid);
1001
+ if (t != null) browse = t.BrowseName;
1002
+ }
1003
+
1004
+ var props = new StringBuilder();
1005
+ int propCount = 0;
1006
+ var clr = ResolveWidgetClrType(f.Name);
1007
+ if (clr != null)
1008
+ {
1009
+ foreach (var pi in clr.GetProperties(BindingFlags.Public | BindingFlags.Instance)
1010
+ .Where(IsLegendProp)
1011
+ .GroupBy(p => p.Name).Select(g => g.First()).OrderBy(p => p.Name))
1012
+ {
1013
+ if (propCount++ > 0) props.Append(",");
1014
+ props.Append("{\"name\":\"" + JsonEscape(pi.Name) +
1015
+ "\",\"datatype\":\"" + JsonEscape(pi.PropertyType.Name) +
1016
+ "\",\"settable\":" +
1017
+ Bool(pi.CanWrite && !pi.PropertyType.IsArray) + "}");
1018
+ }
1019
+ }
1020
+
1021
+ if (typeCount++ > 0) types.Append(",");
1022
+ types.Append("\"" + JsonEscape(f.Name) + "\":{\"browse_name\":\"" +
1023
+ JsonEscape(browse) + "\",\"properties\":[" + props + "]}");
1024
+ }
1025
+ catch { /* skip this type, keep dumping the rest */ }
1026
+ }
1027
+
1028
+ return "{\"studio_version\":\"" + JsonEscape(version) +
1029
+ "\",\"generated_at\":\"" + JsonEscape(DateTime.UtcNow.ToString("o")) +
1030
+ "\",\"types\":{" + types + "}}";
1031
+ }
1032
+
1033
+ // Property schema of a builtin UI type, e.g. ?type=Label. Returns null
1034
+ // (-> 404) for an unknown type. Inheritance-COMPLETE: reflects the generated
1035
+ // Optix CLR proxy (FTOptix.UI.<Type>), whose properties include everything
1036
+ // inherited from Item/base - so a Panel correctly shows NO Border* and a
1037
+ // Rectangle does. This is the authoritative legend an author consults BEFORE a
1038
+ // set, so it never guesses a property the type lacks (the class of write that
1039
+ // crashes Studio - see the validity gate in the set-property path). Falls back
1040
+ // to the type node's direct IUAVariable children if the CLR type can't resolve.
1041
+ private string TypeSchemaJson(string typeName)
1042
+ {
1043
+ var field = typeof(FTOptix.UI.ObjectTypes)
1044
+ .GetField(typeName, BindingFlags.Public | BindingFlags.Static);
1045
+ if (field == null) return null;
1046
+ var nid = field.GetValue(null) as NodeId;
1047
+ var t = nid != null ? InformationModel.Get(nid) : null;
1048
+ if (t == null) return null;
1049
+
1050
+ var props = new StringBuilder();
1051
+ int count = 0;
1052
+ bool trunc = false;
1053
+ var clr = ResolveWidgetClrType(typeName);
1054
+ if (clr != null)
1055
+ {
1056
+ foreach (var pi in clr.GetProperties(BindingFlags.Public | BindingFlags.Instance)
1057
+ .Where(IsLegendProp)
1058
+ .GroupBy(p => p.Name).Select(g => g.First()).OrderBy(p => p.Name))
1059
+ {
1060
+ if (count >= MaxItems) { trunc = true; break; }
1061
+ if (count++ > 0) props.Append(",");
1062
+ props.Append("{\"name\":\"" + JsonEscape(pi.Name) +
1063
+ "\",\"datatype\":\"" + JsonEscape(pi.PropertyType.Name) +
1064
+ // Array props (String[]/NodeId[]) are settable in CLR
1065
+ // terms but set_property refuses them
1066
+ // (unsupported_array_write) - report the tool truth.
1067
+ "\",\"settable\":" + Bool(pi.CanWrite && !pi.PropertyType.IsArray));
1068
+ // Schema visibility for placeholder collections (spec sec.4): the
1069
+ // rule "children go IN the collection" is discoverable here
1070
+ // instead of via a failed emulator run.
1071
+ if (IsPlaceholderColl(pi.PropertyType) || IsPlaceholderRoColl(pi.PropertyType))
1072
+ {
1073
+ var elem = PlaceholderElementType(pi.PropertyType);
1074
+ props.Append(",\"placeholder_collection\":true" +
1075
+ ",\"collection_readonly\":" + Bool(IsPlaceholderRoColl(pi.PropertyType)) +
1076
+ (elem != null ? ",\"children_go_in\":\"" + JsonEscape(pi.Name) +
1077
+ "\",\"element_type\":\"" + JsonEscape(elem.Name) + "\"" : ""));
1078
+ }
1079
+ props.Append("}");
1080
+ }
1081
+ }
1082
+ else
1083
+ {
1084
+ foreach (var child in t.Children.OfType<IUAVariable>())
1085
+ {
1086
+ if (count >= MaxItems) { trunc = true; break; }
1087
+ if (count++ > 0) props.Append(",");
1088
+ props.Append("{\"name\":\"" + JsonEscape(child.BrowseName) +
1089
+ "\",\"datatype\":\"" + JsonEscape(DataTypeNameFull(child)) + "\"}");
1090
+ }
1091
+ }
1092
+ return "{\"type\":\"" + JsonEscape(typeName) +
1093
+ "\",\"browse_name\":\"" + JsonEscape(t.BrowseName) +
1094
+ "\",\"properties\":[" + props + "]" +
1095
+ ",\"truncated\":" + Bool(trunc) + "}";
1096
+ }
1097
+
1098
+ // Legend filter: an author-facing settable property. Keeps FTOptix-declared
1099
+ // value props, drops the noise the generated proxy also exposes - the IUAVariable
1100
+ // companion accessors (X has an XVariable pair) and structural children
1101
+ // (Children/GridLayoutProperties). Used ONLY for the human/LLM legend
1102
+ // (describe + the rejection hint), NOT for the gate's acceptance test - the gate
1103
+ // stays permissive so it never false-rejects a genuinely-declared property.
1104
+ private static bool IsLegendProp(System.Reflection.PropertyInfo pi)
1105
+ {
1106
+ return pi.DeclaringType != null && pi.DeclaringType.Namespace != null
1107
+ && pi.DeclaringType.Namespace.StartsWith("FTOptix")
1108
+ && !pi.Name.EndsWith("Variable")
1109
+ && pi.Name != "Children" && pi.Name != "GridLayoutProperties";
1110
+ }
1111
+
1112
+ // GET /bridge/node/varmembers?path=X&name=Y - DIAGNOSTIC: dump a live variable's
1113
+ // runtime type + its members whose name looks access/permission-related. Used to
1114
+ // discover the correct OPC-UA read-only API (IUAVariable has no AccessLevel;
1115
+ // reflection off the concrete runtime type finds where it actually lives).
1116
+ private string VarMembersJson(string path, string name)
1117
+ {
1118
+ try
1119
+ {
1120
+ var node = ResolveNode(path);
1121
+ if (node == null) return ErrorJson("node_not_found", "no node at: " + path);
1122
+ IUAVariable v = string.IsNullOrEmpty(name) ? (node as IUAVariable) : node.GetVariable(name);
1123
+ if (v == null) return ErrorJson("not_a_variable", "no variable '" + name + "' on " + path);
1124
+ var rt = v.GetType();
1125
+ var sb = new StringBuilder();
1126
+ int i = 0;
1127
+ foreach (var mi in rt.GetMembers())
1128
+ {
1129
+ var n = mi.Name;
1130
+ if (n.IndexOf("Access", StringComparison.OrdinalIgnoreCase) < 0 &&
1131
+ n.IndexOf("Permission", StringComparison.OrdinalIgnoreCase) < 0 &&
1132
+ n.IndexOf("Writ", StringComparison.OrdinalIgnoreCase) < 0 &&
1133
+ n.IndexOf("ReadOnly", StringComparison.OrdinalIgnoreCase) < 0 &&
1134
+ n.IndexOf("Attribute", StringComparison.OrdinalIgnoreCase) < 0) continue;
1135
+ if (i++ > 0) sb.Append(",");
1136
+ sb.Append("{\"kind\":\"" + mi.MemberType + "\",\"name\":\"" + JsonEscape(n) + "\"}");
1137
+ }
1138
+ return "{\"path\":\"" + JsonEscape(path) + "\",\"name\":\"" + JsonEscape(name ?? "") +
1139
+ "\",\"runtime_type\":\"" + JsonEscape(rt.FullName) +
1140
+ "\",\"access_members\":[" + sb + "]}";
1141
+ }
1142
+ catch (Exception ex)
1143
+ {
1144
+ return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}";
1145
+ }
1146
+ }
1147
+
1148
+ // POST /bridge/node/attach-expression?path=<node>&name=<prop>&expression=<expr>&sources=<a,b,..>
1149
+ // Attach an ExpressionEvaluator converter to a property (roadmap tool A). The
1150
+ // ExpressionEvaluator is a formula language ("dumb Excel"): Expression is a
1151
+ // string with {0},{1},.. placeholders bound to SourceN inputs, e.g.
1152
+ // "if({0} > 40, 0xFFFF0000, 0xFF00FF00)" on a FillColor. Subsumes
1153
+ // ConditionalConverter/Linear/etc. Model shape + API from OptixMaster
1154
+ // SetInputVisibility.cs: MakeObject<ExpressionEvaluator>, set .Expression, make a
1155
+ // Source var per input with SetDynamicLink to the source, AddReference(HasSource),
1156
+ // then propVar.SetConverter(ee). SetDynamicLink is the same off-thread-safe call
1157
+ // bind_property already uses. Converters no-op SILENTLY if mis-wired -> runtime
1158
+ // render-verify, not just {ok:true}.
1159
+ private string AttachExpressionInline(string firstLine)
1160
+ {
1161
+ string path = QueryParam(firstLine, "path");
1162
+ string name = QueryParam(firstLine, "name");
1163
+ string expr = QueryParam(firstLine, "expression");
1164
+ string sources = QueryParam(firstLine, "sources");
1165
+ if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(name) || string.IsNullOrEmpty(expr))
1166
+ return ErrorJson("bad_query", "required: path, name, expression (+ sources=comma,sep,node,paths)");
1167
+ try
1168
+ {
1169
+ // Pre-validate the formula (Optix only checks at runtime -> a bad expr
1170
+ // silently no-ops). Same check as the ValidateExpression ExportMethod +
1171
+ // /bridge/expr/validate: reject the common syntactic mistakes up front.
1172
+ var exprErr = ValidateExpressionSyntax(expr, CountSources(sources));
1173
+ if (exprErr != null) return ErrorJson("bad_expression", exprErr);
1174
+
1175
+ var node = ResolveNode(path);
1176
+ if (node == null) return ErrorJson("node_not_found", "no node at: " + path);
1177
+ IUAVariable propVar = node.GetVariable(name);
1178
+ if (propVar == null)
1179
+ {
1180
+ var gate = DeclaredPropertyGuard(node, name);
1181
+ if (gate != null) return gate;
1182
+ // Same pre-materialization array gate as set_property: don't
1183
+ // GetOrCreateVariable an array-typed declared property (NodeId[]
1184
+ // materialization is implicated in the 2026-07-16 Studio crash).
1185
+ var arrGate = DeclaredArrayGuard(node, name);
1186
+ if (arrGate != null) return arrGate;
1187
+ propVar = (node as IUAObject)?.GetOrCreateVariable(name);
1188
+ }
1189
+ if (propVar == null) return ErrorJson("property_not_found", "no property " + name + " on " + path);
1190
+
1191
+ var ee = InformationModel.MakeObject<FTOptix.CoreBase.ExpressionEvaluator>("ExpressionEvaluator");
1192
+ ee.Expression = expr;
1193
+ int i = 0;
1194
+ var added = new StringBuilder();
1195
+ if (!string.IsNullOrEmpty(sources))
1196
+ {
1197
+ foreach (var sp in sources.Split(','))
1198
+ {
1199
+ var s = sp.Trim();
1200
+ if (s.Length == 0) continue;
1201
+ var srcVar = ResolveNode(s) as IUAVariable;
1202
+ if (srcVar == null) return ErrorJson("source_not_variable", "source is not a variable: " + s);
1203
+ var srcN = InformationModel.MakeVariable("Source" + i, OpcUa.DataTypes.BaseDataType);
1204
+ srcN.SetDynamicLink(srcVar);
1205
+ ee.Refs.AddReference(FTOptix.CoreBase.ReferenceTypes.HasSource, srcN);
1206
+ if (i > 0) added.Append(",");
1207
+ added.Append("\"" + JsonEscape(s) + "\"");
1208
+ i++;
1209
+ }
1210
+ }
1211
+ propVar.SetConverter(ee);
1212
+ return "{\"ok\":true,\"path\":\"" + JsonEscape(path) + "\",\"name\":\"" + JsonEscape(name) +
1213
+ "\",\"expression\":\"" + JsonEscape(expr) + "\",\"sources\":[" + added +
1214
+ "],\"via\":\"expression-converter\",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
1215
+ }
1216
+ catch (Exception ex)
1217
+ {
1218
+ return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}";
1219
+ }
1220
+ }
1221
+
1222
+ // POST /bridge/node/reorder?path=X&position=front|back (or &index=N) - change a
1223
+ // node's z-order among its siblings. In Optix render order = child order (a
1224
+ // HasOrderedComponent list): last child renders on TOP (front), first renders at
1225
+ // the BACK. This is Studio's "bring to front / send to back" (drag up/down), and
1226
+ // the enabler for a Panel background Rectangle behind existing children (the
1227
+ // panelbg gap). Rebuilds the parent's Children in the new order using only
1228
+ // node-model Add/Remove (off-thread-safe class). SCRATCH-TEST before trusting.
1229
+ private string ReorderInline(string firstLine)
1230
+ {
1231
+ string path = QueryParam(firstLine, "path");
1232
+ string pos = QueryParam(firstLine, "position");
1233
+ string idxStr = QueryParam(firstLine, "index");
1234
+ if (string.IsNullOrEmpty(path))
1235
+ return ErrorJson("bad_query", "required: path, and position=front|back OR index=<int>");
1236
+ try
1237
+ {
1238
+ var node = ResolveNode(path);
1239
+ if (node == null) return ErrorJson("node_not_found", "no node at: " + path);
1240
+ var parent = node.Owner;
1241
+ if (parent == null) return ErrorJson("no_parent", "node has no parent: " + path);
1242
+ var kids = parent.Children.ToList();
1243
+ int cur = kids.IndexOf(node);
1244
+ if (cur < 0) return ErrorJson("not_a_child", "node is not in its parent's Children: " + path);
1245
+ int target;
1246
+ if (pos == "front") target = kids.Count - 1; // last in list = rendered in FRONT
1247
+ else if (pos == "back") target = 0; // first in list = rendered BEHIND
1248
+ else if (!int.TryParse(idxStr, out target))
1249
+ return ErrorJson("bad_query", "need position=front|back or index=<int>");
1250
+ if (target < 0) target = 0;
1251
+ if (target > kids.Count - 1) target = kids.Count - 1;
1252
+ if (target == cur)
1253
+ return "{\"ok\":true,\"path\":\"" + JsonEscape(path) + "\",\"index\":" + cur + ",\"noop\":true}";
1254
+ // Non-destructive in-place reorder via MoveUp()/MoveDown() (Sort-project-nodes:
1255
+ // the sanctioned API). MoveUp -> earlier in the child list = toward the BACK;
1256
+ // MoveDown -> later = toward the FRONT. NOTE: only effective on graphic objects
1257
+ // that live inside a TYPE (ScreenType/PanelType) - a plain instance's children
1258
+ // won't move. Reload the runtime page to see the visual effect.
1259
+ int moved = 0;
1260
+ while (cur > target) { node.MoveUp(); cur--; moved++; }
1261
+ while (cur < target) { node.MoveDown(); cur++; moved++; }
1262
+ return "{\"ok\":true,\"path\":\"" + JsonEscape(path) + "\",\"to\":" + target +
1263
+ ",\"moves\":" + moved + ",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
1264
+ }
1265
+ catch (Exception ex)
1266
+ {
1267
+ return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}";
1268
+ }
1269
+ }
1270
+
1271
+ // GET /bridge/node/typeinfo?path=X - diagnose a node's KIND. Is it an ObjectType
1272
+ // (a reusable *type*, like a right-click "Add Screen" -> a subtype of Screen) or an
1273
+ // Object (an *instance*, like a bridge MakeObject of the Screen type)? For a type,
1274
+ // walk the SuperType chain so we can SEE the inheritance (e.g. Screen1 -> Screen ->
1275
+ // ... vs a bare instance whose type is the base Screen). This is the diagnostic for
1276
+ // "bridge screens render in the designer but not at runtime".
1277
+ private string TypeInfoJson(string path)
1278
+ {
1279
+ try
1280
+ {
1281
+ var node = ResolveNode(path);
1282
+ if (node == null) return ErrorJson("node_not_found", "no node at: " + path);
1283
+ var sb = new StringBuilder();
1284
+ sb.Append("{\"path\":\"" + JsonEscape(path) +
1285
+ "\",\"browse_name\":\"" + JsonEscape(node.BrowseName) +
1286
+ "\",\"node_class\":\"" + node.NodeClass +
1287
+ "\",\"dotnet_type\":\"" + JsonEscape(node.GetType().Name) + "\"");
1288
+ if (node is IUAObjectType ot)
1289
+ {
1290
+ sb.Append(",\"is_type\":true,\"supertype_chain\":[");
1291
+ int i = 0;
1292
+ for (var cur = ot.SuperType; cur != null && i < 20; cur = cur.SuperType)
1293
+ {
1294
+ if (i++ > 0) sb.Append(",");
1295
+ sb.Append("{\"browse_name\":\"" + JsonEscape(cur.BrowseName) +
1296
+ "\",\"dotnet_type\":\"" + JsonEscape(cur.GetType().Name) + "\"}");
1297
+ }
1298
+ sb.Append("]");
1299
+ }
1300
+ else
1301
+ {
1302
+ sb.Append(",\"is_type\":false");
1303
+ }
1304
+ sb.Append("}");
1305
+ return sb.ToString();
1306
+ }
1307
+ catch (Exception ex)
1308
+ {
1309
+ return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}";
1310
+ }
1311
+ }
1312
+
1313
+ // Resolve a widget type name (e.g. "Panel") to its generated Optix CLR proxy
1314
+ // Type by scanning loaded assemblies for an FTOptix-namespaced type of that
1315
+ // name. Read-only reflection - no instance, no typed setter (which crashes
1316
+ // off-thread), just metadata. Null if not found (caller falls back).
1317
+ private Type ResolveWidgetClrType(string typeName)
1318
+ {
1319
+ foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
1320
+ {
1321
+ Type[] types;
1322
+ try { types = asm.GetTypes(); }
1323
+ catch { continue; } // ReflectionTypeLoadException on a partial assembly
1324
+ foreach (var ty in types)
1325
+ if (ty.Name == typeName && ty.Namespace != null
1326
+ && ty.Namespace.StartsWith("FTOptix"))
1327
+ return ty;
1328
+ }
1329
+ return null;
1330
+ }
1331
+
1332
+ // Inheritance-aware list of a live node's settable property names, filtered to
1333
+ // FTOptix-declared props (drops UAManagedCore infra like NodeId/BrowseName).
1334
+ // The valid set the validity gate hands back on an unknown-property rejection,
1335
+ // and the candidate pool the did-you-mean suggestion matches against.
1336
+ private System.Collections.Generic.List<string> DeclaredPropertyNames(object node)
1337
+ {
1338
+ return node.GetType()
1339
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
1340
+ .Where(IsLegendProp)
1341
+ .Select(pi => pi.Name).Distinct().OrderBy(x => x).ToList();
1342
+ }
1343
+
1344
+ // JSON-array-fragment form of DeclaredPropertyNames, capped at MaxItems, for
1345
+ // embedding in an unknown_property error's valid_properties list.
1346
+ private string PropertyNamesJsonList(object node)
1347
+ {
1348
+ var sb = new StringBuilder();
1349
+ int count = 0;
1350
+ foreach (var pn in DeclaredPropertyNames(node))
1351
+ {
1352
+ if (count >= MaxItems) break;
1353
+ if (count++ > 0) sb.Append(",");
1354
+ sb.Append("\"" + JsonEscape(pn) + "\"");
1355
+ }
1356
+ return sb.ToString();
1357
+ }
1358
+
1359
+ // Guard before GetOrCreateVariable at any CALLER-supplied property name.
1360
+ // GetOrCreateVariable fabricates an orphan variable for a property the type does
1361
+ // not declare (e.g. Panel.BorderThickness), and the renderer then AVs on it
1362
+ // off-thread and kills Studio (0xC0000005). Returns null when it is
1363
+ // safe to materialize (already-materialized OR type-declared, inheritance-aware
1364
+ // via the generated Optix proxy), else an unknown_property error JSON carrying
1365
+ // the valid set. Every user-facing materialization site MUST call this first.
1366
+ private string DeclaredPropertyGuard(IUANode node, string name)
1367
+ {
1368
+ if (node.GetVariable(name) != null) return null; // already materialized
1369
+ if (IsNodeAttributeName(name))
1370
+ return "{\"error\":{\"code\":\"node_attribute_not_settable\",\"message\":\"" +
1371
+ JsonEscape(name + " is a node attribute, not a UA child property - " +
1372
+ "materializing it as a variable crashes Studio. DisplayName is " +
1373
+ "settable ONLY via set_property (dedicated attribute route); to " +
1374
+ "rename a node use the rename op (or move with new_name).") + "\"}}";
1375
+ // The acceptance test requires the CLR match to be DECLARED IN AN FTOptix
1376
+ // NAMESPACE. A bare any-public-property match false-accepted UAManagedCore
1377
+ // node ATTRIBUTES (DisplayName, BrowseName, Description, NodeId, ...): they
1378
+ // exist as CLR properties on every node proxy but are not UA child variables,
1379
+ // so GetOrCreateVariable fabricated an orphan and Studio died on the next
1380
+ // render (crash confirmed live 2026-08-16, agent set DisplayName).
1381
+ if (node.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)
1382
+ .Any(p => p.Name == name && p.DeclaringType != null
1383
+ && p.DeclaringType.Namespace != null
1384
+ && p.DeclaringType.Namespace.StartsWith("FTOptix")))
1385
+ return null; // FTOptix-declared -> safe
1386
+ // Mirror the wire_event reject-with-valid-list: hand back the authoritative
1387
+ // set + a best-effort suggestion, baked into the message so it survives the
1388
+ // Python-side message/code flattening (a sibling did_you_mean field alone is
1389
+ // dropped there). The property_not_found sites downstream are only reached
1390
+ // AFTER this returns null (name already matched a real property), so they
1391
+ // deliberately carry no suggestion - the name is not a typo there.
1392
+ var valid = DeclaredPropertyNames(node);
1393
+ var suggestion = SuggestPropertyName(name, valid);
1394
+ var sb = new StringBuilder();
1395
+ sb.Append("{\"error\":{\"code\":\"unknown_property\",\"message\":\"");
1396
+ sb.Append(JsonEscape(node.GetType().Name + " has no settable property '" + name + "'" +
1397
+ (suggestion != null ? " (did you mean " + suggestion + "?)" : "") +
1398
+ " (call describe_type/describe_node for the valid set)"));
1399
+ sb.Append("\"");
1400
+ if (suggestion != null)
1401
+ {
1402
+ sb.Append(",\"did_you_mean\":\""); sb.Append(JsonEscape(suggestion)); sb.Append("\"");
1403
+ }
1404
+ sb.Append(",\"valid_properties\":[" + PropertyNamesJsonList(node) + "]}}");
1405
+ return sb.ToString();
1406
+ }
1407
+
1408
+ // UA node ATTRIBUTES the proxy exposes as CLR properties. Not UA child
1409
+ // variables - materializing one crashes Studio (see DeclaredPropertyGuard).
1410
+ // Named explicitly so the rename-intent names get the targeted nudge above
1411
+ // instead of falling through to unknown_property.
1412
+ private static bool IsNodeAttributeName(string name)
1413
+ {
1414
+ switch (name)
1415
+ {
1416
+ case "DisplayName": case "BrowseName": case "Description":
1417
+ case "NodeId": case "NodeClass":
1418
+ return true;
1419
+ default:
1420
+ return false;
1421
+ }
1422
+ }
1423
+
1424
+ // ---- live-model write endpoints (inline mutation from the HTTP thread) ----
1425
+ //
1426
+ // POST /bridge/model/variable?name=X&parent=Model&datatype=Boolean[&type=<reviewed variable type>]
1427
+ // Creates a variable via InformationModel.MakeVariable + parent.Add, INLINE
1428
+ // on this background socket thread. The result reports ok/error so we learn
1429
+ // whether off-thread design-time mutation is safe (success) or needs
1430
+ // main-thread marshaling (exception / instability). Params via query string
1431
+ // to avoid body parsing.
1432
+ private string WriteVariableInline(string firstLine)
1433
+ {
1434
+ string name = QueryParam(firstLine, "name");
1435
+ string parent = QueryParam(firstLine, "parent") ?? "Model";
1436
+ string dtName = QueryParam(firstLine, "datatype") ?? "Boolean";
1437
+ string variableType = QueryParam(firstLine, "type");
1438
+ if (string.IsNullOrEmpty(name))
1439
+ return ErrorJson("bad_query", "missing required query param: name");
1440
+ try
1441
+ {
1442
+ var parentNode = ResolveNode(parent);
1443
+ if (parentNode == null)
1444
+ return ErrorJson("node_not_found", "no parent node at: " + parent);
1445
+ IUAVariable v;
1446
+ if (string.IsNullOrEmpty(variableType))
1447
+ v = InformationModel.MakeVariable(name, ResolveDataType(dtName));
1448
+ else if (variableType == "FTOptix.DataLogger.VariableToLog")
1449
+ v = InformationModel.MakeVariable<FTOptix.DataLogger.VariableToLog>(name, ResolveDataType(dtName));
1450
+ else if (variableType == "FTOptix.Core.NodePointer")
1451
+ v = InformationModel.MakeVariable<FTOptix.Core.NodePointer>(name, ResolveDataType(dtName));
1452
+ else
1453
+ return ErrorJson("type_not_found",
1454
+ "unsupported reviewed variable type: " + variableType +
1455
+ " (allowed: FTOptix.DataLogger.VariableToLog, FTOptix.Core.NodePointer)");
1456
+ parentNode.Add(v);
1457
+ return "{\"ok\":true,\"created_path\":\"" + JsonEscape(parent + "/" + name) +
1458
+ "\",\"datatype\":\"" + JsonEscape(dtName) +
1459
+ "\",\"variable_type\":\"" + JsonEscape(variableType ?? "BaseDataVariableType") +
1460
+ "\",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
1461
+ }
1462
+ catch (Exception ex)
1463
+ {
1464
+ return "{\"ok\":false,\"mode\":\"inline\",\"error\":\"" +
1465
+ JsonEscape(ExcMsg(ex)) + "\"}";
1466
+ }
1467
+ }
1468
+
1469
+ private static NodeId ResolveDataType(string name)
1470
+ {
1471
+ switch (name)
1472
+ {
1473
+ case "BaseDataType": return OpcUa.DataTypes.BaseDataType;
1474
+ case "Boolean": return OpcUa.DataTypes.Boolean;
1475
+ case "Int16": return OpcUa.DataTypes.Int16;
1476
+ case "Int32": return OpcUa.DataTypes.Int32;
1477
+ case "Int64": return OpcUa.DataTypes.Int64;
1478
+ case "UInt16": return OpcUa.DataTypes.UInt16;
1479
+ case "UInt32": return OpcUa.DataTypes.UInt32;
1480
+ case "UInt64": return OpcUa.DataTypes.UInt64;
1481
+ case "Float": return OpcUa.DataTypes.Float;
1482
+ case "Double": return OpcUa.DataTypes.Double;
1483
+ case "String": return OpcUa.DataTypes.String;
1484
+ case "NodeId": return OpcUa.DataTypes.NodeId; // was silently Boolean
1485
+ case "DateTime": return OpcUa.DataTypes.DateTime;
1486
+ default: return OpcUa.DataTypes.Boolean;
1487
+ }
1488
+ }
1489
+
1490
+ // Shared duplicate-sibling refusal for the structural-authoring family.
1491
+ // Optix happily creates same-name siblings which are then unaddressable
1492
+ // by path; Studio's UI auto-suffixes, the bridge refuses loud.
1493
+ private static string DupNameGuard(IUANode parent, string name, string parentPath)
1494
+ {
1495
+ foreach (var existing in parent.Children)
1496
+ if (existing.BrowseName == name)
1497
+ return ErrorJson("name_exists",
1498
+ "a node named '" + name + "' already exists under '" +
1499
+ parentPath + "' - pick a different name or delete it first");
1500
+ return null;
1501
+ }
1502
+
1503
+ // POST /bridge/model/folder?parent=<path>&name=<n>
1504
+ // Structural Folder (OpcUa FolderType) - organizational node, not a UI
1505
+ // control, so it lives outside the create_widget catalog by design.
1506
+ private string CreateFolderInline(string firstLine)
1507
+ {
1508
+ string parent = QueryParam(firstLine, "parent");
1509
+ string name = QueryParam(firstLine, "name");
1510
+ if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
1511
+ return ErrorJson("bad_query", "required query params: parent, name");
1512
+ try
1513
+ {
1514
+ var parentNode = ResolveNode(parent);
1515
+ if (parentNode == null)
1516
+ return ErrorJson("node_not_found", "no parent node at: " + parent);
1517
+ var dup = DupNameGuard(parentNode, name, parent);
1518
+ if (dup != null) return dup;
1519
+ var f = InformationModel.MakeObject(name, OpcUa.ObjectTypes.FolderType);
1520
+ parentNode.Add(f);
1521
+ return "{\"ok\":true,\"created_path\":\"" + JsonEscape(parent + "/" + name) +
1522
+ "\",\"kind\":\"folder\",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
1523
+ }
1524
+ catch (Exception ex)
1525
+ { return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}"; }
1526
+ }
1527
+
1528
+ private static NodeId ReviewedModuleObjectType(string typeName)
1529
+ {
1530
+ switch (typeName)
1531
+ {
1532
+ case "FTOptix.Alarm.AlarmController": return FTOptix.Alarm.ObjectTypes.AlarmController;
1533
+ case "FTOptix.Alarm.OffNormalAlarmController": return FTOptix.Alarm.ObjectTypes.OffNormalAlarmController;
1534
+ case "FTOptix.Alarm.LimitAlarmController": return FTOptix.Alarm.ObjectTypes.LimitAlarmController;
1535
+ case "FTOptix.Alarm.ExclusiveLimitAlarmController": return FTOptix.Alarm.ObjectTypes.ExclusiveLimitAlarmController;
1536
+ case "FTOptix.Alarm.ExclusiveLevelAlarmController": return FTOptix.Alarm.ObjectTypes.ExclusiveLevelAlarmController;
1537
+ case "FTOptix.Alarm.ExclusiveDeviationAlarmController": return FTOptix.Alarm.ObjectTypes.ExclusiveDeviationAlarmController;
1538
+ case "FTOptix.Alarm.ExclusiveRateOfChangeAlarmController": return FTOptix.Alarm.ObjectTypes.ExclusiveRateOfChangeAlarmController;
1539
+ case "FTOptix.Alarm.NonExclusiveLimitAlarmController": return FTOptix.Alarm.ObjectTypes.NonExclusiveLimitAlarmController;
1540
+ case "FTOptix.Alarm.NonExclusiveLevelAlarmController": return FTOptix.Alarm.ObjectTypes.NonExclusiveLevelAlarmController;
1541
+ case "FTOptix.Alarm.NonExclusiveDeviationAlarmController": return FTOptix.Alarm.ObjectTypes.NonExclusiveDeviationAlarmController;
1542
+ case "FTOptix.Alarm.NonExclusiveRateOfChangeAlarmController": return FTOptix.Alarm.ObjectTypes.NonExclusiveRateOfChangeAlarmController;
1543
+ case "FTOptix.DataLogger.DataLogger": return FTOptix.DataLogger.ObjectTypes.DataLogger;
1544
+ case "FTOptix.SQLiteStore.SQLiteStore": return FTOptix.SQLiteStore.ObjectTypes.SQLiteStore;
1545
+ case "FTOptix.SQLiteStore.SQLiteStoreTable": return FTOptix.SQLiteStore.ObjectTypes.SQLiteStoreTable;
1546
+ case "FTOptix.Recipe.Recipe": return FTOptix.Recipe.ObjectTypes.Recipe;
1547
+ case "FTOptix.Recipe.RecipeSchema": return FTOptix.Recipe.ObjectTypes.RecipeSchema;
1548
+ case "FTOptix.OPCUAServer.OPCUAServer": return FTOptix.OPCUAServer.ObjectTypes.OPCUAServer;
1549
+ case "FTOptix.OPCUAServer.NodesToPublishConfigurationEntry": return FTOptix.OPCUAServer.ObjectTypes.NodesToPublishConfigurationEntry;
1550
+ case "FTOptix.RAEtherNetIP.Driver": return FTOptix.RAEtherNetIP.ObjectTypes.Driver;
1551
+ case "FTOptix.RAEtherNetIP.Station": return FTOptix.RAEtherNetIP.ObjectTypes.Station;
1552
+ case "FTOptix.RAEtherNetIP.SmartObjectNode": return FTOptix.RAEtherNetIP.ObjectTypes.SmartObjectNode;
1553
+ default: return null;
1554
+ }
1555
+ }
1556
+
1557
+ // POST /bridge/model/object?parent=<path>&name=<n>[&type=<project type path | reviewed module type>]
1558
+ // Plain structural Object (BaseObjectType) container, OR an INSTANCE of a
1559
+ // project-defined ObjectType when `type` is given (the reuse half of the
1560
+ // create_type/templates workflow): MakeObject(name, customType.NodeId) per
1561
+ // the NetLogic cheatsheet's "DesignTime creation of custom Object instances".
1562
+ private string CreateObjectInline(string firstLine)
1563
+ {
1564
+ string parent = QueryParam(firstLine, "parent");
1565
+ string name = QueryParam(firstLine, "name");
1566
+ string typePath = QueryParam(firstLine, "type");
1567
+ if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
1568
+ return ErrorJson("bad_query", "required query params: parent, name (+ optional type=<ObjectType path>)");
1569
+ try
1570
+ {
1571
+ var parentNode = ResolveNode(parent);
1572
+ if (parentNode == null)
1573
+ return ErrorJson("node_not_found", "no parent node at: " + parent);
1574
+ var dup = DupNameGuard(parentNode, name, parent);
1575
+ if (dup != null) return dup;
1576
+ NodeId typeId = OpcUa.ObjectTypes.BaseObjectType;
1577
+ string typeLabel = "BaseObjectType";
1578
+ if (!string.IsNullOrEmpty(typePath))
1579
+ {
1580
+ var reviewedType = ReviewedModuleObjectType(typePath);
1581
+ if (reviewedType != null)
1582
+ typeId = reviewedType;
1583
+ else
1584
+ {
1585
+ var typeNode = ResolveNode(typePath);
1586
+ if (typeNode == null)
1587
+ return ErrorJson("type_not_found", "no reviewed module type or project type at: " + typePath);
1588
+ if (typeNode.NodeClass != NodeClass.ObjectType)
1589
+ return ErrorJson("not_a_type",
1590
+ typePath + " is " + typeNode.NodeClass +
1591
+ ", not an ObjectType - pass the TYPE node (e.g. UI/Templates/MyCard)," +
1592
+ " not an instance; create one with /bridge/model/type first");
1593
+ typeId = typeNode.NodeId;
1594
+ }
1595
+ typeLabel = typePath;
1596
+ }
1597
+ var o = InformationModel.MakeObject(name, typeId);
1598
+ parentNode.Add(o);
1599
+ return "{\"ok\":true,\"created_path\":\"" + JsonEscape(parent + "/" + name) +
1600
+ "\",\"type\":\"" + JsonEscape(typeLabel) +
1601
+ "\",\"node_class\":\"" + o.NodeClass +
1602
+ "\",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
1603
+ }
1604
+ catch (Exception ex)
1605
+ { return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}"; }
1606
+ }
1607
+
1608
+ // POST /bridge/model/netlogic?parent=<path>&name=<ClassName>
1609
+ // Create a NetLogicObject node bound to a C# class BY NAME. This is the piece
1610
+ // that used to force a manual Studio "New -> NetLogic" step: it turns out a
1611
+ // NetLogic node carries no proxy and no code-reference property - Optix binds
1612
+ // the node to a class whose name equals the node BrowseName, and the SDK-style
1613
+ // NetSolution .csproj auto-globs every .cs, so a matching class compiles in on
1614
+ // the next build. Proven by a name-bound probe: a hand-authored NetLogic ran
1615
+ // Start() at runtime with no Studio menu step.
1616
+ // Caller contract: the class named `name` must exist in the NetSolution and
1617
+ // match exactly; add the .cs (design-time authoring), then rebuild/run so the
1618
+ // runtime instantiates it. Placement matters for runtime NetLogics that use
1619
+ // Owner (put it under the object whose siblings it reads).
1620
+ private string CreateNetLogicInline(string firstLine)
1621
+ {
1622
+ string parent = QueryParam(firstLine, "parent");
1623
+ string name = QueryParam(firstLine, "name");
1624
+ if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
1625
+ return ErrorJson("bad_query", "required query params: parent, name (name must equal the C# class name)");
1626
+ try
1627
+ {
1628
+ var parentNode = ResolveNode(parent);
1629
+ if (parentNode == null)
1630
+ return ErrorJson("node_not_found", "no parent node at: " + parent);
1631
+ var dup = DupNameGuard(parentNode, name, parent);
1632
+ if (dup != null) return dup;
1633
+ var o = InformationModel.MakeObject(name, FTOptix.NetLogic.ObjectTypes.NetLogic);
1634
+ parentNode.Add(o);
1635
+ return "{\"ok\":true,\"created_path\":\"" + JsonEscape(parent + "/" + name) +
1636
+ "\",\"type\":\"NetLogic\",\"node_class\":\"" + o.NodeClass +
1637
+ "\",\"bound_class\":\"" + JsonEscape(name) +
1638
+ "\",\"note\":\"class must exist in NetSolution and match name exactly; rebuild/run to instantiate\"" +
1639
+ ",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
1640
+ }
1641
+ catch (Exception ex)
1642
+ { return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}"; }
1643
+ }
1644
+
1645
+ // POST /bridge/model/netlogic-method?path=<NetLogic>&name=<ExportMethod>
1646
+ // Adds only the design-model UAMethod declaration used by event selectors.
1647
+ // The Arya connector verifies the exact matching [ExportMethod] source first.
1648
+ // This route never executes the method.
1649
+ private string DeclareNetLogicMethodInline(string firstLine)
1650
+ {
1651
+ string path = QueryParam(firstLine, "path");
1652
+ string name = QueryParam(firstLine, "name");
1653
+ if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(name))
1654
+ return ErrorJson("bad_query", "required query params: path, name");
1655
+ try
1656
+ {
1657
+ var node = ResolveNode(path);
1658
+ var netLogic = node as IUAObject;
1659
+ if (netLogic == null || !netLogic.IsInstanceOf(FTOptix.NetLogic.ObjectTypes.NetLogic))
1660
+ return ErrorJson("not_netlogic", "path is not a NetLogic object: " + path);
1661
+ foreach (var existing in node.Children)
1662
+ {
1663
+ if (existing.BrowseName != name) continue;
1664
+ if (existing.NodeClass != NodeClass.Method)
1665
+ return ErrorJson("name_exists", "a non-method node named '" + name +
1666
+ "' already exists under '" + path + "'");
1667
+ return "{\"ok\":true,\"created_path\":\"" + JsonEscape(path + "/" + name) +
1668
+ "\",\"type\":\"UAMethod\",\"node_class\":\"" + existing.NodeClass +
1669
+ "\",\"declared_only\":true,\"executed\":false,\"existing\":true}";
1670
+ }
1671
+ var method = InformationModel.Make<UAMethod>(name);
1672
+ node.Add(method);
1673
+ return "{\"ok\":true,\"created_path\":\"" + JsonEscape(path + "/" + name) +
1674
+ "\",\"type\":\"UAMethod\",\"node_class\":\"" + method.NodeClass +
1675
+ "\",\"declared_only\":true,\"executed\":false,\"existing\":false}";
1676
+ }
1677
+ catch (Exception ex)
1678
+ { return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}"; }
1679
+ }
1680
+
1681
+ // POST /bridge/model/type?name=<n>&parent=<path>[&base=<catalog name | type path>]
1682
+ // Creates an ObjectType (a reusable template). base resolves against the
1683
+ // builtin UI catalog first (RowLayout, Button, ...), then as a project path
1684
+ // to another ObjectType (subtyping a custom type); empty = bare
1685
+ // BaseObjectType-derived (model-side structured types, cheatsheet
1686
+ // "NewMotorType"). Children are then authored INTO the type with the
1687
+ // normal tools - proven by MainWindow (a WindowType) taking children today.
1688
+ // NOTE: no promote-by-location magic - Studio auto-types a widget dropped
1689
+ // at the Templates root; the bridge only ever does what the call says.
1690
+ private string CreateTypeInline(string firstLine)
1691
+ {
1692
+ string name = QueryParam(firstLine, "name");
1693
+ string parent = QueryParam(firstLine, "parent");
1694
+ string baseName = QueryParam(firstLine, "base");
1695
+ if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(parent))
1696
+ return ErrorJson("bad_query", "required query params: name, parent (+ optional base=<catalog type or type path>)");
1697
+ try
1698
+ {
1699
+ var parentNode = ResolveNode(parent);
1700
+ if (parentNode == null)
1701
+ return ErrorJson("node_not_found",
1702
+ "no parent node at: " + parent + " - create it first (/bridge/model/folder)");
1703
+ var dup = DupNameGuard(parentNode, name, parent);
1704
+ if (dup != null) return dup;
1705
+ IUANode newType;
1706
+ string baseLabel;
1707
+ if (string.IsNullOrEmpty(baseName))
1708
+ {
1709
+ newType = InformationModel.MakeObjectType(name);
1710
+ baseLabel = "BaseObjectType";
1711
+ }
1712
+ else
1713
+ {
1714
+ NodeId baseId = null;
1715
+ var typeField = typeof(FTOptix.UI.ObjectTypes)
1716
+ .GetField(baseName, BindingFlags.Public | BindingFlags.Static);
1717
+ if (typeField != null && typeField.GetValue(null) is NodeId nid)
1718
+ baseId = nid;
1719
+ if (baseId == null)
1720
+ {
1721
+ var baseNode = ResolveNode(baseName);
1722
+ if (baseNode != null && baseNode.NodeClass == NodeClass.ObjectType)
1723
+ baseId = baseNode.NodeId;
1724
+ }
1725
+ if (baseId == null)
1726
+ return ErrorJson("type_not_found",
1727
+ "base '" + baseName + "' is neither a builtin UI type " +
1728
+ "(optix_list_ui_types) nor a path to a project ObjectType");
1729
+ newType = InformationModel.MakeObjectType(name, baseId);
1730
+ baseLabel = baseName;
1731
+ }
1732
+ parentNode.Add(newType);
1733
+ return "{\"ok\":true,\"created_path\":\"" + JsonEscape(parent + "/" + name) +
1734
+ "\",\"base\":\"" + JsonEscape(baseLabel) +
1735
+ "\",\"node_class\":\"" + newType.NodeClass +
1736
+ "\",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
1737
+ }
1738
+ catch (Exception ex)
1739
+ { return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}"; }
1740
+ }
1741
+
1742
+ // POST /bridge/node/convert-to-type?path=<instance>&type_name=<n>&types_folder=<path>[&replace=true]
1743
+ // Studio's right-click "Convert to Type" has no public API (cheatsheet lists
1744
+ // it as UI-only), so this reproduces it by RE-AUTHORING: new ObjectType
1745
+ // subtyping the instance's own type, then a recursive COPY of the subtree
1746
+ // into it (fresh MakeObject/MakeVariable born in the type + raw value copy
1747
+ // + re-created DynamicLinks). NEVER move the live children: re-parenting
1748
+ // instance children into a type (Children.Remove + type.Add) left the model
1749
+ // in a state whose traversal ACCESS-VIOLATED Studio (confirmed live
1750
+ // 2026-07-17 - the describe after a move-based convert killed the process),
1751
+ // and the instantiate step silently hollowed the type. Born-in-type
1752
+ // authoring is the mechanism the plan-ahead create_type path proved safe
1753
+ // and propagation-correct. Constructs the copy can't reproduce (converters,
1754
+ // exotic child classes) are SKIPPED and listed in the response - honest
1755
+ // partial coverage instead of a half-copied template.
1756
+ private string ConvertToTypeInline(string firstLine)
1757
+ {
1758
+ string path = QueryParam(firstLine, "path");
1759
+ string typeName = QueryParam(firstLine, "type_name");
1760
+ string typesFolder = QueryParam(firstLine, "types_folder");
1761
+ bool replace = (QueryParam(firstLine, "replace") ?? "true") != "false";
1762
+ if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(typeName) || string.IsNullOrEmpty(typesFolder))
1763
+ return ErrorJson("bad_query", "required query params: path, type_name, types_folder (+ optional replace=false)");
1764
+ var steps = new StringBuilder();
1765
+ try
1766
+ {
1767
+ var node = ResolveNode(path);
1768
+ if (node == null)
1769
+ return ErrorJson("node_not_found", "no node at: " + path);
1770
+ if (node.NodeClass == NodeClass.ObjectType)
1771
+ return ErrorJson("already_a_type", path + " is already an ObjectType");
1772
+ if (!(node is IUAObject))
1773
+ return ErrorJson("not_an_object", path + " is " + node.NodeClass + ", not an Object instance");
1774
+ var folderNode = ResolveNode(typesFolder);
1775
+ if (folderNode == null)
1776
+ return ErrorJson("folder_not_found",
1777
+ "no types folder at: " + typesFolder + " - create it first (/bridge/model/folder)");
1778
+ var dup = DupNameGuard(folderNode, typeName, typesFolder);
1779
+ if (dup != null) return dup;
1780
+
1781
+ // Supertype = the instance's own ObjectType (RowLayout, ...) so the
1782
+ // new type keeps its rendering/behavior, mirroring Studio's refactor.
1783
+ NodeId superId = null;
1784
+ var uo = node as UAObject;
1785
+ if (uo != null && uo.ObjectType != null) superId = uo.ObjectType.NodeId;
1786
+ var newType = superId != null
1787
+ ? InformationModel.MakeObjectType(typeName, superId)
1788
+ : InformationModel.MakeObjectType(typeName);
1789
+ folderNode.Add(newType);
1790
+ steps.Append("\"create_type\"");
1791
+
1792
+ int copied = 0;
1793
+ var skipped = new StringBuilder();
1794
+ var fixups = new List<LinkFixup>();
1795
+ string cerr = CopySubtreeInto(node, newType, ref copied, skipped, fixups, 0);
1796
+ if (cerr != null)
1797
+ {
1798
+ // Copy failed part-way: remove the half-built type (a fresh,
1799
+ // never-instantiated ObjectType - safe to delete) and report.
1800
+ newType.Delete();
1801
+ return ErrorJson("copy_failed", cerr + " - nothing was changed (half-built type removed)");
1802
+ }
1803
+ ApplyLinkFixups(node, newType, fixups, skipped);
1804
+ steps.Append(",\"copy_subtree\"");
1805
+
1806
+ string instancePath = null;
1807
+ if (replace)
1808
+ {
1809
+ var owner = node.Owner;
1810
+ var name = node.BrowseName;
1811
+ var ownerPath = NodePathOf(owner);
1812
+ node.Delete();
1813
+ steps.Append(",\"delete_original\"");
1814
+ var inst = InformationModel.MakeObject(name, newType.NodeId);
1815
+ owner.Add(inst);
1816
+ steps.Append(",\"instantiate\"");
1817
+ instancePath = string.IsNullOrEmpty(ownerPath) ? name : ownerPath + "/" + name;
1818
+ }
1819
+
1820
+ // Link audit on the copied subtree: recreated links resolve by
1821
+ // construction, but verify and report anyway (trust nothing).
1822
+ int absOk = 0, relUnverified = 0;
1823
+ var broken = new StringBuilder();
1824
+ AuditLinks(newType, ref absOk, ref relUnverified, broken);
1825
+
1826
+ return "{\"ok\":true,\"type_path\":\"" + JsonEscape(typesFolder + "/" + typeName) +
1827
+ "\",\"copied_nodes\":" + copied +
1828
+ ",\"skipped\":[" + skipped + "]" +
1829
+ ",\"replaced\":" + (replace ? "true" : "false") +
1830
+ (instancePath != null ? ",\"instance_path\":\"" + JsonEscape(instancePath) + "\"" : "") +
1831
+ ",\"links_verified\":" + absOk +
1832
+ ",\"relative_links_unverified\":" + relUnverified +
1833
+ ",\"broken_links\":[" + broken + "]" +
1834
+ ",\"steps\":[" + steps + "],\"mode\":\"inline\",\"thread\":\"http-bg\"}";
1835
+ }
1836
+ catch (Exception ex)
1837
+ {
1838
+ return "{\"ok\":false,\"steps\":[" + steps + "],\"error\":\"" +
1839
+ JsonEscape(ExcMsg(ex)) +
1840
+ "\",\"nudge\":\"conversion stopped mid-way - inspect " + JsonEscape(path) +
1841
+ " and the types folder with describe_node before retrying\"}";
1842
+ }
1843
+ }
1844
+
1845
+ // POST /bridge/node/move?path=<node>&new_parent=<path>[&new_name=<n>]
1846
+ // Reparent a live instance. NEVER a node-model Remove+Add - re-parenting
1847
+ // live children corrupted the model and crashed Studio (crash class #3,
1848
+ // 2026-07-17). Instead: RE-AUTHOR a copy under the new parent (same
1849
+ // machinery as convert_to_type, proven safe), apply link fixups, delete
1850
+ // the original. Consequence reported honestly: the node's identity
1851
+ // (NodeId) CHANGES - outbound links are re-created, but INBOUND references
1852
+ // from elsewhere in the project to the moved subtree are NOT rewritten.
1853
+ private string MoveNodeInline(string firstLine)
1854
+ {
1855
+ string path = QueryParam(firstLine, "path");
1856
+ string newParent = QueryParam(firstLine, "new_parent");
1857
+ string newName = QueryParam(firstLine, "new_name");
1858
+ if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(newParent))
1859
+ return ErrorJson("bad_query", "required: path, new_parent (+ optional new_name)");
1860
+ var steps = new StringBuilder();
1861
+ try
1862
+ {
1863
+ var node = ResolveNode(path);
1864
+ if (node == null) return ErrorJson("node_not_found", "no node at: " + path);
1865
+ if (!(node is IUAObject) || node.NodeClass != NodeClass.Object)
1866
+ return ErrorJson("not_an_object",
1867
+ path + " is " + node.NodeClass + " - move handles Object instances" +
1868
+ " (for a variable, recreate it; for a type, it has no layout position)");
1869
+ var parentNode = ResolveNode(newParent);
1870
+ if (parentNode == null || !(parentNode is IUAObject || parentNode is IUAObjectType))
1871
+ return ErrorJson("node_not_found", "no object or type at new_parent: " + newParent);
1872
+ var nodePath = NodePathOf(node);
1873
+ var parentPath = NodePathOf(parentNode);
1874
+ if (!string.IsNullOrEmpty(nodePath) &&
1875
+ (parentPath == nodePath || parentPath.StartsWith(nodePath + "/")))
1876
+ return ErrorJson("move_into_self",
1877
+ "new_parent " + newParent + " is inside the subtree being moved");
1878
+ var name = string.IsNullOrEmpty(newName) ? node.BrowseName : newName;
1879
+ var dup = DupNameGuard(parentNode, name, newParent);
1880
+ if (dup != null) return dup;
1881
+ var srcType = (node as UAObject)?.ObjectType;
1882
+ if (srcType == null)
1883
+ return ErrorJson("not_an_object", path + " has no resolvable ObjectType");
1884
+
1885
+ var newNode = InformationModel.MakeObject(name, srcType.NodeId);
1886
+ parentNode.Add(newNode);
1887
+ steps.Append("\"create_copy\"");
1888
+ int copied = 0;
1889
+ var skipped = new StringBuilder();
1890
+ var fixups = new List<LinkFixup>();
1891
+ string cerr = CopySubtreeInto(node, newNode, ref copied, skipped, fixups, 0);
1892
+ if (cerr != null)
1893
+ {
1894
+ newNode.Delete();
1895
+ return ErrorJson("copy_failed", cerr + " - nothing was changed (partial copy removed)");
1896
+ }
1897
+ ApplyLinkFixups(node, newNode, fixups, skipped);
1898
+ steps.Append(",\"copy_subtree\"");
1899
+
1900
+ int absOk = 0, relUnverified = 0;
1901
+ var broken = new StringBuilder();
1902
+ AuditLinks(newNode, ref absOk, ref relUnverified, broken);
1903
+
1904
+ node.Delete();
1905
+ steps.Append(",\"delete_original\"");
1906
+
1907
+ return "{\"ok\":true,\"from\":\"" + JsonEscape(path) +
1908
+ "\",\"to\":\"" + JsonEscape(newParent + "/" + name) +
1909
+ "\",\"copied_nodes\":" + copied +
1910
+ ",\"skipped\":[" + skipped + "]" +
1911
+ ",\"links_verified\":" + absOk +
1912
+ ",\"relative_links_unverified\":" + relUnverified +
1913
+ ",\"broken_links\":[" + broken + "]" +
1914
+ ",\"steps\":[" + steps + "]" +
1915
+ ",\"note\":\"the moved node has a NEW NodeId - inbound references" +
1916
+ " from elsewhere to the old subtree are not rewritten\"" +
1917
+ ",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
1918
+ }
1919
+ catch (Exception ex)
1920
+ {
1921
+ return "{\"ok\":false,\"steps\":[" + steps + "],\"error\":\"" +
1922
+ JsonEscape(ExcMsg(ex)) +
1923
+ "\",\"nudge\":\"move stopped mid-way - inspect " + JsonEscape(path) +
1924
+ " and " + JsonEscape(newParent) + " with describe_node before retrying\"}";
1925
+ }
1926
+ }
1927
+
1928
+ // A DynamicLink found during a subtree copy, applied AFTER the whole copy
1929
+ // completes: an intra-subtree link's target may be a sibling copied LATER,
1930
+ // and pointing the copy at the ORIGINAL node breaks the moment the
1931
+ // original is deleted (found designing move; latent in convert too).
1932
+ private class LinkFixup
1933
+ {
1934
+ public IUAVariable DstVar;
1935
+ public string Raw;
1936
+ public string SrcVarPath;
1937
+ }
1938
+
1939
+ // Recursive re-author copy: for each Object child, a fresh MakeObject of
1940
+ // the SAME ObjectType added to dst (born-in-place); for each Variable
1941
+ // child, a same-name variable on dst with the raw UAValue copied (no
1942
+ // coercion - same datatype by construction). DynamicLinks are RECORDED
1943
+ // into `fixups` for ApplyLinkFixups, not applied inline. Returns null on
1944
+ // success, error string to abort. Unsupported constructs go into `skipped`
1945
+ // (json fragments), not half-copied.
1946
+ private string CopySubtreeInto(IUANode src, IUANode dst, ref int copied,
1947
+ StringBuilder skipped, List<LinkFixup> fixups, int depth)
1948
+ {
1949
+ if (depth > 12) return "subtree deeper than 12 levels at " + NodePathOf(src);
1950
+ foreach (var c in src.Children)
1951
+ {
1952
+ if (c is IUAVariable sv)
1953
+ {
1954
+ var cn = c.GetType().Name;
1955
+ if (cn == "DynamicLink") continue; // handled via the owner var's fixup
1956
+ IUAVariable nv = null;
1957
+ if (dst is IUAObject dObj) nv = dObj.GetOrCreateVariable(c.BrowseName);
1958
+ else if (dst is IUAObjectType dTyp) nv = dTyp.GetOrCreateVariable(c.BrowseName);
1959
+ if (nv == null)
1960
+ {
1961
+ var mv = InformationModel.MakeVariable(c.BrowseName, sv.DataType);
1962
+ dst.Add(mv);
1963
+ nv = mv;
1964
+ }
1965
+ try { if (sv.Value != null && !IsArrayVariable(sv)) nv.Value = sv.Value; }
1966
+ catch (Exception ex)
1967
+ {
1968
+ if (skipped.Length > 0) skipped.Append(",");
1969
+ skipped.Append("\"value of " + JsonEscape(c.BrowseName) + ": " + JsonEscape(ex.Message) + "\"");
1970
+ }
1971
+ foreach (var lc in sv.Children)
1972
+ {
1973
+ if (lc.GetType().Name == "DynamicLink" && lc is IUAVariable lv && lv.Value != null)
1974
+ {
1975
+ var raw = lv.Value.Value as string;
1976
+ if (!string.IsNullOrEmpty(raw))
1977
+ fixups.Add(new LinkFixup { DstVar = nv, Raw = raw, SrcVarPath = NodePathOf(sv) });
1978
+ }
1979
+ else if (lc.GetType().Name != "DynamicLink")
1980
+ {
1981
+ if (skipped.Length > 0) skipped.Append(",");
1982
+ skipped.Append("\"" + JsonEscape(sv.BrowseName + "/" + lc.BrowseName) +
1983
+ " (" + JsonEscape(lc.GetType().Name) + "): not copied (converter/attachment)\"");
1984
+ }
1985
+ }
1986
+ copied++;
1987
+ }
1988
+ else if (c is IUAObject so)
1989
+ {
1990
+ var sot = (so as UAObject)?.ObjectType;
1991
+ if (sot == null)
1992
+ {
1993
+ if (skipped.Length > 0) skipped.Append(",");
1994
+ skipped.Append("\"" + JsonEscape(c.BrowseName) + ": no resolvable ObjectType\"");
1995
+ continue;
1996
+ }
1997
+ var no = InformationModel.MakeObject(c.BrowseName, sot.NodeId);
1998
+ dst.Add(no);
1999
+ copied++;
2000
+ var r = CopySubtreeInto(c, no, ref copied, skipped, fixups, depth + 1);
2001
+ if (r != null) return r;
2002
+ }
2003
+ else
2004
+ {
2005
+ if (skipped.Length > 0) skipped.Append(",");
2006
+ skipped.Append("\"" + JsonEscape(c.BrowseName) + " (" + c.NodeClass + "): unsupported node class\"");
2007
+ }
2008
+ }
2009
+ return null;
2010
+ }
2011
+
2012
+ // Apply recorded DynamicLinks after a completed subtree copy.
2013
+ // - RELATIVE ("../..") and brace-form ("{Alias}/...") raws are reproduced
2014
+ // VERBATIM (SetDynamicLink(null) + raw write): the copy occupies the same
2015
+ // relative position, so the same literal stays correct - this is exactly
2016
+ // why Studio stores template links relative.
2017
+ // - ABSOLUTE ("/Objects/<proj>/...") raws are resolved: a target INSIDE the
2018
+ // source subtree is REMAPPED to its counterpart in the destination (the
2019
+ // original may be about to be deleted); an external target is linked
2020
+ // directly. Unresolvable -> skipped, never guessed.
2021
+ private void ApplyLinkFixups(IUANode srcRoot, IUANode dstRoot,
2022
+ List<LinkFixup> fixups, StringBuilder skipped)
2023
+ {
2024
+ string srcRootPath = NodePathOf(srcRoot);
2025
+ string absPrefix = "/Objects/" + Project.Current.BrowseName + "/";
2026
+ foreach (var f in fixups)
2027
+ {
2028
+ try
2029
+ {
2030
+ if (!f.Raw.StartsWith(absPrefix))
2031
+ {
2032
+ // Relative / brace / attribute form: verbatim literal.
2033
+ f.DstVar.SetDynamicLink(null, DynamicLinkMode.Read);
2034
+ var dlv = f.DstVar.Refs.GetVariable(FTOptix.CoreBase.ReferenceTypes.HasDynamicLink);
2035
+ if (dlv != null) dlv.Value = f.Raw;
2036
+ else throw new Exception("link materialization returned null");
2037
+ continue;
2038
+ }
2039
+ var rel = f.Raw.Substring(absPrefix.Length);
2040
+ IUAVariable target;
2041
+ if (!string.IsNullOrEmpty(srcRootPath) &&
2042
+ (rel == srcRootPath || rel.StartsWith(srcRootPath + "/")))
2043
+ {
2044
+ // Intra-subtree: remap to the copy's counterpart.
2045
+ var inner = rel == srcRootPath ? "" : rel.Substring(srcRootPath.Length + 1);
2046
+ IUANode t = dstRoot;
2047
+ foreach (var seg in inner.Split('/'))
2048
+ {
2049
+ if (seg.Length == 0) continue;
2050
+ t = t?.Get(seg);
2051
+ }
2052
+ target = t as IUAVariable;
2053
+ }
2054
+ else
2055
+ {
2056
+ target = ResolveNode(rel) as IUAVariable;
2057
+ }
2058
+ if (target == null)
2059
+ throw new Exception("target not re-resolvable: " + f.Raw);
2060
+ f.DstVar.SetDynamicLink(target);
2061
+ }
2062
+ catch (Exception ex)
2063
+ {
2064
+ if (skipped.Length > 0) skipped.Append(",");
2065
+ skipped.Append("\"link from " + JsonEscape(f.SrcVarPath) + ": " + JsonEscape(ex.Message) + "\"");
2066
+ }
2067
+ }
2068
+ }
2069
+
2070
+ // Walk a subtree; verify project-absolute DynamicLinks still resolve, count
2071
+ // owner-relative ones (unverifiable without replicating Optix path semantics).
2072
+ private void AuditLinks(IUANode root, ref int absOk, ref int relUnverified, StringBuilder broken)
2073
+ {
2074
+ foreach (var c in root.Children)
2075
+ {
2076
+ if (c.GetType().Name == "DynamicLink" && c is IUAVariable lv && lv.Value != null)
2077
+ {
2078
+ var raw = lv.Value.Value as string;
2079
+ if (!string.IsNullOrEmpty(raw))
2080
+ {
2081
+ var absPrefix = "/Objects/" + Project.Current.BrowseName + "/";
2082
+ if (raw.StartsWith(absPrefix))
2083
+ {
2084
+ var rel = raw.Substring(absPrefix.Length);
2085
+ var at = rel.IndexOf('@');
2086
+ var target = ResolveNode(at >= 0 ? rel.Substring(0, at).TrimEnd('/') : rel);
2087
+ if (target != null) absOk++;
2088
+ else
2089
+ {
2090
+ if (broken.Length > 0) broken.Append(",");
2091
+ broken.Append("\"" + JsonEscape(NodePathOf(c) + " -> " + rel) + "\"");
2092
+ }
2093
+ }
2094
+ else if (raw.StartsWith(".")) relUnverified++;
2095
+ }
2096
+ }
2097
+ AuditLinks(c, ref absOk, ref relUnverified, broken);
2098
+ }
2099
+ }
2100
+
2101
+ // Placeholder-collection routing: parents whose schema
2102
+ // declares a PlaceholderChildNodeCollection property (NavigationPanel.Panels,
2103
+ // DataGrid.Columns, ListView.TypeSelectors, Trend/XYChart.Pens, gauges'
2104
+ // WarningZones) need children placed INSIDE the named collection, the way
2105
+ // Studio's own drag-and-drop does - a flat Children.Add "succeeds" but the
2106
+ // emulator fails on it (the NavigationPanel.Panels incident). Reflection-
2107
+ // driven, no hardcoded type table: a property routes when its CLR type is
2108
+ // PlaceholderChildNodeCollection`1 AND its generic element type accepts the
2109
+ // widget being created. Read-only variant (PlaceholderReadOnlyChildNode-
2110
+ // Collection`1, e.g. Trend.TimeRanges) is runtime-managed: never a target.
2111
+ private const string PlaceholderCollPrefix = "PlaceholderChildNodeCollection";
2112
+ private const string PlaceholderRoCollPrefix = "PlaceholderReadOnlyChildNodeCollection";
2113
+
2114
+ private static bool IsPlaceholderColl(Type pt)
2115
+ { return pt.Name.StartsWith(PlaceholderCollPrefix); }
2116
+
2117
+ private static bool IsPlaceholderRoColl(Type pt)
2118
+ { return pt.Name.StartsWith(PlaceholderRoCollPrefix); }
2119
+
2120
+ private static Type PlaceholderElementType(Type pt)
2121
+ {
2122
+ return pt.IsGenericType && pt.GetGenericArguments().Length == 1
2123
+ ? pt.GetGenericArguments()[0] : null;
2124
+ }
2125
+
2126
+ // Collection properties on `parent` whose element type accepts `childClr`.
2127
+ // readOnly selects the runtime-managed variant (for the loud rejection).
2128
+ private static List<string> MatchingPlaceholderColls(
2129
+ IUANode parent, Type childClr, bool readOnly)
2130
+ {
2131
+ var hits = new List<string>();
2132
+ if (parent == null || childClr == null) return hits;
2133
+ foreach (var pi in parent.GetType().GetProperties(
2134
+ BindingFlags.Public | BindingFlags.Instance))
2135
+ {
2136
+ bool ro = IsPlaceholderRoColl(pi.PropertyType);
2137
+ if (readOnly ? !ro : (ro || !IsPlaceholderColl(pi.PropertyType))) continue;
2138
+ var elem = PlaceholderElementType(pi.PropertyType);
2139
+ if (elem != null && elem.IsAssignableFrom(childClr)) hits.Add(pi.Name);
2140
+ }
2141
+ return hits;
2142
+ }
2143
+
2144
+ // POST /bridge/ui/widget?name=X&screen=UI/MainWindow&type=Label
2145
+ // Creates a UI object of a builtin type and adds it to a screen, INLINE on
2146
+ // the HTTP thread. The more thread-sensitive write (presentation engine).
2147
+ private string WriteWidgetInline(string firstLine)
2148
+ {
2149
+ string name = QueryParam(firstLine, "name");
2150
+ string screen = QueryParam(firstLine, "screen") ?? "UI/MainWindow";
2151
+ string typeName = QueryParam(firstLine, "type") ?? "Label";
2152
+ if (string.IsNullOrEmpty(name))
2153
+ return ErrorJson("bad_query", "missing required query param: name");
2154
+ try
2155
+ {
2156
+ var typeField = typeof(FTOptix.UI.ObjectTypes)
2157
+ .GetField(typeName, BindingFlags.Public | BindingFlags.Static);
2158
+ if (typeField == null || !(typeField.GetValue(null) is NodeId typeId))
2159
+ return ErrorJson("type_not_found", "no builtin UI type: " + typeName);
2160
+ // The ObjectTypes catalog also carries the ABSTRACT layout bases in the
2161
+ // Item -> Container -> Panel chain. `Item`/`Container` are not concrete
2162
+ // renderable widgets: a bare instance is "not a UI object type" to the
2163
+ // WebPresentationEngine and CRASHES the render tree, killing every
2164
+ // sibling after it (found live 2026-07-25 — an agent picked "Container"
2165
+ // as a layout widget and lost the screen). Refuse loud and redirect.
2166
+ // (A full reflect-the-runtime-proxy renderability filter is a follow-up;
2167
+ // these two are the only bases an author realistically mistakes for a
2168
+ // widget.)
2169
+ if (typeName == "Item" || typeName == "Container")
2170
+ return ErrorJson("not_renderable",
2171
+ "'" + typeName + "' is an abstract layout base, not a renderable "
2172
+ + "widget — a bare instance crashes the render tree. Use 'Panel' "
2173
+ + "(invisible layout container; add a Rectangle child for a "
2174
+ + "background) or 'Rectangle' (a filled/bordered box) instead.");
2175
+ var screenNode = ResolveNode(screen);
2176
+ if (screenNode == null)
2177
+ return ErrorJson("node_not_found", "no screen at: " + screen);
2178
+ // Screens must be ObjectTypes (a SUBTYPE of Screen) to load at runtime.
2179
+ // Studio's right-click "Add Screen" makes a TYPE; a MakeObject makes an
2180
+ // INSTANCE that previews in the designer but the runtime can't instantiate
2181
+ // as a loadable panel. typeinfo-confirmed: right-click Screen1 =
2182
+ // ObjectType/ScreenType inheriting Screen->Panel->Container->Item; a bridge
2183
+ // MakeObject was Object/Screen (a bare instance). MakeObjectType<Screen>
2184
+ // reproduces the right-click structure. Child widgets then add to the TYPE.
2185
+ if (typeName == "Screen")
2186
+ {
2187
+ var st = InformationModel.MakeObjectType<FTOptix.UI.ScreenType>(name);
2188
+ screenNode.Children.Add(st);
2189
+ return "{\"ok\":true,\"created_path\":\"" + JsonEscape(screen + "/" + name) +
2190
+ "\",\"type\":\"Screen\",\"kind\":\"objecttype\",\"node_class\":\"" +
2191
+ st.NodeClass + "\",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
2192
+ }
2193
+ // Duplicate-name guard: Optix happily creates same-name siblings,
2194
+ // which are then impossible to address (or delete) unambiguously
2195
+ // by path. Studio's own UI auto-suffixes; the bridge refuses loud.
2196
+ foreach (var existing in screenNode.Children)
2197
+ {
2198
+ if (existing.BrowseName == name)
2199
+ return ErrorJson("name_exists",
2200
+ "a node named '" + name + "' already exists under '" +
2201
+ screen + "' - pick a different name or delete it first");
2202
+ }
2203
+ // Explicit collection-path targeting stays authoritative - but a
2204
+ // READ-ONLY collection is rejected loud, never silently accepted.
2205
+ // The resolved NODE's proxy type is not the collection type (that's
2206
+ // the parent's declared PROPERTY type),
2207
+ // so the check goes through the owner's property of the same name.
2208
+ var ownerNode = screenNode.Owner;
2209
+ if (ownerNode != null)
2210
+ {
2211
+ var ownerProp = ownerNode.GetType().GetProperty(
2212
+ screenNode.BrowseName, BindingFlags.Public | BindingFlags.Instance);
2213
+ if (ownerProp != null && IsPlaceholderRoColl(ownerProp.PropertyType))
2214
+ return ErrorJson("read_only_collection",
2215
+ "collection at '" + screen + "' is runtime-managed (read-only) - " +
2216
+ "children cannot be authored into it");
2217
+ }
2218
+ // Placement decision BEFORE creating the widget (no orphan node on
2219
+ // a refusal). 0 matches -> today's flat add; 1 -> auto-route into
2220
+ // the collection; >1 -> refuse, caller must pass the explicit path.
2221
+ var childClr = ResolveWidgetClrType(typeName);
2222
+ var routes = MatchingPlaceholderColls(screenNode, childClr, readOnly: false);
2223
+ if (routes.Count > 1)
2224
+ return ErrorJson("ambiguous_container",
2225
+ "type '" + typeName + "' fits multiple collections on '" + screen +
2226
+ "': " + string.Join(", ", routes) +
2227
+ " - pass the collection sub-path explicitly (e.g. " +
2228
+ screen + "/" + routes[0] + ")");
2229
+ if (routes.Count == 0)
2230
+ {
2231
+ var roHits = MatchingPlaceholderColls(screenNode, childClr, readOnly: true);
2232
+ if (roHits.Count > 0)
2233
+ return ErrorJson("read_only_collection",
2234
+ "type '" + typeName + "' only fits runtime-managed (read-only) " +
2235
+ "collection(s) on '" + screen + "': " + string.Join(", ", roHits) +
2236
+ " - these cannot be authored into");
2237
+ }
2238
+ var widget = InformationModel.MakeObject(name, typeId);
2239
+ if (routes.Count == 1)
2240
+ {
2241
+ var collNode = ResolveNode(screen + "/" + routes[0]);
2242
+ if (collNode == null)
2243
+ return ErrorJson("node_not_found",
2244
+ "collection '" + routes[0] + "' declared by the type but not " +
2245
+ "resolvable at: " + screen + "/" + routes[0]);
2246
+ collNode.Children.Add(widget);
2247
+ return "{\"ok\":true,\"created_path\":\"" +
2248
+ JsonEscape(screen + "/" + routes[0] + "/" + name) +
2249
+ "\",\"type\":\"" + JsonEscape(typeName) +
2250
+ "\",\"routed_into\":\"" + JsonEscape(routes[0]) +
2251
+ "\",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
2252
+ }
2253
+ screenNode.Children.Add(widget);
2254
+ return "{\"ok\":true,\"created_path\":\"" + JsonEscape(screen + "/" + name) +
2255
+ "\",\"type\":\"" + JsonEscape(typeName) +
2256
+ "\",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
2257
+ }
2258
+ catch (Exception ex)
2259
+ {
2260
+ return "{\"ok\":false,\"mode\":\"inline\",\"error\":\"" +
2261
+ JsonEscape(ExcMsg(ex)) + "\"}";
2262
+ }
2263
+ }
2264
+
2265
+ // GENERIC property set: POST /bridge/node/property?path=<node>&name=<prop>&value=<v>
2266
+ // Resolves the property variable and assigns .Value coerced by its DataType.
2267
+ // One endpoint for ALL properties (Text/Color/Width/Model-var/...), no
2268
+ // per-component code. Coercion mirrors the cheatsheet's uniform .Value model.
2269
+ // POST /bridge/node/displayname?path=X&value=Y[&locale=en-US]
2270
+ // Set a node's DisplayName ATTRIBUTE (LocalizedText). This is a direct
2271
+ // node-attribute assignment - NOT the variable-materialization path that
2272
+ // crashed Studio (that fabricated an orphan UA variable named "DisplayName";
2273
+ // this writes the real attribute the proxy setter fronts). BrowseName stays
2274
+ // rename-only: it is the node's IDENTITY (paths, links, bindings key on it),
2275
+ // so it goes through move/new_name re-authoring, never in-place mutation.
2276
+ private string SetDisplayNameInline(string firstLine)
2277
+ {
2278
+ string path = QueryParam(firstLine, "path");
2279
+ string raw = QueryParam(firstLine, "value");
2280
+ string locale = QueryParam(firstLine, "locale") ?? "en-US";
2281
+ if (string.IsNullOrEmpty(path) || raw == null)
2282
+ return ErrorJson("bad_query", "required query params: path, value");
2283
+ try
2284
+ {
2285
+ var node = ResolveNode(path);
2286
+ if (node == null)
2287
+ return ErrorJson("node_not_found", "no node at: " + path);
2288
+ string before = null;
2289
+ try { var d = node.DisplayName; if (d != null) before = d.Text; } catch { }
2290
+ node.DisplayName = new LocalizedText(raw, locale);
2291
+ return "{\"ok\":true,\"path\":\"" + JsonEscape(path) +
2292
+ "\",\"attribute\":\"DisplayName\"" +
2293
+ ",\"locale\":\"" + JsonEscape(locale) + "\"" +
2294
+ (before != null ? ",\"was\":\"" + JsonEscape(before) + "\"" : "") +
2295
+ ",\"value\":\"" + JsonEscape(raw) +
2296
+ "\",\"note\":\"browse_name (paths/links) is unchanged - Studio's " +
2297
+ "tree shows 'BrowseName (DisplayName)' when they differ\"" +
2298
+ ",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
2299
+ }
2300
+ catch (Exception ex)
2301
+ {
2302
+ return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}";
2303
+ }
2304
+ }
2305
+
2306
+ private string SetPropertyInline(string firstLine)
2307
+ {
2308
+ string path = QueryParam(firstLine, "path");
2309
+ string name = QueryParam(firstLine, "name");
2310
+ string raw = QueryParam(firstLine, "value") ?? "";
2311
+ if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(name))
2312
+ return ErrorJson("bad_query", "required query params: path, name");
2313
+ // DisplayName rides the set_property surface for caller convenience but
2314
+ // is handled by the attribute route - it must never reach the
2315
+ // variable-materialization path below.
2316
+ if (name == "DisplayName")
2317
+ return SetDisplayNameInline(firstLine);
2318
+ var fmtErr = FormatSpecifierError(name, raw);
2319
+ if (fmtErr != null) return ErrorJson("bad_value", fmtErr);
2320
+ try
2321
+ {
2322
+ var node = ResolveNode(path);
2323
+ if (node == null)
2324
+ return ErrorJson("node_not_found", "no node at: " + path);
2325
+
2326
+ // Set a VARIABLE's OWN value: `set Model/MyVar Value=...`. A model variable's
2327
+ // value is the node itself, not a child "Value" property, so GetVariable(name)
2328
+ // below would miss it. (Only fires when the resolved NODE is a variable - a
2329
+ // widget with a child "Value" prop like SpinBox is an IUAObject, unaffected.)
2330
+ if (name == "Value" && node is IUAVariable selfVar)
2331
+ {
2332
+ if (IsArrayVariable(selfVar))
2333
+ return ArrayWriteError(name, "variable " + path, DataTypeName(selfVar));
2334
+ var err = CoerceAssign(selfVar, raw, firstLine);
2335
+ if (err != null) return ErrorJson("bad_value", err);
2336
+ return PropOkJson(path, name, DataTypeName(selfVar), "self", selfVar);
2337
+ }
2338
+
2339
+ var prop = node.GetVariable(name);
2340
+ if (prop != null)
2341
+ {
2342
+ // Already-materialized variable (model vars, or UI props set before).
2343
+ if (IsArrayVariable(prop))
2344
+ return ArrayWriteError(name, node.GetType().Name, DataTypeName(prop));
2345
+ var err = CoerceAssign(prop, raw, firstLine);
2346
+ if (err != null) return ErrorJson("bad_value", err);
2347
+ return PropOkJson(path, name, DataTypeName(prop), "variable", prop);
2348
+ }
2349
+
2350
+ // VALIDITY GATE (crash-safety). GetOrCreateVariable below happily
2351
+ // FABRICATES an orphan variable for a property the TYPE does not declare
2352
+ // (e.g. Panel.BorderThickness - Panels have no border). The renderer then
2353
+ // dereferences the orphan off-thread and access-violates, killing Studio
2354
+ // outright (0xC0000005 in coreclr, confirmed live). node.GetType()
2355
+ // is the generated Optix proxy and GetProperty is inheritance-aware, so a
2356
+ // Rectangle exposes BorderColor but a Panel does not. Reject an undeclared
2357
+ // property BEFORE materializing it, and hand back the valid set so the
2358
+ // caller (or describe_*) can self-correct instead of crashing.
2359
+ var gateErr = DeclaredPropertyGuard(node, name);
2360
+ if (gateErr != null) return gateErr;
2361
+
2362
+ // ARRAY GATE (crash-safety). Must fire BEFORE GetOrCreateVariable: the
2363
+ // NodeId[] AliasNodeArray crash (confirmed live 2026-07-16 - Studio
2364
+ // process terminated, connection reset mid-request, no managed
2365
+ // exception) fired at/after this point, so array-ness has to come from
2366
+ // the TYPE DECLARATION, not the never-yet-materialized variable.
2367
+ var arrErr = DeclaredArrayGuard(node, name);
2368
+ if (arrErr != null) return arrErr;
2369
+
2370
+ // FRESH-INSTANCE MATERIALIZATION (node-model, off-thread-safe).
2371
+ // GetVariable was null - a fresh MakeObject'd instance doesn't materialize
2372
+ // its inherited props. node.GetOrCreateVariable(name) creates the variable
2373
+ // FROM THE TYPE DECLARATION (built-in; ref RPC-Template/ProjectOptimizer.cs)
2374
+ // so it renders, using only node-model ops which ARE off-thread-safe. We do
2375
+ // NOT use the typed CLR setter (hard-crashes off-thread) nor DelayedTask
2376
+ // marshaling (HANGS at design time - Studio has no task pump; both confirmed
2377
+ // live). Safe here ONLY because the gate above proved the
2378
+ // property is type-declared.
2379
+ IUAVariable mvar = null;
2380
+ if (node is IUAObject asObject)
2381
+ mvar = asObject.GetOrCreateVariable(name);
2382
+ if (mvar == null)
2383
+ return ErrorJson("property_not_found",
2384
+ "node " + path + " has no variable or materializable property " + name);
2385
+ if (IsArrayVariable(mvar)) // backstop: declared-array gate above should have caught it
2386
+ return ArrayWriteError(name, node.GetType().Name, DataTypeName(mvar));
2387
+ var merr = CoerceAssign(mvar, raw, firstLine);
2388
+ if (merr != null) return ErrorJson("bad_value", merr);
2389
+ return PropOkJson(path, name, DataTypeName(mvar), "materialized", mvar);
2390
+ }
2391
+ catch (Exception ex)
2392
+ {
2393
+ return "{\"ok\":false,\"error\":\"" +
2394
+ JsonEscape(ExcMsg(ex)) + "\"}";
2395
+ }
2396
+ }
2397
+
2398
+ // ARRAY-WRITE GUARD (crash-safety). Array-typed UA variables (String[] like
2399
+ // GridLayout.Columns/Rows, NodeId[] like NavigationPanelItem.AliasNodeArray)
2400
+ // must never reach the scalar coercion/assign path: a scalar .Value assign
2401
+ // raises a catchable CoreException at best (String[], "array dimensions
2402
+ // mismatch") and has TERMINATED the Studio process outright at worst
2403
+ // (NodeId[] AliasNodeArray, confirmed live 2026-07-16 - connection reset
2404
+ // mid-request, no managed exception, all unsaved edits lost). Array-ness
2405
+ // lives in ArrayDimensions / the CLR proxy property type, NOT the DataType
2406
+ // name - a NodeId[] variable reports DataTypeName "NodeId" - so the
2407
+ // CoerceAssign datatype switch cannot see it and must be gated up front.
2408
+ private static bool IsArrayVariable(IUAVariable v)
2409
+ {
2410
+ try { var d = v.ArrayDimensions; return d != null && d.Length > 0; }
2411
+ catch { return false; }
2412
+ }
2413
+
2414
+ // null unless `name` is a declared CLR-array property on the node's generated
2415
+ // proxy type (the proxy exposes array UA props as CLR arrays, e.g. NodeId[]
2416
+ // AliasNodeArray), else the unsupported_array_write error. Call BEFORE
2417
+ // GetOrCreateVariable - array-ness must be established from the declaration,
2418
+ // without materializing (and without writing) anything on the live model.
2419
+ private string DeclaredArrayGuard(IUANode node, string name)
2420
+ {
2421
+ var pi = node.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)
2422
+ .FirstOrDefault(p => p.Name == name);
2423
+ if (pi == null || !pi.PropertyType.IsArray) return null;
2424
+ return ArrayWriteError(name, node.GetType().Name, pi.PropertyType.GetElementType().Name);
2425
+ }
2426
+
2427
+ private static string ArrayWriteError(string name, string owner, string elemType)
2428
+ {
2429
+ return "{\"error\":{\"code\":\"unsupported_array_write\",\"message\":\"" +
2430
+ JsonEscape("property '" + name + "' on " + owner + " is array-typed (" +
2431
+ elemType + "[]). Array writes aren't supported via set_property - " +
2432
+ "a scalar write to an array UA variable can crash Studio. " +
2433
+ "Author array-valued properties in Studio directly.") + "\"}}";
2434
+ }
2435
+
2436
+ // Coerce a raw string into a variable's value by the variable's own DataType, and
2437
+ // assign it. Returns null on success, or an error message. Single source for the
2438
+ // property-set coercion (was duplicated across two switch blocks).
2439
+ private string CoerceAssign(IUAVariable v, string raw, string firstLine)
2440
+ {
2441
+ string dt = DataTypeName(v);
2442
+ // Backstop for callers other than SetPropertyInline (which gates arrays
2443
+ // with the typed unsupported_array_write error before reaching here).
2444
+ if (IsArrayVariable(v))
2445
+ return "unsupported_array_write: property is array-typed (" + dt +
2446
+ "[]); scalar writes to array UA variables are not supported" +
2447
+ " (they can crash Studio)";
2448
+ switch (dt)
2449
+ {
2450
+ case "Boolean":
2451
+ v.Value = (raw == "true" || raw == "1" || raw == "True"); break;
2452
+ case "Int16": case "Int32": case "Int64":
2453
+ case "UInt16": case "UInt32": case "UInt64": case "Byte": case "SByte":
2454
+ {
2455
+ // TryParse (not Convert.ToInt32) so a non-numeric value returns a
2456
+ // clean bad_value instead of leaking a raw FormatException.
2457
+ long iv;
2458
+ if (!long.TryParse(raw, System.Globalization.NumberStyles.Integer,
2459
+ System.Globalization.CultureInfo.InvariantCulture, out iv))
2460
+ return "value must be an integer for " + dt + ": " + raw;
2461
+ v.Value = (int)iv; // (int) truncates rather than throwing on range
2462
+ }
2463
+ break;
2464
+ case "Float": case "Double": case "Size":
2465
+ {
2466
+ double dv;
2467
+ if (!double.TryParse(raw, System.Globalization.NumberStyles.Float,
2468
+ System.Globalization.CultureInfo.InvariantCulture, out dv))
2469
+ return "value must be a number for " + dt + ": " + raw;
2470
+ v.Value = dv;
2471
+ }
2472
+ break;
2473
+ case "LocalizedText":
2474
+ // LITERAL/ad-hoc text: new LocalizedText(text, locale) renders directly;
2475
+ // the (nsIndex, textId) form is a translation KEY (blank without a table
2476
+ // entry) - use /bridge/i18n/translation for keyed.
2477
+ v.Value = new LocalizedText(raw, QueryParam(firstLine, "locale") ?? "en-US"); break;
2478
+ case "String":
2479
+ v.Value = raw; break;
2480
+ case "NodeId":
2481
+ // A NodePointer / NodeId property (PanelLoader.Panel, StartWindow, ...).
2482
+ // The generic coercion can't string->NodeId ("Conversion to NodeId not
2483
+ // supported"), so resolve the value as a node PATH -> NodeId, mirroring
2484
+ // ensure_web_engine's StartWindow set. This is what lets a content loader
2485
+ // point at a screen (the nav "empty content" root cause).
2486
+ {
2487
+ var target = ResolveNode(raw);
2488
+ if (target == null)
2489
+ return "NodeId value must be a resolvable node path: " + raw;
2490
+ v.Value = target.NodeId;
2491
+ }
2492
+ break;
2493
+ case "Color":
2494
+ // Color is UInt32 ARGB. Accept "#RRGGBB" / "#AARRGGBB" hex or a decimal.
2495
+ // Without this it hit the enum-default path, where Convert.ToInt32
2496
+ // OVERFLOWS on any color with alpha (0xFF...... > Int32.Max) and falls to
2497
+ // the string->LocalizedText assertion ("!localeId.empty()") - so every
2498
+ // opaque color silently failed (found via live probing).
2499
+ {
2500
+ string s = raw.Trim();
2501
+ uint argb;
2502
+ if (s.StartsWith("#"))
2503
+ {
2504
+ string hex = s.Substring(1);
2505
+ if (hex.Length == 6) hex = "FF" + hex; // add opaque alpha
2506
+ if (!uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber,
2507
+ System.Globalization.CultureInfo.InvariantCulture, out argb))
2508
+ return "Color must be #RRGGBB / #AARRGGBB hex or a UInt32 decimal: " + raw;
2509
+ }
2510
+ else if (!uint.TryParse(s, out argb))
2511
+ {
2512
+ return "Color must be #RRGGBB / #AARRGGBB hex or a UInt32 decimal: " + raw;
2513
+ }
2514
+ v.Value = argb;
2515
+ }
2516
+ break;
2517
+ default:
2518
+ // Enum datatypes (HorizontalAlignment, ...) are Int32-backed; a bare-string
2519
+ // assign hits a LocalizedText coercion that asserts on an empty localeId
2520
+ // Coerce to the enum ordinal (int or friendly name),
2521
+ // else return a clean error (an INVALID enum value must NOT fall through to
2522
+ // the asserting string assign - confirmed live).
2523
+ {
2524
+ var e = SetEnumOrRaw(v, dt, raw);
2525
+ if (e != null) return e;
2526
+ }
2527
+ break;
2528
+ }
2529
+ return null;
2530
+ }
2531
+
2532
+ // ---- U16: POST /bridge/validate_ops -------------------------------------
2533
+ //
2534
+ // Dry-run an op BATCH and report what would fail, without touching the model.
2535
+ // Every check here reuses a guard the real write path already runs, so a
2536
+ // clean report means the same guards will pass on apply - the point is to
2537
+ // fail the batch BEFORE it half-applies, not to re-implement the rules.
2538
+ //
2539
+ // Three tiers:
2540
+ // 1 per-op validity - node resolves, property is declared, value coerces
2541
+ // 2 batch coherence - ops are checked against a HYPOTHETICAL model that
2542
+ // accumulates this batch's creates/deletes, so
2543
+ // "create X then set X.Prop" validates clean and the
2544
+ // reverse order does not
2545
+ // 3 lint - warnings only; `strict` promotes them to errors
2546
+ //
2547
+ // Body: {"ops":[{"op":"...", ...}], "strict":false}
2548
+ // Reply: {"ok":bool, "op_count":N, "errors":[{op_index,code,message,...}],
2549
+ // "warnings":[{op_index,code,message}]}
2550
+ private string ValidateOpsJson(string body)
2551
+ {
2552
+ var errors = new StringBuilder();
2553
+ var warnings = new StringBuilder();
2554
+ int errCount = 0, warnCount = 0, opCount = 0;
2555
+
2556
+ Action<int, string, string, string> addErr = (idx, code, msg, extra) =>
2557
+ {
2558
+ if (errCount++ > 0) errors.Append(",");
2559
+ errors.Append("{\"op_index\":" + idx + ",\"code\":\"" + JsonEscape(code) +
2560
+ "\",\"message\":\"" + JsonEscape(msg) + "\"" +
2561
+ (string.IsNullOrEmpty(extra) ? "" : "," + extra) + "}");
2562
+ };
2563
+ Action<int, string, string> addWarn = (idx, code, msg) =>
2564
+ {
2565
+ if (warnCount++ > 0) warnings.Append(",");
2566
+ warnings.Append("{\"op_index\":" + idx + ",\"code\":\"" + JsonEscape(code) +
2567
+ "\",\"message\":\"" + JsonEscape(msg) + "\"}");
2568
+ };
2569
+
2570
+ try
2571
+ {
2572
+ if (string.IsNullOrWhiteSpace(body))
2573
+ return ErrorJson("bad_body", "validate_ops requires a JSON body: {\"ops\":[...]}");
2574
+
2575
+ using (var doc = System.Text.Json.JsonDocument.Parse(body))
2576
+ {
2577
+ var root = doc.RootElement;
2578
+ System.Text.Json.JsonElement opsEl;
2579
+ if (!root.TryGetProperty("ops", out opsEl) ||
2580
+ opsEl.ValueKind != System.Text.Json.JsonValueKind.Array)
2581
+ return ErrorJson("bad_body", "body must carry an \"ops\" array");
2582
+
2583
+ bool strict = false;
2584
+ System.Text.Json.JsonElement strictEl;
2585
+ if (root.TryGetProperty("strict", out strictEl) &&
2586
+ strictEl.ValueKind == System.Text.Json.JsonValueKind.True) strict = true;
2587
+
2588
+ // Tier 2 state: the hypothetical model this batch would build.
2589
+ var created = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
2590
+ var deleted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
2591
+
2592
+ // PRE-PASS: every path this batch creates, regardless of position.
2593
+ // The sequential `created` set above is what decides validity (a
2594
+ // forward reference IS an error). This set exists only to make the
2595
+ // error ACTIONABLE: the reversed-order mistake is by definition a
2596
+ // forward reference, so at the point it is caught the sequential
2597
+ // set is still empty and could not name the culprit.
2598
+ var willCreate = new List<string>();
2599
+ foreach (var pre in opsEl.EnumerateArray())
2600
+ {
2601
+ string pv = JsonStr(pre, "op");
2602
+ if (pv == null || !pv.StartsWith("create", StringComparison.Ordinal)) continue;
2603
+ string pn = JsonStr(pre, "name");
2604
+ if (string.IsNullOrEmpty(pn)) continue;
2605
+ string pp = ParentKey(pre);
2606
+ willCreate.Add(string.IsNullOrEmpty(pp) ? pn : pp.TrimEnd('/') + "/" + pn);
2607
+ }
2608
+
2609
+ int idx = -1;
2610
+ foreach (var op in opsEl.EnumerateArray())
2611
+ {
2612
+ idx++; opCount++;
2613
+ string verb = JsonStr(op, "op");
2614
+ if (string.IsNullOrEmpty(verb))
2615
+ {
2616
+ addErr(idx, "missing_op", "op object has no \"op\" field", null);
2617
+ continue;
2618
+ }
2619
+ string path = JsonStr(op, "path");
2620
+ string name = JsonStr(op, "name");
2621
+ string typeName = TypeKey(op);
2622
+ string value = JsonStr(op, "value");
2623
+
2624
+ switch (verb)
2625
+ {
2626
+ case "set_property":
2627
+ case "bind":
2628
+ case "attach_expression":
2629
+ ValidateOnNode(idx, verb, path, name, value, created, deleted,
2630
+ willCreate, addErr, addWarn);
2631
+ break;
2632
+
2633
+ case "delete":
2634
+ {
2635
+ if (string.IsNullOrEmpty(path))
2636
+ { addErr(idx, "bad_op", verb + " requires \"path\"", null); break; }
2637
+ if (deleted.Contains(path))
2638
+ { addErr(idx, "already_deleted", "op deletes '" + path + "' twice in this batch", null); break; }
2639
+ if (ResolveNode(path) == null && !created.ContainsKey(path))
2640
+ addErr(idx, "unresolved_reference",
2641
+ "no node at '" + path + "'" + NearestHint(path, willCreate), null);
2642
+ deleted.Add(path);
2643
+ created.Remove(path);
2644
+ }
2645
+ break;
2646
+
2647
+ case "move":
2648
+ case "reorder":
2649
+ case "wire_event":
2650
+ {
2651
+ if (string.IsNullOrEmpty(path))
2652
+ { addErr(idx, "bad_op", verb + " requires \"path\"", null); break; }
2653
+ if (deleted.Contains(path))
2654
+ { addErr(idx, "modifies_deleted_node", verb + " targets '" + path + "', deleted earlier in this batch", null); break; }
2655
+ if (ResolveNode(path) == null && !created.ContainsKey(path))
2656
+ addErr(idx, "unresolved_reference",
2657
+ "no node at '" + path + "'" + NearestHint(path, willCreate), null);
2658
+ }
2659
+ break;
2660
+
2661
+ case "create_node":
2662
+ case "create_widget":
2663
+ case "create_variable":
2664
+ case "create_folder":
2665
+ case "create_object":
2666
+ case "create_type":
2667
+ case "create_alias":
2668
+ {
2669
+ if (string.IsNullOrEmpty(name))
2670
+ { addErr(idx, "bad_op", verb + " requires \"name\"", null); break; }
2671
+ string parentPath = ParentKey(op);
2672
+ string newPath = string.IsNullOrEmpty(parentPath)
2673
+ ? name : parentPath.TrimEnd('/') + "/" + name;
2674
+
2675
+ if (created.ContainsKey(newPath))
2676
+ { addErr(idx, "duplicate_create", "'" + newPath + "' is created twice in this batch", null); break; }
2677
+ if (!string.IsNullOrEmpty(parentPath))
2678
+ {
2679
+ if (deleted.Contains(parentPath))
2680
+ addErr(idx, "modifies_deleted_node",
2681
+ "parent '" + parentPath + "' is deleted earlier in this batch", null);
2682
+ else if (ResolveNode(parentPath) == null && !created.ContainsKey(parentPath))
2683
+ addErr(idx, "unresolved_parent",
2684
+ "no parent node at '" + parentPath + "'" + NearestHint(parentPath, willCreate), null);
2685
+ }
2686
+ // Credit an earlier delete in THIS batch: a
2687
+ // delete-then-recreate of the same path is the
2688
+ // documented "hypothetical model accumulates
2689
+ // creates AND deletes" contract, so it must not
2690
+ // warn already_exists (which becomes an ERROR
2691
+ // under strict). Only a create over a node the
2692
+ // batch has NOT deleted may collide.
2693
+ if (ResolveNode(newPath) != null && !deleted.Contains(newPath))
2694
+ addWarn(idx, "already_exists",
2695
+ "'" + newPath + "' already exists in the live model; the create may collide");
2696
+ created[newPath] = typeName ?? "";
2697
+ deleted.Remove(newPath);
2698
+ }
2699
+ break;
2700
+
2701
+ default:
2702
+ addWarn(idx, "unknown_op",
2703
+ "op '" + verb + "' is not validated by this bridge; it will be applied unchecked");
2704
+ break;
2705
+ }
2706
+ }
2707
+
2708
+ if (strict && warnCount > 0)
2709
+ {
2710
+ // strict: fold the warnings into errors so the batch refuses.
2711
+ if (errCount > 0) errors.Append(",");
2712
+ errors.Append(warnings);
2713
+ errCount += warnCount;
2714
+ }
2715
+
2716
+ bool ok = errCount == 0;
2717
+ return "{\"ok\":" + Bool(ok) + ",\"op_count\":" + opCount +
2718
+ ",\"strict\":" + Bool(strict) +
2719
+ ",\"errors\":[" + errors + "],\"warnings\":[" + warnings + "]}";
2720
+ }
2721
+ }
2722
+ catch (System.Text.Json.JsonException jx)
2723
+ {
2724
+ return ErrorJson("bad_json", "could not parse the ops body: " + jx.Message);
2725
+ }
2726
+ catch (Exception ex)
2727
+ {
2728
+ return ErrorJson("internal", ExcMsg(ex));
2729
+ }
2730
+ }
2731
+
2732
+ // set_property / bind / attach_expression share one shape: they target a
2733
+ // property `name` on an existing-or-hypothetical node `path`.
2734
+ private void ValidateOnNode(
2735
+ int idx, string verb, string path, string name, string value,
2736
+ Dictionary<string, string> created, HashSet<string> deleted,
2737
+ List<string> willCreate,
2738
+ Action<int, string, string, string> addErr,
2739
+ Action<int, string, string> addWarn)
2740
+ {
2741
+ if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(name))
2742
+ {
2743
+ addErr(idx, "bad_op", verb + " requires \"path\" and \"name\"", null);
2744
+ return;
2745
+ }
2746
+ // set_property DisplayName is the attribute route (SetDisplayNameInline),
2747
+ // valid on any node - it must not fall into the child-variable guards
2748
+ // below (which would refuse it as a node attribute). Deleted-node check
2749
+ // still applies; everything else about it needs no validation.
2750
+ if (verb == "set_property" && name == "DisplayName")
2751
+ {
2752
+ if (deleted.Contains(path))
2753
+ addErr(idx, "modifies_deleted_node",
2754
+ verb + " targets '" + path + "', deleted earlier in this batch", null);
2755
+ return;
2756
+ }
2757
+ var fmtErr = FormatSpecifierError(name, value);
2758
+ if (fmtErr != null) { addErr(idx, "bad_value", fmtErr, null); return; }
2759
+ if (deleted.Contains(path))
2760
+ {
2761
+ addErr(idx, "modifies_deleted_node",
2762
+ verb + " targets '" + path + "', deleted earlier in this batch", null);
2763
+ return;
2764
+ }
2765
+
2766
+ var node = ResolveNode(path);
2767
+ if (node == null)
2768
+ {
2769
+ string hypoType;
2770
+ if (!created.TryGetValue(path, out hypoType))
2771
+ {
2772
+ addErr(idx, "unresolved_reference",
2773
+ "no node at '" + path + "'" + NearestHint(path, willCreate), null);
2774
+ return;
2775
+ }
2776
+ // HYPOTHETICAL node: it does not exist yet, so the live-instance
2777
+ // guard cannot see it. Fall back to TYPE-level reflection off the
2778
+ // declared type - the same property set describe_type reports.
2779
+ if (string.IsNullOrEmpty(hypoType))
2780
+ {
2781
+ addWarn(idx, "unverifiable_property",
2782
+ "'" + path + "' is created earlier in this batch without a declared "
2783
+ + "type, so '" + name + "' cannot be checked until apply");
2784
+ return;
2785
+ }
2786
+ if (name == "Value" &&
2787
+ (hypoType == "FTOptix.DataLogger.VariableToLog" ||
2788
+ hypoType == "FTOptix.Core.NodePointer"))
2789
+ return;
2790
+ var clr = ResolveWidgetClrType(hypoType);
2791
+ if (clr == null)
2792
+ {
2793
+ addWarn(idx, "unverifiable_property",
2794
+ "type '" + hypoType + "' does not resolve to a CLR type; '" + name +
2795
+ "' cannot be checked until apply");
2796
+ return;
2797
+ }
2798
+ bool declared = clr.GetProperties(BindingFlags.Public | BindingFlags.Instance)
2799
+ .Where(IsLegendProp).Any(p => p.Name == name);
2800
+ if (!declared)
2801
+ {
2802
+ var valid = clr.GetProperties(BindingFlags.Public | BindingFlags.Instance)
2803
+ .Where(IsLegendProp).Select(p => p.Name)
2804
+ .GroupBy(x => x).Select(g => g.Key).OrderBy(x => x).ToList();
2805
+ var sugg = SuggestPropertyName(name, valid);
2806
+ var extra = new StringBuilder();
2807
+ extra.Append("\"valid_properties\":[");
2808
+ for (int i = 0; i < valid.Count; i++)
2809
+ {
2810
+ if (i > 0) extra.Append(",");
2811
+ extra.Append("\"" + JsonEscape(valid[i]) + "\"");
2812
+ }
2813
+ extra.Append("]");
2814
+ if (sugg != null)
2815
+ {
2816
+ extra.Append(",\"did_you_mean\":\"" + JsonEscape(sugg) + "\"");
2817
+ }
2818
+ addErr(idx, "unknown_property",
2819
+ hypoType + " has no settable property '" + name + "'" +
2820
+ (sugg != null ? " (did you mean " + sugg + "?)" : ""), extra.ToString());
2821
+ }
2822
+ return; // value coercion needs a live variable; deferred to apply
2823
+ }
2824
+
2825
+ // LIVE node: run the same guards the write path runs.
2826
+ if (name == "Value" && node is IUAVariable selfVar)
2827
+ {
2828
+ if (IsArrayVariable(selfVar))
2829
+ addErr(idx, "unsupported_array_write",
2830
+ "variable '" + path + "' is array-typed; scalar writes and bindings are not supported",
2831
+ null);
2832
+ else if (verb == "set_property" && value != null)
2833
+ {
2834
+ var selfBad = CoerceCheck(selfVar, value);
2835
+ if (selfBad != null) addErr(idx, "bad_value", selfBad, null);
2836
+ }
2837
+ return;
2838
+ }
2839
+ var gate = DeclaredPropertyGuard(node, name);
2840
+ if (gate != null)
2841
+ {
2842
+ // DeclaredPropertyGuard hands back a whole {"error":{...}} object.
2843
+ // Unwrap its inner fields so the report stays one flat shape.
2844
+ addErr(idx, "unknown_property",
2845
+ node.GetType().Name + " has no settable property '" + name + "'",
2846
+ "\"guard\":" + gate);
2847
+ return;
2848
+ }
2849
+ var arrGate = DeclaredArrayGuard(node, name);
2850
+ if (arrGate != null)
2851
+ {
2852
+ // Also a whole {"error":{...}} object - carry it structurally rather
2853
+ // than escaping JSON into the message field.
2854
+ addErr(idx, "unsupported_array_write",
2855
+ "property '" + name + "' on " + node.GetType().Name +
2856
+ " is array-typed; array writes are not supported via set_property",
2857
+ "\"guard\":" + arrGate);
2858
+ return;
2859
+ }
2860
+ if (verb == "set_property" && value != null)
2861
+ {
2862
+ var v = node.GetVariable(name);
2863
+ if (v != null)
2864
+ {
2865
+ var bad = CoerceCheck(v, value);
2866
+ if (bad != null) addErr(idx, "bad_value", bad, null);
2867
+ }
2868
+ // A declared-but-unmaterialized property has no IUAVariable yet, so
2869
+ // the value cannot be type-checked here; the write materializes it.
2870
+ }
2871
+ }
2872
+
2873
+ // Best-effort "did you mean this path" for an unresolved reference, drawn from
2874
+ // every path the batch creates ANYWHERE in the list (see the pre-pass): the
2875
+ // usual cause is an op-ordering mistake, and naming the later create is what
2876
+ // makes the error fixable rather than just true.
2877
+ private static string NearestHint(string path, List<string> willCreate)
2878
+ {
2879
+ if (willCreate == null || willCreate.Count == 0) return "";
2880
+ // Exact match first: the path IS created, just too late.
2881
+ foreach (var c in willCreate)
2882
+ {
2883
+ if (string.Equals(c, path, StringComparison.OrdinalIgnoreCase))
2884
+ return " (this batch creates '" + c + "' at a LATER op - is the op order wrong?)";
2885
+ }
2886
+ var hit = SuggestPropertyName(path, willCreate);
2887
+ if (hit != null)
2888
+ return " (this batch creates '" + hit + "' - did you mean that, or is the op order wrong?)";
2889
+ return "";
2890
+ }
2891
+
2892
+ // Same story for the TYPE field: create_widget says "widget_type",
2893
+ // create_object "object_type", create_type "base_type". A missed type here is
2894
+ // softer than a missed parent - the node is still tracked as created, but the
2895
+ // hypothetical property check downgrades to an `unverifiable_property`
2896
+ // warning instead of validating.
2897
+ private static string TypeKey(System.Text.Json.JsonElement op)
2898
+ {
2899
+ foreach (var key in new[] { "type", "widget_type", "object_type", "base_type" })
2900
+ {
2901
+ var v = JsonStr(op, key);
2902
+ if (!string.IsNullOrEmpty(v)) return v;
2903
+ }
2904
+ return null;
2905
+ }
2906
+
2907
+ // Which op field names the PARENT depends on the noun, because each create
2908
+ // op mirrors its per-noun tool's own vocabulary: create_widget takes
2909
+ // "screen", create_folder/object/type/variable take "parent", create_alias
2910
+ // takes "parent_path". Checked in that order, with "path" last as a
2911
+ // fallback. Getting this wrong is silent: the validator computes the wrong
2912
+ // child path, so create-tracking misses and Tier 2 stops working while every
2913
+ // report still looks clean (caught by the live gate 2026-07-25).
2914
+ private static string ParentKey(System.Text.Json.JsonElement op)
2915
+ {
2916
+ foreach (var key in new[] { "parent", "screen", "parent_path", "path" })
2917
+ {
2918
+ var v = JsonStr(op, key);
2919
+ if (!string.IsNullOrEmpty(v)) return v;
2920
+ }
2921
+ return null;
2922
+ }
2923
+
2924
+ // Read a string field off a JSON object, whatever its scalar kind.
2925
+ private static string JsonStr(System.Text.Json.JsonElement obj, string key)
2926
+ {
2927
+ System.Text.Json.JsonElement el;
2928
+ if (!obj.TryGetProperty(key, out el)) return null;
2929
+ switch (el.ValueKind)
2930
+ {
2931
+ case System.Text.Json.JsonValueKind.String: return el.GetString();
2932
+ case System.Text.Json.JsonValueKind.Number: return el.GetRawText();
2933
+ case System.Text.Json.JsonValueKind.True: return "true";
2934
+ case System.Text.Json.JsonValueKind.False: return "false";
2935
+ default: return null;
2936
+ }
2937
+ }
2938
+
2939
+ // ---- U16: check-only twins of the coercion path -------------------------
2940
+
2941
+ // The VALUE-VALIDITY half of CoerceAssign, with every `v.Value = ...` write
2942
+ // removed. Returns the same error strings CoerceAssign would, or null when
2943
+ // the value would assign cleanly.
2944
+ //
2945
+ // Kept as a separate method rather than a flag on CoerceAssign: the write
2946
+ // path is load-bearing and crash-adjacent (an invalid enum assign asserts
2947
+ // inside the native layer), so it is not worth threading a "don't write"
2948
+ // branch through it. The arms MUST stay in sync - if you add a datatype to
2949
+ // CoerceAssign, add it here.
2950
+ //
2951
+ // Arms with no validation to do (Boolean, String, LocalizedText) return null
2952
+ // because CoerceAssign accepts anything for them: Boolean maps any string to
2953
+ // a bool rather than rejecting, so a validator that "failed" on "yes" would
2954
+ // be lying about what the write would do.
2955
+ private string CoerceCheck(IUAVariable v, string raw)
2956
+ {
2957
+ string dt = DataTypeName(v);
2958
+ if (IsArrayVariable(v))
2959
+ return "unsupported_array_write: property is array-typed (" + dt +
2960
+ "[]); scalar writes to array UA variables are not supported" +
2961
+ " (they can crash Studio)";
2962
+ switch (dt)
2963
+ {
2964
+ case "Boolean":
2965
+ case "LocalizedText":
2966
+ case "String":
2967
+ return null; // CoerceAssign accepts any string for these
2968
+ case "Int16": case "Int32": case "Int64":
2969
+ case "UInt16": case "UInt32": case "UInt64": case "Byte": case "SByte":
2970
+ {
2971
+ long iv;
2972
+ if (!long.TryParse(raw, System.Globalization.NumberStyles.Integer,
2973
+ System.Globalization.CultureInfo.InvariantCulture, out iv))
2974
+ return "value must be an integer for " + dt + ": " + raw;
2975
+ return null;
2976
+ }
2977
+ case "Float": case "Double": case "Size":
2978
+ {
2979
+ double dv;
2980
+ if (!double.TryParse(raw, System.Globalization.NumberStyles.Float,
2981
+ System.Globalization.CultureInfo.InvariantCulture, out dv))
2982
+ return "value must be a number for " + dt + ": " + raw;
2983
+ return null;
2984
+ }
2985
+ case "NodeId":
2986
+ return ResolveNode(raw) == null
2987
+ ? "NodeId value must be a resolvable node path: " + raw : null;
2988
+ case "Color":
2989
+ return CheckColor(raw);
2990
+ default:
2991
+ return CheckEnumOrRaw(dt, raw);
2992
+ }
2993
+ }
2994
+
2995
+ // Color parse-check, mirroring CoerceAssign's Color arm without the write.
2996
+ private static string CheckColor(string raw)
2997
+ {
2998
+ string s = (raw ?? "").Trim();
2999
+ uint argb;
3000
+ if (s.StartsWith("#"))
3001
+ {
3002
+ string hex = s.Substring(1);
3003
+ if (hex.Length == 6) hex = "FF" + hex;
3004
+ if (!uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber,
3005
+ System.Globalization.CultureInfo.InvariantCulture, out argb))
3006
+ return "Color must be #RRGGBB / #AARRGGBB hex or a UInt32 decimal: " + raw;
3007
+ return null;
3008
+ }
3009
+ return uint.TryParse(s, out argb)
3010
+ ? null : "Color must be #RRGGBB / #AARRGGBB hex or a UInt32 decimal: " + raw;
3011
+ }
3012
+
3013
+ // Enum check mirroring SetEnumOrRaw, minus the assign. Deliberately PERMISSIVE
3014
+ // for a datatype we have no member list for: SetEnumOrRaw discovers those by
3015
+ // attempting the write and catching the native assert, which a validator must
3016
+ // not do (it would mutate). Reporting "valid" there and letting the real write
3017
+ // surface the error beats false-rejecting a value that would have worked.
3018
+ private static string CheckEnumOrRaw(string dt, string raw)
3019
+ {
3020
+ int ord;
3021
+ if (int.TryParse(raw, out ord)) return null;
3022
+ if (TryEnumOrdinal(dt, raw, out ord)) return null;
3023
+ var known = KnownEnumMembers(dt);
3024
+ if (known != null)
3025
+ return "invalid value '" + raw + "' for enum " + dt + "; valid: " + string.Join(", ", known);
3026
+ return null;
3027
+ }
3028
+
3029
+ // FTOptix's ValueFormatter accepts .NET STANDARD numeric specifiers (F1, N2, G,
3030
+ // E2, P1, C, D, X) but REJECTS custom patterns (0.0, 0.00, #.##, #,##0.0): a
3031
+ // custom pattern is let through at author-time and only throws at RUNTIME
3032
+ // ("Unsupported number format: <p>"), so the author burns a restart+screenshot
3033
+ // cycle to discover it. A "Format" value with NO ASCII letter can ONLY be a
3034
+ // custom numeric pattern (standard specifiers start with a letter; DateTime
3035
+ // formats always contain H/m/s/y/d/M), so reject it up front with a did_you_mean
3036
+ // pointing at the standard equivalent (decimal count -> F<n>, or N<n> if grouped).
3037
+ private static string FormatSpecifierError(string propName, string raw)
3038
+ {
3039
+ if (propName != "Format" || string.IsNullOrEmpty(raw)) return null;
3040
+ foreach (char c in raw)
3041
+ if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) return null;
3042
+ int dot = raw.LastIndexOf('.');
3043
+ int decimals = dot < 0 ? 0 : raw.Length - dot - 1;
3044
+ string sugg = (raw.IndexOf(',') >= 0 ? "N" : "F") + decimals;
3045
+ return "invalid Format '" + raw + "': FTOptix accepts .NET standard specifiers " +
3046
+ "(F1, N2, G, P1), not custom patterns like 0.0/#.##; did_you_mean: '" + sugg + "'";
3047
+ }
3048
+
3049
+ // Read the POST body that follows the headers. The accept loop does ONE 4KB
3050
+ // read, which usually swallows the whole request - but an op batch can exceed
3051
+ // that, so continue reading until Content-Length bytes are in hand. Decoded as
3052
+ // UTF-8 (the header read is ASCII, which is fine for headers and wrong for a
3053
+ // body carrying non-ASCII property values).
3054
+ private static string ReadRequestBody(NetworkStream stream, byte[] first, int firstLen, string head)
3055
+ {
3056
+ try
3057
+ {
3058
+ int sep = head.IndexOf("\r\n\r\n", StringComparison.Ordinal);
3059
+ int headerLen = sep >= 0 ? sep + 4 : firstLen;
3060
+ int contentLength = 0;
3061
+ foreach (var line in head.Split('\n'))
3062
+ {
3063
+ var t = line.Trim();
3064
+ if (t.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
3065
+ int.TryParse(t.Substring("Content-Length:".Length).Trim(), out contentLength);
3066
+ }
3067
+ var ms = new System.IO.MemoryStream();
3068
+ int already = Math.Max(0, firstLen - headerLen);
3069
+ if (already > 0) ms.Write(first, headerLen, already);
3070
+ var tmp = new byte[8192];
3071
+ while (ms.Length < contentLength)
3072
+ {
3073
+ int got = stream.Read(tmp, 0, tmp.Length);
3074
+ if (got <= 0) break; // client closed early
3075
+ ms.Write(tmp, 0, got);
3076
+ }
3077
+ return System.Text.Encoding.UTF8.GetString(ms.ToArray());
3078
+ }
3079
+ catch (Exception)
3080
+ {
3081
+ return "";
3082
+ }
3083
+ }
3084
+
3085
+ private string PropOkJson(string path, string name, string dt, string via, IUAVariable v)
3086
+ {
3087
+ return "{\"ok\":true,\"path\":\"" + JsonEscape(path) +
3088
+ "\",\"name\":\"" + JsonEscape(name) +
3089
+ "\",\"datatype\":\"" + JsonEscape(dt) +
3090
+ "\",\"via\":\"" + via + "\",\"value\":\"" + JsonEscape(ValueString(v)) +
3091
+ "\",\"mode\":\"inline\",\"thread\":\"http-bg\"}";
3092
+ }
3093
+
3094
+ // Assign an enum/unknown-datatype property from its string form.
3095
+ // Enums are Int32-backed; a bare-string assign hits an internal LocalizedText path
3096
+ // that asserts on an empty localeId. Prefer the integer ordinal (raw is a number,
3097
+ // or a resolvable friendly member name for the built-in alignment enums); only
3098
+ // fall back to the string assign for a genuinely non-enum exotic datatype.
3099
+ // dt (browse-name) -> the actual FTOptix .NET enum Type, resolved by reflection
3100
+ // and cached (misses cached as null). The scan is over already-loaded assemblies,
3101
+ // so at model-write time the FTOptix.UI enums are present.
3102
+ private static readonly Dictionary<string, Type> _enumTypeCache =
3103
+ new Dictionary<string, Type>();
3104
+
3105
+ private static Type ResolveEnumType(string dt)
3106
+ {
3107
+ if (string.IsNullOrEmpty(dt)) return null;
3108
+ lock (_enumTypeCache)
3109
+ {
3110
+ Type cached;
3111
+ if (_enumTypeCache.TryGetValue(dt, out cached)) return cached;
3112
+ // Optix names some enum DATATYPES with an "Enum" suffix (FontWeightEnum)
3113
+ // while the .NET enum TYPE is bare (FTOptix.UI.FontWeight); others match
3114
+ // exactly (VerticalAlignment). Accept the datatype name AND the
3115
+ // suffix-stripped form. Prefer an exact match if both a "FooEnum" and a
3116
+ // "Foo" enum exist.
3117
+ string stripped = dt.EndsWith("Enum", StringComparison.Ordinal) && dt.Length > 4
3118
+ ? dt.Substring(0, dt.Length - 4) : null;
3119
+ Type exact = null, alt = null;
3120
+ foreach (var a in AppDomain.CurrentDomain.GetAssemblies())
3121
+ {
3122
+ Type[] types;
3123
+ try { types = a.GetTypes(); }
3124
+ catch (ReflectionTypeLoadException e) { types = e.Types.Where(x => x != null).ToArray(); }
3125
+ catch { continue; }
3126
+ foreach (var cand in types)
3127
+ {
3128
+ if (cand == null || !cand.IsEnum) continue;
3129
+ if (cand.Name == dt) { exact = cand; break; }
3130
+ if (stripped != null && alt == null && cand.Name == stripped) alt = cand;
3131
+ }
3132
+ if (exact != null) break;
3133
+ }
3134
+ Type found = exact ?? alt;
3135
+ _enumTypeCache[dt] = found;
3136
+ return found;
3137
+ }
3138
+ }
3139
+
3140
+ private static string SetEnumOrRaw(IUAVariable v, string dt, string raw)
3141
+ {
3142
+ int ord;
3143
+ if (int.TryParse(raw, out ord)) { v.Value = ord; return null; }
3144
+ // GENERIC: reflect the property's real enum type and parse the friendly
3145
+ // name from its own metadata. Handles every enum (FontWeight,
3146
+ // TextHorizontalAlignment, alignment, ...) correctly — no hardcoded
3147
+ // per-enum map to maintain or get wrong. Case-insensitive. An invalid
3148
+ // name gets a valid-member list straight from the enum.
3149
+ var et = ResolveEnumType(dt);
3150
+ if (et != null)
3151
+ {
3152
+ try { v.Value = Convert.ToInt32(Enum.Parse(et, raw, true)); return null; }
3153
+ catch
3154
+ {
3155
+ return "invalid value '" + raw + "' for enum " + dt + "; valid: " +
3156
+ string.Join(", ", Enum.GetNames(et));
3157
+ }
3158
+ }
3159
+ // Fallback for the built-in alignment enums if reflection can't resolve the
3160
+ // type (redundant while FTOptix.UI is loaded, kept as belt-and-suspenders).
3161
+ if (TryEnumOrdinal(dt, raw, out ord)) { v.Value = ord; return null; }
3162
+ var known = KnownEnumMembers(dt);
3163
+ if (known != null)
3164
+ return "invalid value '" + raw + "' for enum " + dt + "; valid: " + string.Join(", ", known);
3165
+ // Genuinely non-enum datatype: attempt the string assign, catching the
3166
+ // native assert so we still return a clean message.
3167
+ try { v.Value = raw; return null; }
3168
+ catch (Exception ex)
3169
+ {
3170
+ return "could not assign '" + raw + "' to a property of type " + dt +
3171
+ " (for an enum, pass an integer ordinal or a valid member name; " +
3172
+ "call describe_type for the property): " + ex.Message;
3173
+ }
3174
+ }
3175
+
3176
+ // Member names for the built-in enums we know (mirrors TryEnumOrdinal). Used to build
3177
+ // a helpful valid-list on an invalid enum value. Extend alongside TryEnumOrdinal.
3178
+ // Listed in TRUE ordinal order (see TryEnumOrdinal) so the valid-list an
3179
+ // invalid value shows isn't misleading about position.
3180
+ private static string[] KnownEnumMembers(string dt)
3181
+ {
3182
+ switch (dt)
3183
+ {
3184
+ case "HorizontalAlignment": return new[] { "Left", "Right", "Center", "Stretch" };
3185
+ case "VerticalAlignment": return new[] { "Top", "Bottom", "Center", "Stretch" };
3186
+ default: return null;
3187
+ }
3188
+ }
3189
+
3190
+ // Friendly member name -> ordinal for the built-in FTOptix.UI alignment enums.
3191
+ // Case-insensitive. Extend here as more enum properties are exercised.
3192
+ //
3193
+ // CRITICAL / REGRESSION-CITE: FTOptix.UI.{Horizontal,Vertical}Alignment do NOT
3194
+ // use the WPF-standard {Left/Top=0, Center=1, Right/Bottom=2} order. Verified
3195
+ // by reflecting FTOptix.UI.Net.dll (2026-07-25):
3196
+ // VerticalAlignment Top=0 Bottom=1 Center=2 Stretch=3
3197
+ // HorizontalAlignment Left=0 Right=1 Center=2 Stretch=3
3198
+ // i.e. the EXTREME member is 1 and Center is 2. An earlier map assumed the WPF
3199
+ // order, so "Bottom" set ordinal 2 (=Center) and rendered centered — a live
3200
+ // build burned ~14 tool calls reverse-engineering this. Do NOT "fix" Center
3201
+ // back to 1; that reintroduces the swap. (Note the sibling Content*/Text*
3202
+ // alignment enums DO use the standard Center=1 order — different enums.)
3203
+ private static bool TryEnumOrdinal(string dt, string name, out int ord)
3204
+ {
3205
+ ord = 0;
3206
+ string key = (dt ?? "") + "." + (name ?? "").Trim().ToLowerInvariant();
3207
+ switch (key)
3208
+ {
3209
+ case "HorizontalAlignment.left": ord = 0; return true;
3210
+ case "HorizontalAlignment.right": ord = 1; return true;
3211
+ case "HorizontalAlignment.center": ord = 2; return true;
3212
+ case "HorizontalAlignment.stretch": ord = 3; return true;
3213
+ case "VerticalAlignment.top": ord = 0; return true;
3214
+ case "VerticalAlignment.bottom": ord = 1; return true;
3215
+ case "VerticalAlignment.center": ord = 2; return true;
3216
+ case "VerticalAlignment.stretch": ord = 3; return true;
3217
+ default: return false;
3218
+ }
3219
+ }
3220
+
3221
+ // ---- semantic authoring (bind / alias / i18n / delete) ------------------
3222
+ // All node-model ops (SetDynamicLink / SetAlias / AddTranslation / Delete) -
3223
+ // off-thread-safe (same class as MakeObject/Add/GetOrCreateVariable), so they
3224
+ // run inline on the HTTP thread with no marshaling. (The typed-property setter
3225
+ // and DelayedTask were both ruled out live: off-thread crash / design-time hang.)
3226
+
3227
+ private string BindPropertyInline(string firstLine)
3228
+ {
3229
+ string path = QueryParam(firstLine, "path");
3230
+ string name = QueryParam(firstLine, "name");
3231
+ string source = QueryParam(firstLine, "source");
3232
+ string raw = QueryParam(firstLine, "raw");
3233
+ string modeStr = QueryParam(firstLine, "mode") ?? "Read";
3234
+ if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(name) ||
3235
+ (string.IsNullOrEmpty(source) && string.IsNullOrEmpty(raw)))
3236
+ return ErrorJson("bad_query", "required: path, name, and source=<resolvable path> OR raw=<literal NodePath>");
3237
+ if (!string.IsNullOrEmpty(source) && !string.IsNullOrEmpty(raw))
3238
+ return ErrorJson("bad_query", "pass source OR raw, not both");
3239
+ try
3240
+ {
3241
+ var node = ResolveNode(path);
3242
+ if (node == null) return ErrorJson("node_not_found", "no node at: " + path);
3243
+ IUAVariable srcVar = null;
3244
+ if (!string.IsNullOrEmpty(source))
3245
+ {
3246
+ srcVar = ResolveNode(source) as IUAVariable;
3247
+ if (srcVar == null) return ErrorJson("source_not_variable",
3248
+ "source is not a variable: " + source +
3249
+ " - binding THROUGH an alias ({Alias1}/Child or ../../Alias1/Child)" +
3250
+ " is deliberately unresolvable at bind time; pass it as raw= instead");
3251
+ }
3252
+ // A variable's value is the variable itself, not a child named
3253
+ // Value. This is required for logger VariableToLog entries and is
3254
+ // equally useful for ordinary model variables.
3255
+ if (name == "Value" && node is IUAVariable selfVar)
3256
+ {
3257
+ if (IsArrayVariable(selfVar))
3258
+ return ArrayWriteError(name, "variable " + path, DataTypeName(selfVar));
3259
+ DynamicLinkMode selfMode;
3260
+ switch (modeStr)
3261
+ {
3262
+ case "Write": selfMode = DynamicLinkMode.Write; break;
3263
+ case "ReadWrite": selfMode = DynamicLinkMode.ReadWrite; break;
3264
+ default: selfMode = DynamicLinkMode.Read; break;
3265
+ }
3266
+ if (srcVar != null)
3267
+ {
3268
+ selfVar.SetDynamicLink(srcVar, selfMode);
3269
+ return "{\"ok\":true,\"path\":\"" + JsonEscape(path) +
3270
+ "\",\"source\":\"" + JsonEscape(source) +
3271
+ "\",\"mode\":\"" + JsonEscape(modeStr) + "\",\"via\":\"dynamiclink-self\"}";
3272
+ }
3273
+ selfVar.SetDynamicLink(null, selfMode);
3274
+ var selfDl = selfVar.Refs.GetVariable(FTOptix.CoreBase.ReferenceTypes.HasDynamicLink);
3275
+ if (selfDl == null)
3276
+ return ErrorJson("link_materialize_failed",
3277
+ "SetDynamicLink(null) did not materialize a DynamicLink child on " + path);
3278
+ selfDl.Value = raw;
3279
+ return "{\"ok\":true,\"path\":\"" + JsonEscape(path) +
3280
+ "\",\"raw\":\"" + JsonEscape(raw) +
3281
+ "\",\"mode\":\"" + JsonEscape(modeStr) + "\",\"via\":\"dynamiclink-self-raw\"}";
3282
+ }
3283
+ IUAVariable propVar = node.GetVariable(name);
3284
+ if (propVar == null)
3285
+ {
3286
+ var gateErr = DeclaredPropertyGuard(node, name);
3287
+ if (gateErr != null) return gateErr;
3288
+ // Same pre-materialization array gate as set_property (an already-
3289
+ // materialized array prop can still be dynamic-linked; only the
3290
+ // fresh materialization of one is blocked).
3291
+ var arrGateErr = DeclaredArrayGuard(node, name);
3292
+ if (arrGateErr != null) return arrGateErr;
3293
+ if (node is IUAObject obj) propVar = obj.GetOrCreateVariable(name);
3294
+ else if (node is IUAObjectType objT) propVar = objT.GetOrCreateVariable(name);
3295
+ }
3296
+ if (propVar == null) return ErrorJson("property_not_found", "no property " + name + " on " + path);
3297
+ DynamicLinkMode mode;
3298
+ switch (modeStr)
3299
+ {
3300
+ case "Write": mode = DynamicLinkMode.Write; break;
3301
+ case "ReadWrite": mode = DynamicLinkMode.ReadWrite; break;
3302
+ default: mode = DynamicLinkMode.Read; break;
3303
+ }
3304
+ if (srcVar != null)
3305
+ {
3306
+ propVar.SetDynamicLink(srcVar, mode);
3307
+ return "{\"ok\":true,\"path\":\"" + JsonEscape(path + "/" + name) +
3308
+ "\",\"source\":\"" + JsonEscape(source) +
3309
+ "\",\"mode\":\"" + JsonEscape(modeStr) + "\",\"via\":\"dynamiclink\"}";
3310
+ }
3311
+ // RAW NodePath binding - the alias/template mechanism. The stored
3312
+ // value is a LITERAL path ("{Alias1}/MyInt" or "../../Alias1/MyInt")
3313
+ // resolved at RUNTIME per instance - deliberately NOT resolvable at
3314
+ // bind time (that per-instance late binding is what makes a template
3315
+ // reusable). Studio 1.7.x legacy pattern per the NetLogic cheatsheet:
3316
+ // materialize an empty link, then write the literal into the
3317
+ // DynamicLink variable (1.8.x gets SetDynamicLinkToAlias; not in
3318
+ // this SDK). No target validation is possible by design - the
3319
+ // response echoes raw for the caller to render-verify.
3320
+ propVar.SetDynamicLink(null, mode);
3321
+ var dlVar = propVar.Refs.GetVariable(FTOptix.CoreBase.ReferenceTypes.HasDynamicLink);
3322
+ if (dlVar == null)
3323
+ return ErrorJson("link_materialize_failed",
3324
+ "SetDynamicLink(null) did not materialize a DynamicLink child on " + path + "/" + name);
3325
+ dlVar.Value = raw;
3326
+ return "{\"ok\":true,\"path\":\"" + JsonEscape(path + "/" + name) +
3327
+ "\",\"raw\":\"" + JsonEscape(raw) +
3328
+ "\",\"mode\":\"" + JsonEscape(modeStr) + "\",\"via\":\"dynamiclink-raw\"" +
3329
+ ",\"note\":\"literal NodePath - resolves per instance at runtime; render-verify\"}";
3330
+ }
3331
+ catch (Exception ex) { return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}"; }
3332
+ }
3333
+
3334
+ private string CreateAliasInline(string firstLine)
3335
+ {
3336
+ string parent = QueryParam(firstLine, "parent");
3337
+ string name = QueryParam(firstLine, "name");
3338
+ string target = QueryParam(firstLine, "target"); // OPTIONAL: template aliases are unassigned by design
3339
+ string kind = QueryParam(firstLine, "kind"); // OPTIONAL: type constraint (Studio's "+ Alias" sets one)
3340
+ if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
3341
+ return ErrorJson("bad_query", "required: parent, name (+ optional target=<path>, kind=<type name or path>)");
3342
+ try
3343
+ {
3344
+ // Accept Object AND ObjectType parents - the primary home of an
3345
+ // alias slot is a TEMPLATE TYPE (create_type output), which is an
3346
+ // IUAObjectType; an IUAObject-only cast rejected exactly that
3347
+ // (found live 2026-07-17, same gap family as set_property-on-type).
3348
+ var parentNode = ResolveNode(parent);
3349
+ if (parentNode == null || !(parentNode is IUAObject || parentNode is IUAObjectType))
3350
+ return ErrorJson("node_not_found", "no object or type at parent: " + parent);
3351
+ IUANode targetNode = null;
3352
+ if (!string.IsNullOrEmpty(target))
3353
+ {
3354
+ targetNode = ResolveNode(target);
3355
+ if (targetNode == null) return ErrorJson("node_not_found", "no target at: " + target);
3356
+ }
3357
+ NodeId kindId = null;
3358
+ if (!string.IsNullOrEmpty(kind))
3359
+ {
3360
+ // Catalog UI type first (Button, ...), else a project path to a type node.
3361
+ var kf = typeof(FTOptix.UI.ObjectTypes).GetField(kind, BindingFlags.Public | BindingFlags.Static);
3362
+ if (kf != null && kf.GetValue(null) is NodeId knid) kindId = knid;
3363
+ if (kindId == null)
3364
+ {
3365
+ var kn = ResolveNode(kind);
3366
+ if (kn != null && (kn.NodeClass == NodeClass.ObjectType || kn.NodeClass == NodeClass.VariableType))
3367
+ kindId = kn.NodeId;
3368
+ }
3369
+ if (kindId == null)
3370
+ return ErrorJson("type_not_found",
3371
+ "kind '" + kind + "' is neither a builtin UI type nor a path to a type node");
3372
+ }
3373
+ var dup = DupNameGuard(parentNode, name, parent);
3374
+ if (dup != null) return dup;
3375
+ // CREATE a new alias. IUAObject.SetAlias(name, target) only ASSIGNS a
3376
+ // target to an alias the node's TYPE already declares - it raises
3377
+ // "Alias {name} not found" on an arbitrary node (live-validated 0.8.2).
3378
+ // An Alias is a variable subtype (FTOptix.Core.Alias, YAML `Type: Alias`,
3379
+ // DataType NodeId, Value = target path). Build with node-model ops.
3380
+ // Kind (the type CONSTRAINT Studio's "+ Alias" carries - what makes
3381
+ // binding/validation "know" the alias's shape) is set via the typed
3382
+ // setter BEFORE parentNode.Add: a detached node isn't observed by the
3383
+ // renderer, so the off-thread typed-setter hazard doesn't apply yet.
3384
+ var alias = InformationModel.MakeVariable<FTOptix.Core.Alias>(name, OpcUa.DataTypes.NodeId);
3385
+ if (kindId != null) alias.Kind = kindId;
3386
+ if (targetNode != null) alias.Value = targetNode.NodeId;
3387
+ parentNode.Add(alias);
3388
+ return "{\"ok\":true,\"alias\":\"" + JsonEscape(parent + "/" + name) +
3389
+ "\",\"target\":" + (targetNode != null ? "\"" + JsonEscape(target) + "\"" : "null") +
3390
+ ",\"kind\":" + (kindId != null ? "\"" + JsonEscape(kind) + "\"" : "null") +
3391
+ ",\"via\":\"alias-create\"}";
3392
+ }
3393
+ catch (Exception ex) { return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}"; }
3394
+ }
3395
+
3396
+ private string AddTranslationInline(string firstLine)
3397
+ {
3398
+ string key = QueryParam(firstLine, "key");
3399
+ string value = QueryParam(firstLine, "value") ?? "";
3400
+ string locale = QueryParam(firstLine, "locale") ?? "en-US";
3401
+ if (string.IsNullOrEmpty(key))
3402
+ return ErrorJson("bad_query", "required: key");
3403
+ try
3404
+ {
3405
+ int nsIdx = Project.Current.NodeId.NamespaceIndex;
3406
+ var existing = InformationModel.LookupTranslation(new LocalizedText(key));
3407
+ bool isNew = existing == null || string.IsNullOrEmpty(existing.Text);
3408
+ var lt = new LocalizedText(nsIdx, key, value, locale);
3409
+ if (isNew) InformationModel.AddTranslation(lt);
3410
+ else InformationModel.SetTranslation(lt);
3411
+ return "{\"ok\":true,\"key\":\"" + JsonEscape(key) + "\",\"locale\":\"" + JsonEscape(locale) +
3412
+ "\",\"new\":" + Bool(isNew) + ",\"via\":\"translation\"}";
3413
+ }
3414
+ catch (Exception ex) { return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}"; }
3415
+ }
3416
+
3417
+ private string DeleteNodeInline(string firstLine)
3418
+ {
3419
+ string path = QueryParam(firstLine, "path");
3420
+ if (string.IsNullOrEmpty(path))
3421
+ return ErrorJson("bad_query", "required: path");
3422
+ try
3423
+ {
3424
+ var node = ResolveNode(path);
3425
+ if (node == null) return ErrorJson("node_not_found", "no node at: " + path);
3426
+ node.Delete();
3427
+ return "{\"ok\":true,\"deleted\":\"" + JsonEscape(path) + "\",\"via\":\"delete\"}";
3428
+ }
3429
+ catch (Exception ex) { return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}"; }
3430
+ }
3431
+
3432
+ // new method. Verified against the real installed SDK
3433
+ // (UAManagedCore.dll / UAManagedCoreCommon.dll via .NET reflection) that
3434
+ // IUAObject.ExecuteMethod(name, inArgs, out outArgs) is the correct API before
3435
+ // writing this, and compile-checked it in isolation since two of the project's
3436
+ // other referenced DLLs wouldn't load cleanly in an ad-hoc build context.
3437
+ // POST /bridge/node/invoke?path=<node>&method=<name>[&args=v1,v2,...] - execute an
3438
+ // exported UAMethod (an [ExportMethod] C# method, or a built-in library method such
3439
+ // as FTOptix's SearchBrokenDynamicLinks/FixAliasDynamicLinkMode) that isn't already
3440
+ // covered by a dedicated bridge verb. This is the generic escape hatch: right-click
3441
+ // "Execute" in Studio's UI does exactly this under the hood (IUAObject.ExecuteMethod),
3442
+ // so this exposes the same capability remotely instead of requiring a human at the
3443
+ // keyboard for every method Studio ships or a project author writes.
3444
+ //
3445
+ // args, if present, is a comma-separated list (matching this file's existing
3446
+ // convention for list-shaped query params, e.g. attach-expression's "sources") -
3447
+ // each element is passed through as a plain string. Methods that need typed,
3448
+ // numeric, or array arguments are out of scope for this generic endpoint; the
3449
+ // input arg marshaling here is intentionally minimal.
3450
+ //
3451
+ // Output arguments (if the method has any) come back as output_args, each
3452
+ // stringified best-effort - good enough to report a result/count/summary, not a
3453
+ // substitute for a typed read.
3454
+ //
3455
+ // Same trust model as every other write verb in this file (set-property,
3456
+ // delete-node, convert-to-type, ...): no allow-list, no confirmation step. This
3457
+ // runs at Studio DESIGN TIME against Project.Current, not a live runtime/PLC - the
3458
+ // blast radius is "the open project", the same as any other bridge write.
3459
+ //
3460
+ // CONFIRMED HAZARD, not theoretical. Calling
3461
+ // SearchBrokenDynamicLinks' FindBrokenDynamicLink through this endpoint killed
3462
+ // the whole FTOptixStudio.exe process outright - reproduced twice, across two
3463
+ // separate Studio sessions (crash, user relaunched Studio + StartBridge, called
3464
+ // it again, crashed again). No exception was ever caught by the try/catch below;
3465
+ // the process just died, which on .NET means something fatal happened on a
3466
+ // thread this handler doesn't control (most likely: this TcpListener accept
3467
+ // thread is NOT Studio's main/UI thread, and this particular built-in tool -
3468
+ // presumably written assuming it's driven by the UI's own Execute gesture -
3469
+ // touches something UI-thread-affine and crashes hard off-thread). This file's
3470
+ // OWN _bridge_write docstring already documents this exact class of problem for
3471
+ // property writes ("Several target endpoints require the bridge's
3472
+ // main-thread-marshaled write path... until that ships the bridge replies
3473
+ // not_implemented") - ExecuteMethod needs the same treatment, which it does NOT
3474
+ // have yet. Until proper main-thread marshaling is added here, treat EVERY call
3475
+ // through this endpoint as able to crash Studio, not just this one method - a
3476
+ // custom, UI-free [ExportMethod] may well be fine, but that has not been
3477
+ // verified either. For finding/fixing broken links specifically, use Studio's
3478
+ // own right-click Execute instead (the safe, supported path) until this is
3479
+ // fixed properly.
3480
+ private string InvokeMethodInline(string firstLine)
3481
+ {
3482
+ string path = QueryParam(firstLine, "path");
3483
+ string methodName = QueryParam(firstLine, "method");
3484
+ if (string.IsNullOrEmpty(path))
3485
+ return ErrorJson("bad_query", "missing required query param: path");
3486
+ if (string.IsNullOrEmpty(methodName))
3487
+ return ErrorJson("bad_query", "missing required query param: method");
3488
+
3489
+ var node = ResolveNode(path);
3490
+ if (node == null)
3491
+ return ErrorJson("node_not_found", "no node at path: " + path);
3492
+
3493
+ var obj = node as IUAObject;
3494
+ if (obj == null)
3495
+ return ErrorJson("not_invokable",
3496
+ "node at " + path + " is a " + node.GetType().Name +
3497
+ " - only IUAObject nodes host invokable methods");
3498
+
3499
+ string argsParam = QueryParam(firstLine, "args");
3500
+ object[] inputArgs = string.IsNullOrEmpty(argsParam)
3501
+ ? new object[0]
3502
+ : argsParam.Split(',').Select(s => (object)s).ToArray();
3503
+
3504
+ object[] outputArgs;
3505
+ try
3506
+ {
3507
+ obj.ExecuteMethod(methodName, inputArgs, out outputArgs);
3508
+ }
3509
+ catch (Exception ex)
3510
+ {
3511
+ return ErrorJson("execution_failed", ExcMsg(ex));
3512
+ }
3513
+
3514
+ var outSb = new StringBuilder();
3515
+ if (outputArgs != null)
3516
+ {
3517
+ for (int i = 0; i < outputArgs.Length; i++)
3518
+ {
3519
+ if (i > 0) outSb.Append(",");
3520
+ var v = outputArgs[i];
3521
+ outSb.Append("\"" + JsonEscape(v == null ? "null" : v.ToString()) + "\"");
3522
+ }
3523
+ }
3524
+ return "{\"ok\":true,\"path\":\"" + JsonEscape(path) +
3525
+ "\",\"method\":\"" + JsonEscape(methodName) +
3526
+ "\",\"output_args\":[" + outSb + "]}";
3527
+ }
3528
+
3529
+ // Wire a UI event on a node (EventHandler graph, reverse-engineered from
3530
+ // FTRemoteAccessWidgetSetupLogic.cs + the NetLogic_CheatSheet). Node-model ops only.
3531
+ // TWO modes (use ONE):
3532
+ // - NATIVE COMMAND (preferred - no custom NetLogic):
3533
+ // command=SetVariable&variable=<path>&value=<v> -> VariableCommands.Set
3534
+ // command=ToggleVariable&variable=<path> -> VariableCommands.Toggle
3535
+ // ObjectPointer -> the builtin FTOptix.CoreBase.Objects.VariableCommands object;
3536
+ // InputArguments = VariableToModify (VariablePointer) [+ Value] + ArrayIndex,
3537
+ // the proven shape (FTRemoteAccessWidgetSetupLogic.cs:128-161 + cheatsheet).
3538
+ // - CUSTOM METHOD: method=ObjectPath/MethodName (an object owning an [ExportMethod]).
3539
+ // ObjectPointer is a typed FTOptix.Core.NodePointer (the dispatcher resolves the call
3540
+ // target through it; a plain NodeId wires but never invokes). FTOptix.Core/.CoreBase
3541
+ // are referenced, so the fully-qualified names add no module ref. EventHandler is
3542
+ // fully-qualified to dodge the System.EventHandler ambiguity (CS0104). Node-attach
3543
+ // order matches the sample: container -> ObjectPointer, Method, InputArguments; then
3544
+ // populate InputArguments after it's parented.
3545
+ private string WireEventInline(string firstLine)
3546
+ {
3547
+ string path = QueryParam(firstLine, "path");
3548
+ string evt = QueryParam(firstLine, "event");
3549
+ string command = QueryParam(firstLine, "command");
3550
+ string method = QueryParam(firstLine, "method");
3551
+ if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(evt))
3552
+ return ErrorJson("bad_query", "required: path, event");
3553
+ if (string.IsNullOrEmpty(command) && string.IsNullOrEmpty(method))
3554
+ return ErrorJson("bad_query", "required: command (SetVariable|ToggleVariable) or method (ObjectPath/MethodName)");
3555
+ try
3556
+ {
3557
+ var node = ResolveNode(path);
3558
+ if (node == null) return ErrorJson("node_not_found", "no node at: " + path);
3559
+ var evtTypeId = ResolveEventType(evt);
3560
+ if (evtTypeId == null)
3561
+ {
3562
+ // Mirror the property guard: reject-with-valid-list. Event names are NOT
3563
+ // derivable from describe (they're SDK identifiers), so a bare miss left
3564
+ // the model guessing ("Click" instead of "MouseClickEvent" - the A/B trap
3565
+ // that beat even describe-first arms). Hand back the authoritative set +
3566
+ // a best-effort suggestion.
3567
+ var valid = ValidUiEventNames();
3568
+ var suggestion = SuggestUiEvent(evt, valid);
3569
+ var sb = new StringBuilder();
3570
+ sb.Append("{\"error\":{\"code\":\"event_not_found\",\"message\":\"");
3571
+ sb.Append(JsonEscape("no builtin UI event type: " + evt +
3572
+ (suggestion != null ? " (did you mean " + suggestion + "?)" : "") +
3573
+ " - use one of valid_events"));
3574
+ sb.Append("\"");
3575
+ if (suggestion != null)
3576
+ {
3577
+ sb.Append(",\"suggestion\":\""); sb.Append(JsonEscape(suggestion)); sb.Append("\"");
3578
+ }
3579
+ sb.Append(",\"valid_events\":[");
3580
+ for (int i = 0; i < valid.Count; i++)
3581
+ {
3582
+ if (i > 0) sb.Append(",");
3583
+ sb.Append("\""); sb.Append(JsonEscape(valid[i])); sb.Append("\"");
3584
+ }
3585
+ sb.Append("]}}");
3586
+ return sb.ToString();
3587
+ }
3588
+
3589
+ // Resolve the call target (ObjectPointer value + Method name) by mode.
3590
+ NodeId objPtrTarget;
3591
+ string methodName;
3592
+ IUAVariable cmdTargetVar = null; // command mode only
3593
+ string cmdValueRaw = null; // command mode, Set only
3594
+ bool cmdNeedsValue = false;
3595
+ if (!string.IsNullOrEmpty(command))
3596
+ {
3597
+ string varPath = QueryParam(firstLine, "variable");
3598
+ if (string.IsNullOrEmpty(varPath))
3599
+ return ErrorJson("bad_query", "command mode requires: variable");
3600
+ cmdTargetVar = ResolveNode(varPath) as IUAVariable;
3601
+ if (cmdTargetVar == null) return ErrorJson("node_not_found", "no variable at: " + varPath);
3602
+ var vcObj = InformationModel.GetObject(FTOptix.CoreBase.Objects.VariableCommands);
3603
+ if (vcObj == null) return ErrorJson("command_unavailable", "VariableCommands not in address space");
3604
+ objPtrTarget = vcObj.NodeId;
3605
+ switch (command)
3606
+ {
3607
+ case "SetVariable": case "Set":
3608
+ methodName = "Set"; cmdNeedsValue = true;
3609
+ cmdValueRaw = QueryParam(firstLine, "value") ?? "";
3610
+ break;
3611
+ case "ToggleVariable": case "Toggle":
3612
+ methodName = "Toggle";
3613
+ break;
3614
+ default:
3615
+ return ErrorJson("bad_query", "unknown command: " + command + " (SetVariable|ToggleVariable)");
3616
+ }
3617
+ }
3618
+ else
3619
+ {
3620
+ int slash = method.LastIndexOf('/');
3621
+ if (slash <= 0) return ErrorJson("bad_query", "method must be 'ObjectPath/MethodName'");
3622
+ var objNode = ResolveNode(method.Substring(0, slash));
3623
+ if (objNode == null) return ErrorJson("node_not_found", "no method object at: " + method.Substring(0, slash));
3624
+ objPtrTarget = objNode.NodeId;
3625
+ methodName = method.Substring(slash + 1);
3626
+ }
3627
+
3628
+ var eh = InformationModel.MakeObject<FTOptix.CoreBase.EventHandler>("EH_" + evt + "_" + node.BrowseName);
3629
+ node.Add(eh);
3630
+ var letVar = eh.GetOrCreateVariable("ListenEventType");
3631
+ letVar.Value = evtTypeId;
3632
+ var mc = InformationModel.MakeObject("MethodContainer1");
3633
+ eh.MethodsToCall.Add(mc);
3634
+ var objPtr = InformationModel.MakeVariable<FTOptix.Core.NodePointer>(
3635
+ "ObjectPointer", OpcUa.DataTypes.NodeId);
3636
+ objPtr.Value = objPtrTarget;
3637
+ mc.Add(objPtr);
3638
+ var mName = InformationModel.MakeVariable("Method", OpcUa.DataTypes.String);
3639
+ mName.Value = methodName;
3640
+ mc.Add(mName);
3641
+ var inputArgs = InformationModel.MakeObject("InputArguments");
3642
+ mc.Add(inputArgs);
3643
+
3644
+ if (cmdTargetVar != null)
3645
+ {
3646
+ // VariableToModify is a VariablePointer at the target variable's NodeId.
3647
+ var vtm = InformationModel.MakeVariable("VariableToModify", FTOptix.Core.DataTypes.VariablePointer);
3648
+ vtm.Value = cmdTargetVar.NodeId;
3649
+ inputArgs.Add(vtm);
3650
+ if (cmdNeedsValue)
3651
+ {
3652
+ // Value typed to the target variable's own DataType. Route through
3653
+ // CoerceAssign (the full set_property coercion) so enum / Color / NodeId
3654
+ // targets resolve like set_property does. CoerceRaw's default arm
3655
+ // bare-string-assigns them and hits the "!localeId.empty()" assert class.
3656
+ var valVar = InformationModel.MakeVariable("Value", cmdTargetVar.DataType);
3657
+ inputArgs.Add(valVar);
3658
+ var cverr = CoerceAssign(valVar, cmdValueRaw, firstLine);
3659
+ if (cverr != null) return ErrorJson("bad_value", cverr);
3660
+ }
3661
+ var ai = InformationModel.MakeVariable("ArrayIndex", OpcUa.DataTypes.UInt32);
3662
+ ai.Value = (uint)0;
3663
+ inputArgs.Add(ai);
3664
+ }
3665
+
3666
+ string via = string.IsNullOrEmpty(command) ? "eventhandler" : "command:" + methodName;
3667
+ return "{\"ok\":true,\"node\":\"" + JsonEscape(path) + "\",\"event\":\"" + JsonEscape(evt) +
3668
+ "\",\"via\":\"" + JsonEscape(via) + "\"}";
3669
+ }
3670
+ catch (Exception ex) { return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}"; }
3671
+ }
3672
+
3673
+ // ---- setup / project scaffolding ----------------------------------------
3674
+
3675
+ // POST /bridge/setup/web-engine[?port=8081&ip=0.0.0.0]
3676
+ // Ensure a Web presentation engine exists under UI so the deployed runtime
3677
+ // serves a canvas (the manual "add UI -> Web presentation engine" step from
3678
+ // fresh-box validation). Idempotent: returns existed:true if one is already
3679
+ // present; otherwise creates + configures one named "WebPresentationEngine"
3680
+ // and points StartWindow at the first window in the project.
3681
+ private string EnsureWebEngineInline(string firstLine)
3682
+ {
3683
+ int port = WebEnginePort, parsed;
3684
+ if (int.TryParse(QueryParam(firstLine, "port"), out parsed) && parsed > 0) port = parsed;
3685
+ return EnsureWebEngineCore(port, QueryParam(firstLine, "ip") ?? "0.0.0.0");
3686
+ }
3687
+
3688
+ // Shared by the HTTP endpoint and the [ExportMethod] SetupProject. Returns a JSON
3689
+ // status string. Off-thread-safe node-model ops only (works from the HTTP thread
3690
+ // AND design-time Studio).
3691
+ private string EnsureWebEngineCore(int port, string ip)
3692
+ {
3693
+ try
3694
+ {
3695
+ var ui = ResolveNode("UI");
3696
+ if (ui == null)
3697
+ return ErrorJson("node_not_found", "no UI node in project");
3698
+
3699
+ // 1. Already present? (idempotent - one engine is enough)
3700
+ foreach (var existing in ui.Children)
3701
+ if (existing.GetType().Name == "WebUIPresentationEngine")
3702
+ return "{\"ok\":true,\"existed\":true,\"path\":\"UI/" +
3703
+ JsonEscape(existing.BrowseName) + "\"}";
3704
+
3705
+ // 2. Resolve the type NodeId WITHOUT a compile-time dependency on the
3706
+ // WebUI module: string-based Type.GetType returns null (graceful) on a
3707
+ // wrong name rather than failing the compile, and the module IS loaded
3708
+ // at runtime inside Studio. Mirrors the widget path's ObjectTypes-NodeId
3709
+ // approach (typeof(FTOptix.UI.ObjectTypes) in WriteWidgetInline).
3710
+ NodeId engType = null;
3711
+ var otType = Type.GetType("FTOptix.WebUI.ObjectTypes, FTOptix.WebUI.Net");
3712
+ if (otType != null)
3713
+ foreach (var fieldName in new[] { "WebPresentationEngine", "WebUIPresentationEngine" })
3714
+ {
3715
+ var f = otType.GetField(fieldName, BindingFlags.Public | BindingFlags.Static);
3716
+ if (f != null && f.GetValue(null) is NodeId nid) { engType = nid; break; }
3717
+ }
3718
+ if (engType == null)
3719
+ return ErrorJson("type_unresolved",
3720
+ "could not resolve the WebPresentationEngine type NodeId from FTOptix.WebUI.ObjectTypes");
3721
+
3722
+ // 3. Create + add (node-model, off-thread-safe), THEN configure - inherited
3723
+ // type properties materialize once the object is in the tree (same
3724
+ // GetVariable-null-on-a-fresh-instance trap the property setter handles).
3725
+ var eng = InformationModel.MakeObject("WebPresentationEngine", engType);
3726
+ ui.Children.Add(eng);
3727
+
3728
+ SetIfPresent(eng, "Port", port); // UInt16 var accepts Int32 (see SetPropertyInline)
3729
+ SetIfPresent(eng, "IPAddress", ip);
3730
+ SetIfPresent(eng, "Protocol", 0); // 0 = HTTP
3731
+ // MaxNumberOfConnections: a fresh MakeObject leaves this ABSENT (-> 0 -> the
3732
+ // deployed runtime refuses browser connections; the "not accessible" symptom).
3733
+ // Studio's own "Add Web presentation engine" sets 5 - match it so it serves.
3734
+ SetIfPresent(eng, "MaxNumberOfConnections", WebEngineMaxConnections);
3735
+
3736
+ // StyleSheet -> the project DefaultStyleSheet so the canvas renders styled
3737
+ // (a fresh engine leaves it Null -> unstyled). Optional; skip if absent.
3738
+ var styleSheet = ResolveNode("UI/DefaultStyleSheet");
3739
+ if (styleSheet != null) SetIfPresent(eng, "StyleSheet", styleSheet.NodeId);
3740
+
3741
+ // StartingUser -> the built-in Anonymous user at /Objects/Users/Anonymous, a
3742
+ // SYSTEM node OUTSIDE Project.Current (that's why a fresh project's own
3743
+ // Security/Users is empty). Without a session user the runtime won't serve.
3744
+ // Resolve via the Objects root = Project.Current's Owner.
3745
+ bool anon = false;
3746
+ var anonUser = ResolveAnonymousUser();
3747
+ if (anonUser != null) { SetIfPresent(eng, "StartingUser", anonUser.NodeId); anon = true; }
3748
+
3749
+ // AllowedLocalSources -> Studio's default asset allow-list (empty on a fresh
3750
+ // engine -> the runtime blocks images/fonts/css/js).
3751
+ SetArrayIfPresent(eng, "AllowedLocalSources", WebEngineAllowedSources);
3752
+
3753
+ // StartWindow -> the first window in the project, else leave unset.
3754
+ string startWinName = "";
3755
+ var startWin = FindFirstWindow(ui);
3756
+ if (startWin != null)
3757
+ {
3758
+ SetIfPresent(eng, "StartWindow", startWin.NodeId);
3759
+ startWinName = startWin.BrowseName;
3760
+ }
3761
+
3762
+ return "{\"ok\":true,\"existed\":false,\"path\":\"UI/WebPresentationEngine\",\"port\":" +
3763
+ port + ",\"protocol\":\"HTTP\",\"max_connections\":" + WebEngineMaxConnections +
3764
+ ",\"styled\":" + Bool(styleSheet != null) + ",\"anonymous_user\":" + Bool(anon) +
3765
+ ",\"allowed_sources\":" + WebEngineAllowedSources.Length +
3766
+ ",\"start_window\":\"" + JsonEscape(startWinName) + "\"}";
3767
+ }
3768
+ catch (Exception ex)
3769
+ {
3770
+ return "{\"ok\":false,\"error\":\"" + JsonEscape(ExcMsg(ex)) + "\"}";
3771
+ }
3772
+ }
3773
+
3774
+ // The built-in Anonymous user (/Objects/Users/Anonymous). Project.Current is
3775
+ // /Objects/<project>, so its Owner is the Objects root; Get("Users/Anonymous")
3776
+ // from there reaches the system user. Returns null (graceful) if unresolvable.
3777
+ private IUANode ResolveAnonymousUser()
3778
+ {
3779
+ try
3780
+ {
3781
+ var objectsRoot = Project.Current.Owner;
3782
+ if (objectsRoot != null) return objectsRoot.Get("Users/Anonymous");
3783
+ }
3784
+ catch { /* leave StartingUser unset */ }
3785
+ return null;
3786
+ }
3787
+
3788
+ // Assign a String[] to a materialized-or-materializable array property.
3789
+ private static void SetArrayIfPresent(IUANode node, string name, string[] values)
3790
+ {
3791
+ var v = node.GetVariable(name);
3792
+ if (v == null && node is IUAObject obj) v = obj.GetOrCreateVariable(name);
3793
+ if (v != null) v.Value = new UAValue(values);
3794
+ }
3795
+
3796
+ // Assign a value to a materialized-or-materializable property (node-model). The
3797
+ // param is UAValue (not object) so each call site's concrete type (int/string/
3798
+ // NodeId) implicitly converts - an `object` needs an explicit (UAValue) cast at
3799
+ // the assignment site (CS0266); same reason CoerceRaw returns UAValue.
3800
+ private static void SetIfPresent(IUANode node, string name, UAValue value)
3801
+ {
3802
+ var v = node.GetVariable(name);
3803
+ if (v == null && node is IUAObject obj) v = obj.GetOrCreateVariable(name);
3804
+ if (v != null) v.Value = value;
3805
+ }
3806
+
3807
+ // First window in the project (a StartWindow candidate). MainWindow is a direct
3808
+ // WindowType child of UI; also look one level into folders (e.g. UI/Screens).
3809
+ private IUANode FindFirstWindow(IUANode ui)
3810
+ {
3811
+ foreach (var c in ui.Children)
3812
+ if (c.GetType().Name == "WindowType") return c;
3813
+ foreach (var c in ui.Children)
3814
+ if (c.GetType().Name == "Folder")
3815
+ foreach (var g in c.Children)
3816
+ if (Array.IndexOf(ScreenTypes, g.GetType().Name) >= 0) return g;
3817
+ return null;
3818
+ }
3819
+
3820
+ // Coerce a query-string value to a target variable's DataType (mirror of the
3821
+ // SetPropertyInline switch; kept separate so that validated path is untouched).
3822
+ // Returns UAValue (not object) so each typed return implicitly converts - an
3823
+ // `object` would need an explicit (UAValue) cast at the assignment site (CS0266).
3824
+ private UAValue CoerceRaw(string dtName, string raw, string firstLine)
3825
+ {
3826
+ switch (dtName)
3827
+ {
3828
+ case "Boolean": return (raw == "true" || raw == "1" || raw == "True");
3829
+ case "Int16": case "Int32": case "Int64":
3830
+ case "UInt16": case "UInt32": case "UInt64": case "Byte": case "SByte":
3831
+ return Convert.ToInt32(raw);
3832
+ case "Float": case "Double": case "Size":
3833
+ return Convert.ToDouble(raw);
3834
+ case "LocalizedText":
3835
+ return new LocalizedText(raw, QueryParam(firstLine, "locale") ?? "en-US");
3836
+ default:
3837
+ return raw;
3838
+ }
3839
+ }
3840
+
3841
+ // The 17 ExpressionEvaluator formula functions (see docs/expression-evaluator-
3842
+ // reference.md). Used by ValidateExpressionSyntax to flag an unknown call.
3843
+ private static readonly System.Collections.Generic.HashSet<string> ExprFunctions =
3844
+ new System.Collections.Generic.HashSet<string>(System.StringComparer.OrdinalIgnoreCase)
3845
+ { "max","min","avg","abs","trunc","ceil","floor","round","sqrt","sign","like",
3846
+ "isempty","if","left_of","right_of" };
3847
+
3848
+ private const string ConcatErr =
3849
+ "ExpressionEvaluator '+' is numeric-only -- it can't compose a number with text " +
3850
+ "(e.g. round(...) + \" L\" silently no-ops, even fully parenthesized). To append a " +
3851
+ "unit/label, wrap the expression in a StringFormatter node with Format like " +
3852
+ "\"{0} L\" -- not '+ \"...\"'.";
3853
+
3854
+ // FTOptix's ExpressionEvaluator '+' is NUMERIC-ONLY -- it cannot compose a number
3855
+ // with text (confirmed live 2026-07-26). Text composition needs a StringFormatter
3856
+ // node. Flag an arithmetic operator (+ - * /) adjacent (whitespace-skipped) to a
3857
+ // string literal. Low false-positive: if(c,"A","B") / left_of(x,"-") keep their
3858
+ // string args after a ',', not an arithmetic op, so they pass unflagged.
3859
+ private static string CheckNumStringConcat(string expr)
3860
+ {
3861
+ bool inStr = false; char prevSig = '\0';
3862
+ for (int i = 0; i < expr.Length; i++)
3863
+ {
3864
+ char c = expr[i];
3865
+ if (inStr) { if (c == '"') { inStr = false; prevSig = '"'; } continue; }
3866
+ if (char.IsWhiteSpace(c)) continue;
3867
+ if (c == '"')
3868
+ {
3869
+ if (prevSig == '+' || prevSig == '-' || prevSig == '*' || prevSig == '/')
3870
+ return ConcatErr;
3871
+ inStr = true; prevSig = '"'; continue;
3872
+ }
3873
+ if ((c == '+' || c == '-' || c == '*' || c == '/') && prevSig == '"')
3874
+ return ConcatErr;
3875
+ prevSig = c;
3876
+ }
3877
+ return null;
3878
+ }
3879
+
3880
+ // Structural validation of an ExpressionEvaluator formula. Optix exposes NO
3881
+ // design-time parser (confirmed by reflection: ExpressionEvaluator has only
3882
+ // Expression/ExpressionVariable + inherited Start/Stop) and validates a formula
3883
+ // only at RUNTIME (a bad one silently no-ops). This catches the common author-time
3884
+ // mistakes WITHOUT reimplementing the grammar: unbalanced ()/{}, out-of-range {N}
3885
+ // placeholders, unknown function names, unterminated strings, number+string concat.
3886
+ // String literals are skipped so parens/braces inside them don't false-positive.
3887
+ // Returns null when the formula is structurally sound, else a human-readable reason.
3888
+ private static string ValidateExpressionSyntax(string expr, int sourceCount)
3889
+ {
3890
+ if (string.IsNullOrWhiteSpace(expr)) return "expression is empty";
3891
+ var concat = CheckNumStringConcat(expr);
3892
+ if (concat != null) return concat;
3893
+ int paren = 0;
3894
+ bool inStr = false;
3895
+ var word = new StringBuilder();
3896
+ for (int i = 0; i < expr.Length; i++)
3897
+ {
3898
+ char c = expr[i];
3899
+ if (inStr) { if (c == '"') inStr = false; continue; }
3900
+ if (c == '"') { inStr = true; word.Clear(); continue; }
3901
+ if (c == '(')
3902
+ {
3903
+ string w = word.ToString();
3904
+ if (w.Length > 0 && char.IsLetter(w[0]) && !ExprFunctions.Contains(w))
3905
+ return "unknown function '" + w + "' (valid: " + string.Join(", ", ExprFunctions) + ")";
3906
+ paren++; word.Clear(); continue;
3907
+ }
3908
+ if (c == ')')
3909
+ {
3910
+ paren--;
3911
+ if (paren < 0) return "unbalanced parentheses: ')' with no matching '('";
3912
+ word.Clear(); continue;
3913
+ }
3914
+ if (c == '}') return "unbalanced braces: '}' with no matching '{'";
3915
+ if (c == '{')
3916
+ {
3917
+ int j = expr.IndexOf('}', i);
3918
+ if (j < 0) return "unbalanced braces: '{' with no matching '}'";
3919
+ string inner = expr.Substring(i + 1, j - i - 1).Trim();
3920
+ if (inner.Length > 0 && inner[0] != '#') // numeric source placeholder
3921
+ {
3922
+ int idx;
3923
+ if (!int.TryParse(inner, out idx))
3924
+ return "invalid placeholder '{" + inner + "}' (use {0},{1},... or {#name})";
3925
+ if (idx < 0 || idx >= sourceCount)
3926
+ return "placeholder {" + idx + "} but only " + sourceCount + " source(s) provided";
3927
+ }
3928
+ i = j; word.Clear(); continue;
3929
+ }
3930
+ if (char.IsLetterOrDigit(c) || c == '_') word.Append(c);
3931
+ else word.Clear();
3932
+ }
3933
+ if (inStr) return "unterminated string literal";
3934
+ if (paren > 0) return "unbalanced parentheses: " + paren + " unclosed '('";
3935
+ return null;
3936
+ }
3937
+
3938
+ private static int CountSources(string sources)
3939
+ {
3940
+ int n = 0;
3941
+ if (!string.IsNullOrEmpty(sources))
3942
+ foreach (var s in sources.Split(',')) if (s.Trim().Length > 0) n++;
3943
+ return n;
3944
+ }
3945
+
3946
+ // POST /bridge/expr/validate?expression=...&sources=comma,sep - syntax-check a
3947
+ // formula WITHOUT attaching it (the read-only sibling of the attach gate + the
3948
+ // ValidateExpression ExportMethod; all three share ValidateExpressionSyntax).
3949
+ private string ValidateExprJson(string firstLine)
3950
+ {
3951
+ string expr = QueryParam(firstLine, "expression");
3952
+ string sources = QueryParam(firstLine, "sources");
3953
+ if (string.IsNullOrEmpty(expr))
3954
+ return ErrorJson("bad_query", "required: expression (+ sources=comma,sep,node,paths)");
3955
+ int n = CountSources(sources);
3956
+ var err = ValidateExpressionSyntax(expr, n);
3957
+ if (err == null)
3958
+ return "{\"ok\":true,\"valid\":true,\"sources\":" + n + "}";
3959
+ return "{\"ok\":true,\"valid\":false,\"sources\":" + n + ",\"error\":\"" + JsonEscape(err) + "\"}";
3960
+ }
3961
+
3962
+ // Diagnostic: reflect a CLR type's public methods + properties (walking the base
3963
+ // chain) by full name across loaded assemblies. Read-only introspection to discover
3964
+ // an Optix managed API without a doc - e.g. how to validate an ExpressionEvaluator
3965
+ // at design time. Same spirit as varmembers/typeinfo.
3966
+ private static string DiagClrTypeJson(string clrName)
3967
+ {
3968
+ if (string.IsNullOrEmpty(clrName))
3969
+ return "{\"error\":{\"code\":\"bad_query\",\"message\":\"required: name=Full.Clr.TypeName\"}}";
3970
+ System.Type t = null;
3971
+ foreach (var asm in System.AppDomain.CurrentDomain.GetAssemblies())
3972
+ {
3973
+ try { var c = asm.GetType(clrName); if (c != null) { t = c; break; } } catch { }
3974
+ }
3975
+ if (t == null)
3976
+ return "{\"error\":{\"code\":\"type_not_found\",\"message\":\"no loaded CLR type: " + JsonEscape(clrName) + "\"}}";
3977
+ var sb = new StringBuilder();
3978
+ sb.Append("{\"type\":\"" + JsonEscape(t.FullName) + "\",\"assembly\":\"" +
3979
+ JsonEscape(t.Assembly.GetName().Name) + "\",\"chain\":[");
3980
+ bool firstLevel = true;
3981
+ for (var cur = t; cur != null && cur != typeof(object); cur = cur.BaseType)
3982
+ {
3983
+ if (!firstLevel) sb.Append(",");
3984
+ firstLevel = false;
3985
+ sb.Append("{\"level\":\"" + JsonEscape(cur.Name) + "\",\"methods\":[");
3986
+ bool f = true;
3987
+ foreach (var m in cur.GetMethods(BindingFlags.Public | BindingFlags.Instance |
3988
+ BindingFlags.Static | BindingFlags.DeclaredOnly))
3989
+ {
3990
+ if (m.IsSpecialName) continue;
3991
+ if (!f) sb.Append(",");
3992
+ f = false;
3993
+ var ps = string.Join(", ", m.GetParameters().Select(p => p.ParameterType.Name));
3994
+ sb.Append("\"" + JsonEscape(m.ReturnType.Name + " " + m.Name + "(" + ps + ")") + "\"");
3995
+ }
3996
+ sb.Append("],\"properties\":[");
3997
+ f = true;
3998
+ foreach (var p in cur.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
3999
+ {
4000
+ if (!f) sb.Append(",");
4001
+ f = false;
4002
+ sb.Append("\"" + JsonEscape(p.PropertyType.Name + " " + p.Name + (p.CanWrite ? " {s}" : " {r}")) + "\"");
4003
+ }
4004
+ sb.Append("]}");
4005
+ if (cur.Name == "NodeLogic" || cur.Name == "UAObject" || cur.Name == "UANode") break;
4006
+ }
4007
+ sb.Append("]}");
4008
+ return sb.ToString();
4009
+ }
4010
+
4011
+ private static NodeId ResolveEventType(string name)
4012
+ {
4013
+ var f = typeof(FTOptix.UI.ObjectTypes).GetField(name, BindingFlags.Public | BindingFlags.Static);
4014
+ if (f != null && f.GetValue(null) is NodeId id) return id;
4015
+ return null;
4016
+ }
4017
+
4018
+ // The authoritative valid-event set for wire_event: the SAME reflection surface
4019
+ // ResolveEventType resolves against (public static NodeId fields of
4020
+ // FTOptix.UI.ObjectTypes whose name ends "Event"). By construction, everything
4021
+ // returned here WOULD resolve - so the reject-with-valid-list can never lie.
4022
+ private static System.Collections.Generic.List<string> ValidUiEventNames()
4023
+ {
4024
+ var names = new System.Collections.Generic.List<string>();
4025
+ foreach (var f in typeof(FTOptix.UI.ObjectTypes).GetFields(BindingFlags.Public | BindingFlags.Static))
4026
+ {
4027
+ if (f.FieldType == typeof(NodeId) && f.Name.EndsWith("Event"))
4028
+ names.Add(f.Name);
4029
+ }
4030
+ names.Sort(System.StringComparer.Ordinal);
4031
+ return names;
4032
+ }
4033
+
4034
+ // Best-effort "did you mean" for a wrong event name. Normalizes both sides
4035
+ // (letters only, drop a trailing "event") and matches on containment either way,
4036
+ // so "click"/"Click"/"clickEvent" -> MouseClickEvent. Returns null on no match
4037
+ // (the valid_events list still carries the full authoritative set).
4038
+ private static string SuggestUiEvent(string given, System.Collections.Generic.List<string> valid)
4039
+ {
4040
+ var g = new string((given ?? "").ToLowerInvariant().Where(char.IsLetter).ToArray());
4041
+ if (g.EndsWith("event")) g = g.Substring(0, g.Length - 5);
4042
+ if (g.Length == 0) return null;
4043
+ foreach (var v in valid)
4044
+ {
4045
+ var n = v.ToLowerInvariant();
4046
+ if (n.EndsWith("event")) n = n.Substring(0, n.Length - 5);
4047
+ if (n == g || n.Contains(g) || g.Contains(n)) return v;
4048
+ }
4049
+ return null;
4050
+ }
4051
+
4052
+ // Best-effort "did you mean" for a wrong property name, sharing SuggestUiEvent's
4053
+ // algorithm: normalize both sides (letters only, lowercased) and match on
4054
+ // containment either way, so "backgroundcolor"/"colour" -> ... (whatever the
4055
+ // type declares that contains or is contained by the input). Property names have
4056
+ // no "Event" suffix to trim, so normalization is just lowercase + strip-letters.
4057
+ // Returns null on no match (the valid_properties list still carries the full set).
4058
+ private static string SuggestPropertyName(string given, System.Collections.Generic.List<string> valid)
4059
+ {
4060
+ var g = new string((given ?? "").ToLowerInvariant().Where(char.IsLetter).ToArray());
4061
+ if (g.Length == 0) return null;
4062
+ foreach (var v in valid)
4063
+ {
4064
+ var n = v.ToLowerInvariant();
4065
+ if (n == g || n.Contains(g) || g.Contains(n)) return v;
4066
+ }
4067
+ return null;
4068
+ }
4069
+
4070
+ // ---- model helpers ------------------------------------------------------
4071
+
4072
+ // GET /bridge/map?path=UI&depth=6&max=800&ids=1
4073
+ // Project map: name/type outline of a subtree in ONE call - the cheap
4074
+ // alternative to walking with repeated /bridge/nodes. A depth-exhausted
4075
+ // node reports its hidden descendant count ("n") instead of children;
4076
+ // the global node budget ("max") stops expansion with per-parent "more"
4077
+ // counts - truncation is always explicit. Placeholder-collection children
4078
+ // carry their element type ("coll") so placement rules read off the tree.
4079
+ private int MapCountDescendants(IUANode node, int cap)
4080
+ {
4081
+ int total = 0;
4082
+ foreach (var c in node.Children)
4083
+ {
4084
+ total++;
4085
+ if (total >= cap) return total;
4086
+ total += MapCountDescendants(c, cap - total);
4087
+ }
4088
+ return total;
4089
+ }
4090
+
4091
+ private void MapNodeJson(StringBuilder sb, IUANode node, string name,
4092
+ int depth, ref int budget, bool ids, string collElem)
4093
+ {
4094
+ sb.Append("{\"name\":\"" + JsonEscape(name) + "\"");
4095
+ if (collElem != null)
4096
+ sb.Append(",\"coll\":\"" + JsonEscape(collElem) + "\"");
4097
+ else
4098
+ sb.Append(",\"type\":\"" + JsonEscape(node.GetType().Name) + "\"");
4099
+ if (ids)
4100
+ sb.Append(",\"id\":\"" + JsonEscape(node.NodeId.ToString()) + "\"");
4101
+ var deref = MapDeref(node);
4102
+ if (deref != null)
4103
+ sb.Append(",\"ref\":\"" + JsonEscape(deref) + "\"");
4104
+ var kids = node.Children.ToList();
4105
+ if (kids.Count > 0)
4106
+ {
4107
+ if (depth <= 0 || budget <= 0)
4108
+ {
4109
+ sb.Append(",\"n\":" + MapCountDescendants(node, 100000));
4110
+ }
4111
+ else
4112
+ {
4113
+ var collMap = new Dictionary<string, string>();
4114
+ foreach (var pi in node.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
4115
+ {
4116
+ if (!IsPlaceholderColl(pi.PropertyType) && !IsPlaceholderRoColl(pi.PropertyType)) continue;
4117
+ var elem = PlaceholderElementType(pi.PropertyType);
4118
+ if (elem != null) collMap[pi.Name] = elem.Name;
4119
+ }
4120
+ sb.Append(",\"children\":[");
4121
+ int emitted = 0;
4122
+ foreach (var c in kids)
4123
+ {
4124
+ if (budget <= 0) break;
4125
+ budget--;
4126
+ if (emitted++ > 0) sb.Append(",");
4127
+ string ce;
4128
+ collMap.TryGetValue(c.BrowseName, out ce);
4129
+ MapNodeJson(sb, c, c.BrowseName, depth - 1, ref budget, ids, ce);
4130
+ }
4131
+ sb.Append("]");
4132
+ if (emitted < kids.Count)
4133
+ sb.Append(",\"more\":" + (kids.Count - emitted));
4134
+ }
4135
+ }
4136
+ sb.Append("}");
4137
+ }
4138
+
4139
+ private static bool MapIsFolder(IUANode node)
4140
+ { return node.GetType().Name.EndsWith("Folder"); }
4141
+
4142
+ private static bool MapIsLeaf(IUANode node)
4143
+ {
4144
+ // variables / methods are plumbing at orientation altitude
4145
+ var nc = node.NodeClass.ToString();
4146
+ return nc.Contains("Variable") || nc.Contains("Method");
4147
+ }
4148
+
4149
+ // Overview walk: folders expand recursively; a COMPONENT (non-folder
4150
+ // object) is a single line + descendant count - its properties appear
4151
+ // only when the caller scopes the map to it (mode auto -> full). Leaf
4152
+ // plumbing (variables/methods) folds into the parent's "skip" count.
4153
+ private void MapOverviewJson(StringBuilder sb, IUANode node, string name,
4154
+ int depth, ref int budget, bool ids, string collElem)
4155
+ {
4156
+ sb.Append("{\"name\":\"" + JsonEscape(name) + "\"");
4157
+ if (collElem != null)
4158
+ sb.Append(",\"coll\":\"" + JsonEscape(collElem) + "\"");
4159
+ else
4160
+ sb.Append(",\"type\":\"" + JsonEscape(node.GetType().Name) + "\"");
4161
+ if (ids)
4162
+ sb.Append(",\"id\":\"" + JsonEscape(node.NodeId.ToString()) + "\"");
4163
+ var kids = node.Children.ToList();
4164
+ if (kids.Count > 0)
4165
+ {
4166
+ if (!MapIsFolder(node) || depth <= 0 || budget <= 0)
4167
+ {
4168
+ // component boundary (or exhausted): compress to a count
4169
+ sb.Append(",\"n\":" + MapCountDescendants(node, 100000));
4170
+ }
4171
+ else
4172
+ {
4173
+ var collMap = new Dictionary<string, string>();
4174
+ foreach (var pi in node.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
4175
+ {
4176
+ if (!IsPlaceholderColl(pi.PropertyType) && !IsPlaceholderRoColl(pi.PropertyType)) continue;
4177
+ var elem = PlaceholderElementType(pi.PropertyType);
4178
+ if (elem != null) collMap[pi.Name] = elem.Name;
4179
+ }
4180
+ sb.Append(",\"children\":[");
4181
+ int emitted = 0, skipped = 0;
4182
+ foreach (var c in kids)
4183
+ {
4184
+ if (MapIsLeaf(c)) { skipped++; continue; }
4185
+ if (budget <= 0) break;
4186
+ budget--;
4187
+ if (emitted++ > 0) sb.Append(",");
4188
+ string ce;
4189
+ collMap.TryGetValue(c.BrowseName, out ce);
4190
+ MapOverviewJson(sb, c, c.BrowseName, depth - 1, ref budget, ids, ce);
4191
+ }
4192
+ sb.Append("]");
4193
+ int unshown = kids.Count - emitted - skipped;
4194
+ if (unshown > 0) sb.Append(",\"more\":" + unshown);
4195
+ if (skipped > 0) sb.Append(",\"vars\":" + skipped);
4196
+ }
4197
+ }
4198
+ sb.Append("}");
4199
+ }
4200
+
4201
+ // Project-relative path of a node (walk the Owner chain up to the root).
4202
+ private string NodePathOf(IUANode node)
4203
+ {
4204
+ try
4205
+ {
4206
+ var parts = new List<string>();
4207
+ var cur = node;
4208
+ var rootId = Project.Current.NodeId;
4209
+ int guard = 0;
4210
+ while (cur != null && guard++ < 64)
4211
+ {
4212
+ if (cur.NodeId == rootId || cur.NodeId.Equals(rootId)) break;
4213
+ parts.Add(cur.BrowseName);
4214
+ cur = cur.Owner;
4215
+ }
4216
+ parts.Reverse();
4217
+ return string.Join("/", parts);
4218
+ }
4219
+ catch { return null; }
4220
+ }
4221
+
4222
+ // Inline dereference for the detail walk: a NodePointer/Alias value is a
4223
+ // NodeId (resolved to a project path), a DynamicLink value is already a
4224
+ // NodePath string. Kills the describe-per-pointer round trip: a detail
4225
+ // map doubles as a wiring audit.
4226
+ private string MapDeref(IUANode node)
4227
+ {
4228
+ try
4229
+ {
4230
+ string tn = node.GetType().Name;
4231
+ var v = node as IUAVariable;
4232
+ if (v == null || v.Value == null) return null;
4233
+ if (tn == "NodePointer" || tn == "Alias")
4234
+ {
4235
+ var nid = v.Value.Value as NodeId;
4236
+ if (nid == null) return null;
4237
+ var target = InformationModel.Get(nid);
4238
+ if (target == null) return null;
4239
+ var p = NodePathOf(target);
4240
+ return string.IsNullOrEmpty(p) ? target.BrowseName : p;
4241
+ }
4242
+ if (tn == "DynamicLink")
4243
+ {
4244
+ // The stored NodePath is absolute ("/Objects/<Project>/Model/X")
4245
+ // or owner-relative ("../..") or an attribute ref ("...@BrowseName").
4246
+ // Re-render the absolute child form as the project-relative path
4247
+ // every bridge tool accepts (paste-ready); leave the other forms
4248
+ // as stored - they are not node targets.
4249
+ var raw = v.Value.Value as string;
4250
+ if (string.IsNullOrEmpty(raw)) return null;
4251
+ var absPrefix = "/Objects/" + Project.Current.BrowseName + "/";
4252
+ if (raw.StartsWith(absPrefix))
4253
+ return raw.Substring(absPrefix.Length);
4254
+ return raw;
4255
+ }
4256
+ }
4257
+ catch { }
4258
+ return null;
4259
+ }
4260
+
4261
+ // match= search: case-insensitive, '*' wildcard, against node NAME or
4262
+ // TYPE name. Returns flat full paths - ancestry rides in the path itself.
4263
+ private static bool MapMatch(string s, string pattern)
4264
+ {
4265
+ if (s == null) return false;
4266
+ var rx = "^" + System.Text.RegularExpressions.Regex.Escape(pattern)
4267
+ .Replace("\\*", ".*") + "$";
4268
+ return System.Text.RegularExpressions.Regex.IsMatch(
4269
+ s, rx, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
4270
+ }
4271
+
4272
+ private void MapSearch(IUANode node, string prefix, string pattern,
4273
+ List<string> hits, ref int visited, int maxVisit, int maxHits)
4274
+ {
4275
+ foreach (var c in node.Children)
4276
+ {
4277
+ if (++visited > maxVisit || hits.Count >= maxHits) return;
4278
+ string p = prefix.Length == 0 ? c.BrowseName : prefix + "/" + c.BrowseName;
4279
+ string tn = c.GetType().Name;
4280
+ if (MapMatch(c.BrowseName, pattern) || MapMatch(tn, pattern))
4281
+ hits.Add("{\"path\":\"" + JsonEscape(p) + "\",\"type\":\"" + JsonEscape(tn) + "\"}");
4282
+ MapSearch(c, p, pattern, hits, ref visited, maxVisit, maxHits);
4283
+ }
4284
+ }
4285
+
4286
+ private string MapSearchJson(string path, string pattern, int maxHits)
4287
+ {
4288
+ IUANode root = string.IsNullOrEmpty(path) ? Project.Current : ResolveNode(path);
4289
+ if (root == null) return null;
4290
+ var hits = new List<string>();
4291
+ int visited = 0;
4292
+ MapSearch(root, string.IsNullOrEmpty(path) ? "" : path.TrimEnd('/'),
4293
+ pattern, hits, ref visited, 50000, maxHits);
4294
+ return "{\"path\":\"" + JsonEscape(path ?? "") + "\",\"mode\":\"search\"" +
4295
+ ",\"match\":\"" + JsonEscape(pattern) + "\"" +
4296
+ ",\"matches\":[" + string.Join(",", hits) + "]" +
4297
+ ",\"visited\":" + visited +
4298
+ ",\"hits_capped\":" + (hits.Count >= maxHits ? "true" : "false") + "}";
4299
+ }
4300
+
4301
+ private string ProjectMapJson(string path, int depth, int max, bool ids, string mode)
4302
+ {
4303
+ IUANode root = string.IsNullOrEmpty(path) ? Project.Current : ResolveNode(path);
4304
+ if (root == null) return null;
4305
+ string rootName = string.IsNullOrEmpty(path)
4306
+ ? root.BrowseName : path.TrimEnd('/').Split('/').Last();
4307
+ // auto: orientation for folders, detail for a component. "detail" =
4308
+ // every node kind shown at the requested depth (NOT "fully expanded").
4309
+ string effective = mode == "full" ? "detail" : mode;
4310
+ if (mode == "auto")
4311
+ effective = MapIsFolder(root) ? "overview" : "detail";
4312
+ var sb = new StringBuilder();
4313
+ int budget = max;
4314
+ sb.Append("{\"path\":\"" + JsonEscape(path ?? "") + "\",\"mode\":\"" + effective + "\",\"map\":");
4315
+ if (effective == "overview")
4316
+ MapOverviewJson(sb, root, rootName, Math.Max(depth, 8), ref budget, ids, null);
4317
+ else
4318
+ MapNodeJson(sb, root, rootName, depth, ref budget, ids, null);
4319
+ // (effective mode rides in the header so callers never misread
4320
+ // "detail at depth 1" as "the whole tree")
4321
+ sb.Append(",\"budget_left\":" + budget + "}");
4322
+ return sb.ToString();
4323
+ }
4324
+
4325
+ // Arya-owned typed design-time operations. No arbitrary method dispatch.
4326
+ // API source: FactoryTalk-Optix/NetLogic_CheatSheet/pages/template-library.md
4327
+ // ImportLibraryItem is supported from Optix 1.6; the shipped template targets 1.7.
4328
+ private string AryaLibraryKey => "ARYA_LIBRARY_" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Project.Current.NodeId.ToString())));
4329
+
4330
+ private string AryaImportLibraryItem(string request)
4331
+ {
4332
+ if (QueryParam(request, "project") != Project.Current.BrowseName)
4333
+ return ErrorJson("project_mismatch", "Request does not match this Studio project");
4334
+ string parent = QueryParam(request, "parent"), library = QueryParam(request, "library"), item = QueryParam(request, "item"), kind = QueryParam(request, "node_class");
4335
+ string ticket = QueryParam(request, "ticket");
4336
+ if (!Guid.TryParseExact(ticket, "N", out _) || !AryaRelativePath(parent) || !AryaName(library) || !AryaRelativePath(item) || (kind != "Object" && kind != "ObjectType"))
4337
+ return ErrorJson("invalid_import", "Use an inspected library item and a fresh empty destination folder");
4338
+ var destination = ResolveNode(parent);
4339
+ if (!(destination is FTOptix.Core.Folder) || destination.Children.Any())
4340
+ return ErrorJson("destination_not_empty_folder", "Import requires a fresh empty folder");
4341
+ using var mutex = new Mutex(false, "Local\\AryaLibraryImport_" + System.Diagnostics.Process.GetCurrentProcess().Id);
4342
+ mutex.WaitOne();
4343
+ try
4344
+ {
4345
+ string key = AryaLibraryKey;
4346
+ string state = Environment.GetEnvironmentVariable(key + "_STATE");
4347
+ if (state == "queued" || state == "executing") return ErrorJson("import_busy", "Reconcile the existing import before another request");
4348
+ Environment.SetEnvironmentVariable(key + "_REQUEST", request);
4349
+ Environment.SetEnvironmentVariable(key + "_TICKET", ticket);
4350
+ Environment.SetEnvironmentVariable(key + "_EXPIRES", DateTime.UtcNow.AddSeconds(90).Ticks.ToString());
4351
+ Environment.SetEnvironmentVariable(key + "_RESULT", null);
4352
+ Environment.SetEnvironmentVariable(key + "_STATE", "queued");
4353
+ return "{\"ok\":true,\"state\":\"queued\",\"ticket\":\"" + ticket + "\"}";
4354
+ }
4355
+ finally { mutex.ReleaseMutex(); }
4356
+ }
4357
+
4358
+ // Fixed typed import only. Invoked through Studio's design-time Execute menu,
4359
+ // which supplies the vendor context missing on the raw TCP listener thread.
4360
+ [ExportMethod]
4361
+ public void ImportQueuedLibraryItem()
4362
+ {
4363
+ string request, key = AryaLibraryKey;
4364
+ using (var mutex = new Mutex(false, "Local\\AryaLibraryImport_" + System.Diagnostics.Process.GetCurrentProcess().Id))
4365
+ {
4366
+ mutex.WaitOne();
4367
+ try
4368
+ {
4369
+ if (Environment.GetEnvironmentVariable(key + "_STATE") != "queued") return;
4370
+ if (!long.TryParse(Environment.GetEnvironmentVariable(key + "_EXPIRES"), out long expires) || DateTime.UtcNow.Ticks > expires)
4371
+ {
4372
+ Environment.SetEnvironmentVariable(key + "_RESULT", ErrorJson("import_expired", "Queued import expired without dispatch"));
4373
+ Environment.SetEnvironmentVariable(key + "_STATE", "completed");
4374
+ return;
4375
+ }
4376
+ request = Environment.GetEnvironmentVariable(key + "_REQUEST");
4377
+ Environment.SetEnvironmentVariable(key + "_REQUEST", null);
4378
+ Environment.SetEnvironmentVariable(key + "_STATE", "executing");
4379
+ }
4380
+ finally { mutex.ReleaseMutex(); }
4381
+ }
4382
+ string result;
4383
+ try
4384
+ {
4385
+ string parent = QueryParam(request, "parent"), library = QueryParam(request, "library"), item = QueryParam(request, "item"), kind = QueryParam(request, "node_class");
4386
+ var destination = ResolveNode(parent);
4387
+ if (QueryParam(request, "project") != Project.Current.BrowseName || !AryaRelativePath(parent) || !AryaName(library) || !AryaRelativePath(item) || (kind != "Object" && kind != "ObjectType"))
4388
+ result = ErrorJson("invalid_import", "Queued project or import parameters are invalid");
4389
+ else if (!(destination is FTOptix.Core.Folder) || destination.Children.Any())
4390
+ result = ErrorJson("destination_changed", "Destination changed before execution");
4391
+ else
4392
+ {
4393
+ var imported = TemplateLibrary.ImportLibraryItem(destination, kind == "ObjectType" ? NodeClass.ObjectType : NodeClass.Object, library, item);
4394
+ result = imported == null ? ErrorJson("import_unconfirmed", "No node returned; reconcile before retrying") :
4395
+ "{\"ok\":true,\"state\":\"completed\",\"created_path\":\"" + JsonEscape(parent + "/" + imported.BrowseName) + "\",\"requires_dependency_readback\":true}";
4396
+ }
4397
+ }
4398
+ catch (Exception ex) { result = ErrorJson("library_import_failed", ExcMsg(ex) + "; reconcile before retrying"); }
4399
+ Environment.SetEnvironmentVariable(key + "_RESULT", result);
4400
+ Environment.SetEnvironmentVariable(key + "_STATE", "completed");
4401
+ }
4402
+
4403
+ private string AryaLibraryResult(string request)
4404
+ {
4405
+ string key = AryaLibraryKey;
4406
+ if (QueryParam(request, "project") != Project.Current.BrowseName || QueryParam(request, "ticket") != Environment.GetEnvironmentVariable(key + "_TICKET"))
4407
+ return ErrorJson("import_ticket_mismatch", "This result belongs to another request");
4408
+ string result = Environment.GetEnvironmentVariable(key + "_RESULT");
4409
+ return result ?? "{\"ok\":true,\"state\":\"pending\"}";
4410
+ }
4411
+
4412
+ private static bool AryaName(string value)
4413
+ {
4414
+ return !string.IsNullOrWhiteSpace(value) && value.Length <= 128 && value != "." && value != ".." &&
4415
+ value.IndexOfAny(new char[] { '/', '\\', '\0', '\r', '\n' }) < 0;
4416
+ }
4417
+ private static bool AryaRelativePath(string value)
4418
+ {
4419
+ return !string.IsNullOrEmpty(value) && value.Length <= 512 && value.Split('/').All(AryaName);
4420
+ }
4421
+ private string AryaSecurity(string request)
4422
+ {
4423
+ if (QueryParam(request, "project") != Project.Current.BrowseName)
4424
+ return ErrorJson("project_mismatch", "Request does not match this Studio project");
4425
+ string action = QueryParam(request, "action");
4426
+ string name = QueryParam(request, "name");
4427
+ string roleName = QueryParam(request, "role");
4428
+ if (!AryaName(name) || (action != "create_user" && action != "create_role" && action != "assign_role") ||
4429
+ (action == "assign_role" ? !AryaName(roleName) : !string.IsNullOrEmpty(roleName)))
4430
+ return ErrorJson("invalid_security_operation", "Only named User/Role creation and role assignment are supported");
4431
+ try
4432
+ {
4433
+ var users = ResolveNode("Security/Users");
4434
+ var roles = ResolveNode("Security/Roles");
4435
+ if (!(users is FTOptix.Core.Folder) || !(roles is FTOptix.Core.Folder))
4436
+ return ErrorJson("security_folders_missing", "Inspect the project's actual Security/Users and Security/Roles folders");
4437
+ if (action == "assign_role")
4438
+ {
4439
+ var user = users.Get<FTOptix.Core.User>(name);
4440
+ var role = roles.Get<FTOptix.Core.Role>(roleName);
4441
+ if (user == null || role == null) return ErrorJson("security_node_missing", "Existing typed user and role are required");
4442
+ user.Refs.AddReference(FTOptix.Core.ReferenceTypes.HasRole, role.NodeId);
4443
+ return "{\"ok\":true,\"user\":\"" + JsonEscape(name) + "\",\"role\":\"" + JsonEscape(roleName) + "\",\"requires_readback\":true}";
4444
+ }
4445
+ var folder = action == "create_user" ? users : roles;
4446
+ if (folder.Children.Any(n => n.BrowseName == name)) return ErrorJson("duplicate_security_node", "Node exists; inspect it instead of recreating it");
4447
+ IUANode created = action == "create_user" ? (IUANode)InformationModel.Make<FTOptix.Core.User>(name) : InformationModel.Make<FTOptix.Core.Role>(name);
4448
+ folder.Add(created);
4449
+ return "{\"ok\":true,\"created_path\":\"Security/" + (action == "create_user" ? "Users/" : "Roles/") + JsonEscape(name) + "\",\"requires_save\":true}";
4450
+ }
4451
+ catch (Exception ex) { return ErrorJson("security_operation_failed", ExcMsg(ex)); }
4452
+ }
4453
+
4454
+ private IUANode ResolveNode(string path)
4455
+ {
4456
+ try { return Project.Current.Get(path); }
4457
+ catch { return null; }
4458
+ }
4459
+
4460
+ private string DataTypeName(IUAVariable v)
4461
+ {
4462
+ try
4463
+ {
4464
+ var dt = InformationModel.Get(v.DataType);
4465
+ return dt != null ? dt.BrowseName : v.DataType.ToString();
4466
+ }
4467
+ catch { return "unknown"; }
4468
+ }
4469
+
4470
+ // Datatype for DESCRIBE output: array-typed variables get a "[]" suffix
4471
+ // ("NodeId[]") so callers can see the array-ness that set_property rejects
4472
+ // (unsupported_array_write) - DataTypeName alone hides it.
4473
+ private string DataTypeNameFull(IUAVariable v)
4474
+ {
4475
+ return DataTypeName(v) + (IsArrayVariable(v) ? "[]" : "");
4476
+ }
4477
+
4478
+ // root-cause fix for a false "broken link" diagnosis.
4479
+ // describe_node (NodeJson) called this for EVERY property, including
4480
+ // DynamicLink/Alias children, via UAValue.ToString() - which returns
4481
+ // blank for a NodePath-boxed value (confirmed live: Studio's own
4482
+ // Properties panel showed VFD_2411's INPUT_PATH/OUTPUT_PATH/SETTINGS_PATH
4483
+ // fully populated with template-placeholder paths like
4484
+ // ".../VFD_{#id}&:I@NodeId", while describe_node reported them as empty
4485
+ // strings, 3 aliases x 9 VFD instances - a tooling bug, not a project
4486
+ // defect). MapDeref (used by the project-map tree) already had the correct
4487
+ // extraction for this exact case: unwrap UAValue.Value as a string FIRST
4488
+ // (works for NodePath/String-backed values), and only fall back to
4489
+ // ToString() for value types (Int32, Boolean, enums, ...) where ToString()
4490
+ // is correct. Copying that proven pattern here instead of leaving
4491
+ // describe_node on the broken path.
4492
+ private string ValueString(IUAVariable v)
4493
+ {
4494
+ try
4495
+ {
4496
+ if (v.Value == null) return "null";
4497
+ var raw = v.Value.Value as string;
4498
+ if (raw != null) return raw;
4499
+ return v.Value.ToString();
4500
+ }
4501
+ catch { return "unreadable"; }
4502
+ }
4503
+
4504
+ // ---- HTTP / JSON plumbing ----------------------------------------------
4505
+
4506
+ private static void WriteResponse(NetworkStream stream, string status, string body)
4507
+ {
4508
+ byte[] bodyBytes = System.Text.Encoding.UTF8.GetBytes(body);
4509
+ string headers =
4510
+ "HTTP/1.1 " + status + "\r\n" +
4511
+ "Content-Type: application/json\r\n" +
4512
+ "Content-Length: " + bodyBytes.Length + "\r\n" +
4513
+ "Connection: close\r\n\r\n";
4514
+ byte[] headerBytes = System.Text.Encoding.ASCII.GetBytes(headers);
4515
+ stream.Write(headerBytes, 0, headerBytes.Length);
4516
+ stream.Write(bodyBytes, 0, bodyBytes.Length);
4517
+ stream.Flush();
4518
+ }
4519
+
4520
+ private static string QueryParam(string firstLine, string key)
4521
+ {
4522
+ int q = firstLine.IndexOf('?');
4523
+ if (q < 0) return null;
4524
+ int sp = firstLine.IndexOf(' ', q);
4525
+ string query = sp > q ? firstLine.Substring(q + 1, sp - q - 1) : firstLine.Substring(q + 1);
4526
+ foreach (var pair in query.Split('&'))
4527
+ {
4528
+ var kv = pair.Split(new[] { '=' }, 2);
4529
+ if (kv.Length == 2 && kv[0] == key)
4530
+ {
4531
+ try { return Uri.UnescapeDataString(kv[1]); }
4532
+ catch { return kv[1]; }
4533
+ }
4534
+ }
4535
+ return null;
4536
+ }
4537
+
4538
+ private static string ErrorJson(string code, string message)
4539
+ {
4540
+ return "{\"error\":{\"code\":\"" + JsonEscape(code) +
4541
+ "\",\"message\":\"" + JsonEscape(message) + "\"}}";
4542
+ }
4543
+
4544
+ private static string Bool(bool b) { return b ? "true" : "false"; }
4545
+
4546
+ // Type + message for an exception, the payload of every write handler's catch.
4547
+ private static string ExcMsg(Exception ex) { return ex.GetType().Name + ": " + ex.Message; }
4548
+
4549
+ private static string JsonEscape(string s)
4550
+ {
4551
+ if (s == null) return "";
4552
+ return s.Replace("\\", "\\\\").Replace("\"", "\\\"")
4553
+ .Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
4554
+ }
4555
+ }