@aryacode/dsh-engineering 0.4.4 → 0.4.6
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.
- package/CHANGELOG.md +45 -0
- package/README.md +11 -8
- package/cordis.patch.yml +3 -2
- package/package.json +3 -2
- package/vendor/l5x/cip-worker.mjs +181 -0
- package/vendor/l5x/lib/core/params.d.ts +3 -1
- package/vendor/l5x/lib/core/params.js +4 -0
- package/vendor/l5x/lib/engine/cip-request.d.ts +85 -0
- package/vendor/l5x/lib/engine/cip-request.js +134 -0
- package/vendor/l5x/lib/engine/cip-transaction.d.ts +115 -0
- package/vendor/l5x/lib/engine/cip-transaction.js +185 -0
- package/vendor/l5x/lib/live.d.ts +5 -0
- package/vendor/l5x/lib/live.js +13 -2
- package/vendor/l5x/lib/tools/live.d.ts +17 -6
- package/vendor/l5x/lib/tools/live.js +177 -15
- package/vendor/optix/bridge/LICENSE +35 -0
- package/vendor/optix/bridge/StudioMCPBridge.cs +7022 -0
- package/vendor/optix/bridge/source.json +10 -0
- package/vendor/optix/icon.svg +13 -0
- package/vendor/optix/lib/index.js +1531 -0
- package/vendor/optix/lib/types/bridge.d.ts +77 -0
- package/vendor/optix/lib/types/index.d.ts +52 -0
- package/vendor/optix/lib/types/ocr.d.ts +46 -0
- package/vendor/optix/lib/types/operations.d.ts +42 -0
- package/vendor/optix/lib/types/preview.d.ts +33 -0
- package/vendor/optix/lib/types/templates.d.ts +18 -0
- package/vendor/optix/locale/en.json +6 -0
- package/vendor/optix/locale/zh.json +6 -0
- package/vendor/optix/templates/arya-optix-ipc/manifest.json +260 -0
- package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix +81 -0
- package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix.design +38 -0
- package/vendor/optix/templates/arya-optix-ipc/project/IDEVersion.txt +1 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Alarms/Alarms.yaml +3 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/AryaNewHMIProject_IPC.yaml +67 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/CommDrivers/CommDrivers.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Converters/Converters.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/DataStores/DataStores.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Loggers/Loggers.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/MQTT/MQTT.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Model/Model.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/NetLogic/NetLogic.yaml +28 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/OPC-UA/OPC-UA.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Recipes/Recipes.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Reports/Reports.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Retentivity/Retentivity.yaml +57 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Groups/Groups.yaml +3 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Roles/Roles.yaml +3 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Security.yaml +7 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Users/Users.yaml +3 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/System/System.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Translations/Translations.yaml +16 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Screens/Screens.yaml +2 -0
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/UI.yaml +306 -0
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/.vscode/launch.json +19 -0
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.code-workspace +10 -0
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.csproj +19 -0
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.references +172 -0
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.sln +21 -0
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +20 -0
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +11 -0
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4555 -0
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/UserDefinedModule.xml +26 -0
- package/vendor/optix/templates/arya-optix-panel/manifest.json +411 -0
- package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix +97 -0
- package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix.design +38 -0
- package/vendor/optix/templates/arya-optix-panel/project/IDEVersion.txt +1 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Alarms/Alarms.yaml +3 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/AryaNewHMIProject_OptixPanel.yaml +67 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/CommDrivers/CommDrivers.yaml +2 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Converters/Converters.yaml +2 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/DataStores/DataStores.yaml +2 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Loggers/Loggers.yaml +2 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/MQTT/MQTT.yaml +2 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Model/Model.yaml +2 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/NetLogic/NetLogic.yaml +28 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/OPC-UA/OPC-UA.yaml +2 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Recipes/Recipes.yaml +2 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Reports/Reports.yaml +2 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Retentivity/Retentivity.yaml +57 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Groups/Groups.yaml +3 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Roles/Roles.yaml +3 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Security.yaml +7 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Users/Users.yaml +3 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/System/System.yaml +444 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Translations/Translations.yaml +81 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Screens/Screens.yaml +2 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidget.yaml +163 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidgetComponents/DeviceSettingsWidgetComponents.yaml +1663 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FTRemoteAccessWidgetComponents/FTRemoteAccessWidgetComponents.yaml +2471 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FactoryTalkRemoteAccessWidget.yaml +218 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidget.yaml +524 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidgetComponents/NetworkSettingsWidgetComponents.yaml +690 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/SettingsWidgets.yaml +1265 -0
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/UI.yaml +308 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/.vscode/launch.json +19 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ApplicationNameLogic.cs +19 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.code-workspace +10 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.csproj +19 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.references +172 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.sln +21 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ConnectionSwitchLogic.cs +74 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DateAndTimeWidgetLogic.cs +420 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DeviceSettingsWidgetLogic.cs +34 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTOptixStudioVersionLogic.cs +47 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTRemoteAccessWidgetSetupLogic.cs +153 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionApprovalLogic.cs +118 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionReceiverLogic.cs +166 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkConfiguratorEditModelsLogic.cs +56 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfaceLogic.cs +138 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelReaderLogic.cs +87 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelToSystemWriterLogic.cs +139 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkSettingsLogic.cs +168 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/FTRemoteAccessWidgetDataObject.cs +106 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +37 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +91 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/RequestAssistanceSwitchLogic.cs +93 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4555 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/SystemToNetworkInterfacesEditModelWriterLogic.cs +77 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/UserDefinedModule.xml +111 -0
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/WarningIcon.svg +4 -0
package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs
ADDED
|
@@ -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
|
+
}
|