@aryacode/dsh-engineering 0.0.0-stage → 0.3.0
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 +115 -0
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/cordis.patch.yml +122 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +77 -3
- package/vendor/ftview/README.md +55 -0
- package/vendor/ftview/SKILL.md +74 -0
- package/vendor/ftview/lib/checks.d.ts +27 -0
- package/vendor/ftview/lib/checks.js +39 -0
- package/vendor/ftview/lib/core.d.ts +208 -0
- package/vendor/ftview/lib/core.js +160 -0
- package/vendor/ftview/lib/edit/drafts.d.ts +49 -0
- package/vendor/ftview/lib/edit/drafts.js +84 -0
- package/vendor/ftview/lib/edit/operations.d.ts +62 -0
- package/vendor/ftview/lib/edit/operations.js +126 -0
- package/vendor/ftview/lib/edit/prepare.d.ts +38 -0
- package/vendor/ftview/lib/edit/prepare.js +133 -0
- package/vendor/ftview/lib/edit/tools.d.ts +25 -0
- package/vendor/ftview/lib/edit/tools.js +198 -0
- package/vendor/ftview/lib/edit.d.ts +30 -0
- package/vendor/ftview/lib/edit.js +38 -0
- package/vendor/ftview/lib/index.d.ts +31 -0
- package/vendor/ftview/lib/index.js +36 -0
- package/vendor/ftview/lib/model.d.ts +80 -0
- package/vendor/ftview/lib/model.js +267 -0
- package/vendor/ftview/lib/session.d.ts +48 -0
- package/vendor/ftview/lib/session.js +98 -0
- package/vendor/ftview/lib/skill.d.ts +29 -0
- package/vendor/ftview/lib/skill.js +43 -0
- package/vendor/ftview/lib/studio/discover.d.ts +36 -0
- package/vendor/ftview/lib/studio/discover.js +87 -0
- package/vendor/ftview/lib/studio/preflight.d.ts +51 -0
- package/vendor/ftview/lib/studio/preflight.js +64 -0
- package/vendor/ftview/lib/studio/tools.d.ts +15 -0
- package/vendor/ftview/lib/studio/tools.js +83 -0
- package/vendor/ftview/lib/studio.d.ts +30 -0
- package/vendor/ftview/lib/studio.js +37 -0
- package/vendor/ftview/lib/tools.d.ts +14 -0
- package/vendor/ftview/lib/tools.js +202 -0
- package/vendor/l5x/README.md +62 -0
- package/vendor/l5x/SKILL.md +81 -0
- package/vendor/l5x/lib/approvals.d.ts +50 -0
- package/vendor/l5x/lib/approvals.js +93 -0
- package/vendor/l5x/lib/client.js +412 -0
- package/vendor/l5x/lib/client.js.map +1 -0
- package/vendor/l5x/lib/core/errors.d.ts +38 -0
- package/vendor/l5x/lib/core/errors.js +48 -0
- package/vendor/l5x/lib/core/params.d.ts +49 -0
- package/vendor/l5x/lib/core/params.js +39 -0
- package/vendor/l5x/lib/core/result.d.ts +46 -0
- package/vendor/l5x/lib/core/result.js +21 -0
- package/vendor/l5x/lib/core/tool.d.ts +102 -0
- package/vendor/l5x/lib/core/tool.js +19 -0
- package/vendor/l5x/lib/drafts.d.ts +66 -0
- package/vendor/l5x/lib/drafts.js +98 -0
- package/vendor/l5x/lib/editing.d.ts +30 -0
- package/vendor/l5x/lib/editing.js +42 -0
- package/vendor/l5x/lib/engine/acd-edit.d.ts +13 -0
- package/vendor/l5x/lib/engine/acd-edit.js +115 -0
- package/vendor/l5x/lib/engine/analysis.d.ts +22 -0
- package/vendor/l5x/lib/engine/analysis.js +258 -0
- package/vendor/l5x/lib/engine/capabilities.d.ts +9 -0
- package/vendor/l5x/lib/engine/capabilities.js +40 -0
- package/vendor/l5x/lib/engine/change-set.d.ts +122 -0
- package/vendor/l5x/lib/engine/change-set.js +446 -0
- package/vendor/l5x/lib/engine/configuration.d.ts +21 -0
- package/vendor/l5x/lib/engine/configuration.js +42 -0
- package/vendor/l5x/lib/engine/details.d.ts +47 -0
- package/vendor/l5x/lib/engine/details.js +173 -0
- package/vendor/l5x/lib/engine/direct-polling.d.ts +91 -0
- package/vendor/l5x/lib/engine/direct-polling.js +193 -0
- package/vendor/l5x/lib/engine/download-review.d.ts +15 -0
- package/vendor/l5x/lib/engine/download-review.js +63 -0
- package/vendor/l5x/lib/engine/logic-syntax.d.ts +60 -0
- package/vendor/l5x/lib/engine/logic-syntax.js +237 -0
- package/vendor/l5x/lib/engine/observations.d.ts +41 -0
- package/vendor/l5x/lib/engine/observations.js +67 -0
- package/vendor/l5x/lib/engine/project-analysis.d.ts +131 -0
- package/vendor/l5x/lib/engine/project-analysis.js +314 -0
- package/vendor/l5x/lib/engine/review-evidence.d.ts +8 -0
- package/vendor/l5x/lib/engine/review-evidence.js +49 -0
- package/vendor/l5x/lib/engine/review.d.ts +18 -0
- package/vendor/l5x/lib/engine/review.js +123 -0
- package/vendor/l5x/lib/engine/studio-sdk.d.ts +101 -0
- package/vendor/l5x/lib/engine/studio-sdk.js +571 -0
- package/vendor/l5x/lib/engine/tag-resolution.d.ts +20 -0
- package/vendor/l5x/lib/engine/tag-resolution.js +169 -0
- package/vendor/l5x/lib/engine/usage.d.ts +16 -0
- package/vendor/l5x/lib/engine/usage.js +92 -0
- package/vendor/l5x/lib/engine/xml-regions.d.ts +23 -0
- package/vendor/l5x/lib/engine/xml-regions.js +77 -0
- package/vendor/l5x/lib/fs.d.ts +17 -0
- package/vendor/l5x/lib/fs.js +36 -0
- package/vendor/l5x/lib/index.d.ts +36 -0
- package/vendor/l5x/lib/index.js +42 -0
- package/vendor/l5x/lib/live.d.ts +48 -0
- package/vendor/l5x/lib/live.js +48 -0
- package/vendor/l5x/lib/model/aois.d.ts +39 -0
- package/vendor/l5x/lib/model/aois.js +112 -0
- package/vendor/l5x/lib/model/errors.d.ts +15 -0
- package/vendor/l5x/lib/model/errors.js +18 -0
- package/vendor/l5x/lib/model/index.d.ts +20 -0
- package/vendor/l5x/lib/model/index.js +19 -0
- package/vendor/l5x/lib/model/module.d.ts +37 -0
- package/vendor/l5x/lib/model/module.js +54 -0
- package/vendor/l5x/lib/model/modules.d.ts +24 -0
- package/vendor/l5x/lib/model/modules.js +55 -0
- package/vendor/l5x/lib/model/parse.d.ts +32 -0
- package/vendor/l5x/lib/model/parse.js +116 -0
- package/vendor/l5x/lib/model/project.d.ts +15 -0
- package/vendor/l5x/lib/model/project.js +31 -0
- package/vendor/l5x/lib/model/routines.d.ts +71 -0
- package/vendor/l5x/lib/model/routines.js +183 -0
- package/vendor/l5x/lib/model/tags.d.ts +51 -0
- package/vendor/l5x/lib/model/tags.js +119 -0
- package/vendor/l5x/lib/model/types.d.ts +304 -0
- package/vendor/l5x/lib/model/types.js +12 -0
- package/vendor/l5x/lib/model/udts.d.ts +34 -0
- package/vendor/l5x/lib/model/udts.js +81 -0
- package/vendor/l5x/lib/model/xml.d.ts +139 -0
- package/vendor/l5x/lib/model/xml.js +291 -0
- package/vendor/l5x/lib/model/xref.d.ts +28 -0
- package/vendor/l5x/lib/model/xref.js +154 -0
- package/vendor/l5x/lib/session.d.ts +119 -0
- package/vendor/l5x/lib/session.js +236 -0
- package/vendor/l5x/lib/skill.d.ts +30 -0
- package/vendor/l5x/lib/skill.js +44 -0
- package/vendor/l5x/lib/studio-state.d.ts +77 -0
- package/vendor/l5x/lib/studio-state.js +79 -0
- package/vendor/l5x/lib/studio.d.ts +29 -0
- package/vendor/l5x/lib/studio.js +40 -0
- package/vendor/l5x/lib/tools/checks.d.ts +19 -0
- package/vendor/l5x/lib/tools/checks.js +140 -0
- package/vendor/l5x/lib/tools/common.d.ts +41 -0
- package/vendor/l5x/lib/tools/common.js +84 -0
- package/vendor/l5x/lib/tools/controller.d.ts +21 -0
- package/vendor/l5x/lib/tools/controller.js +170 -0
- package/vendor/l5x/lib/tools/drafts.d.ts +23 -0
- package/vendor/l5x/lib/tools/drafts.js +270 -0
- package/vendor/l5x/lib/tools/live.d.ts +21 -0
- package/vendor/l5x/lib/tools/live.js +175 -0
- package/vendor/l5x/lib/tools/read.d.ts +19 -0
- package/vendor/l5x/lib/tools/read.js +245 -0
- package/vendor/l5x/lib/tools/references.d.ts +20 -0
- package/vendor/l5x/lib/tools/references.js +269 -0
- package/vendor/l5x/lib/tools/studio.d.ts +21 -0
- package/vendor/l5x/lib/tools/studio.js +193 -0
- package/vendor/l5x/poll-worker.mjs +77 -0
- package/vendor/l5x/studio-bridge/NuGet.Config +1 -0
- package/vendor/l5x/studio-bridge/Program.cs +304 -0
- package/vendor/l5x/studio-bridge/StudioBridge.csproj +11 -0
- package/vendor/mwi/README.md +60 -0
- package/vendor/mwi/SKILL.md +87 -0
- package/vendor/mwi/bridge/README.md +8 -0
- package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -0
- package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -0
- package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -0
- package/vendor/mwi/bridge/start_bridge.cmd +21 -0
- package/vendor/mwi/bridge/watch_dialogs.cmd +13 -0
- package/vendor/mwi/bridge/watch_dialogs.ps1 +153 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__init__.py +14 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/block_interfaces.py +275 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/capture.py +448 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/com.py +268 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/datatypes.py +204 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/descriptions.py +30 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/eip.py +292 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/errors.py +36 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/graphical_listing.py +208 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/grid.py +227 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +317 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/iec.py +317 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/installed_help.py +151 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/knowledge.py +218 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/libraries.py +250 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/navigation.py +44 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/pou_writer.py +1547 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/program_checks.py +360 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/project.py +704 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/server.py +1913 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/snapshot.py +138 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/st_comments.py +25 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +179 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +676 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/structure.py +98 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/task_bindings.py +68 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tasks.py +162 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/transaction.py +157 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree.py +563 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/uia.py +465 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/validation.py +41 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/variable_edit.py +183 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/variables.py +472 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/workflow.py +130 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +666 -0
- package/vendor/mwi/code/mw_code.py +1258 -0
- package/vendor/mwi/legacy/edit-session.js +50 -0
- package/vendor/mwi/legacy/fb-insertion.js +74 -0
- package/vendor/mwi/legacy/native-code.js +83 -0
- package/vendor/mwi/legacy/native-groups.js +75 -0
- package/vendor/mwi/legacy/native-structure.js +143 -0
- package/vendor/mwi/legacy/native-variables.js +77 -0
- package/vendor/mwi/legacy/pou-conversion.js +47 -0
- package/vendor/mwi/legacy/pou-package.js +66 -0
- package/vendor/mwi/lib/core/bridge.d.ts +71 -0
- package/vendor/mwi/lib/core/bridge.js +53 -0
- package/vendor/mwi/lib/core/code-engine.d.ts +47 -0
- package/vendor/mwi/lib/core/code-engine.js +109 -0
- package/vendor/mwi/lib/core/errors.d.ts +37 -0
- package/vendor/mwi/lib/core/errors.js +47 -0
- package/vendor/mwi/lib/core/params.d.ts +52 -0
- package/vendor/mwi/lib/core/params.js +46 -0
- package/vendor/mwi/lib/core/ps-bridge.d.ts +62 -0
- package/vendor/mwi/lib/core/ps-bridge.js +127 -0
- package/vendor/mwi/lib/core/result.d.ts +45 -0
- package/vendor/mwi/lib/core/result.js +20 -0
- package/vendor/mwi/lib/core/tool.d.ts +80 -0
- package/vendor/mwi/lib/core/tool.js +55 -0
- package/vendor/mwi/lib/edit/compare.d.ts +33 -0
- package/vendor/mwi/lib/edit/compare.js +70 -0
- package/vendor/mwi/lib/edit.d.ts +20 -0
- package/vendor/mwi/lib/edit.js +32 -0
- package/vendor/mwi/lib/index.d.ts +21 -0
- package/vendor/mwi/lib/index.js +33 -0
- package/vendor/mwi/lib/read/service.d.ts +117 -0
- package/vendor/mwi/lib/read/service.js +229 -0
- package/vendor/mwi/lib/read.d.ts +21 -0
- package/vendor/mwi/lib/read.js +33 -0
- package/vendor/mwi/lib/session/project.d.ts +34 -0
- package/vendor/mwi/lib/session/project.js +54 -0
- package/vendor/mwi/lib/session/service.d.ts +167 -0
- package/vendor/mwi/lib/session/service.js +566 -0
- package/vendor/mwi/lib/session/stage.d.ts +84 -0
- package/vendor/mwi/lib/session/stage.js +187 -0
- package/vendor/mwi/lib/session/workspace.d.ts +52 -0
- package/vendor/mwi/lib/session/workspace.js +90 -0
- package/vendor/mwi/lib/skill.d.ts +36 -0
- package/vendor/mwi/lib/skill.js +56 -0
- package/vendor/mwi/lib/tools/edit.d.ts +20 -0
- package/vendor/mwi/lib/tools/edit.js +286 -0
- package/vendor/mwi/lib/tools/read.d.ts +19 -0
- package/vendor/mwi/lib/tools/read.js +161 -0
- package/vendor/mwi/lib/tools/session.d.ts +20 -0
- package/vendor/mwi/lib/tools/session.js +166 -0
- package/vendor/mwi/lib/tools/verify.d.ts +19 -0
- package/vendor/mwi/lib/tools/verify.js +85 -0
- package/vendor/mwi/lib/verify.d.ts +17 -0
- package/vendor/mwi/lib/verify.js +29 -0
|
@@ -0,0 +1,2763 @@
|
|
|
1
|
+
<#
|
|
2
|
+
MotionWorks Use - COM bridge (32-bit Windows PowerShell).
|
|
3
|
+
|
|
4
|
+
WHY THIS PROCESS EXISTS
|
|
5
|
+
-----------------------
|
|
6
|
+
MotionWorks IEC 3 Pro registers an out-of-process COM automation server:
|
|
7
|
+
Ade.Application.550 -> LocalServer32 = <install>\Mwt.exe
|
|
8
|
+
It can ONLY be instantiated from a 32-bit client, and on this machine the
|
|
9
|
+
DSH agent sandbox blocks that activation (CO_E_SERVER_EXEC_FAILURE 0x80080005).
|
|
10
|
+
Launched from outside the sandbox, the same call succeeds and reports
|
|
11
|
+
Version 1.19 with a clean IsProjectOpen.
|
|
12
|
+
|
|
13
|
+
So the agent does not click the IDE at all: it asks this bridge, which talks to
|
|
14
|
+
the IDE through the IDE's own automation API. No synthetic input is involved,
|
|
15
|
+
which is why neither the sandbox nor wincomputer's DRY_RUN gate matters here.
|
|
16
|
+
|
|
17
|
+
PROTOCOL
|
|
18
|
+
--------
|
|
19
|
+
The agent writes bridge\req.json {"id":<n>,"verb":"...", ...}
|
|
20
|
+
The bridge writes bridge\res.json {"id":<n>,"ok":bool,"verb":...,"data":...,"error":...}
|
|
21
|
+
and deletes req.json. Matching on `id` prevents reading a stale answer.
|
|
22
|
+
|
|
23
|
+
SAFETY (non-negotiable)
|
|
24
|
+
-----------------------
|
|
25
|
+
* Never downloads to a controller. Never commands motion. No such verb exists.
|
|
26
|
+
* `open` only accepts a project that lives under -StageRoot (a copy under the
|
|
27
|
+
agent workspace), never the real project tree.
|
|
28
|
+
* Never calls Quit: the user's IDE is left exactly as it was found.
|
|
29
|
+
#>
|
|
30
|
+
param(
|
|
31
|
+
[string]$BridgeDir = $(if ($env:MW_BRIDGE_DIR) { $env:MW_BRIDGE_DIR } else { $PSScriptRoot }),
|
|
32
|
+
# Defaults to the plugin's OWN stage directory, so `open` can only ever touch a
|
|
33
|
+
# copy that lives beside this bridge. Never point this at a real project tree.
|
|
34
|
+
[string]$StageRoot = '',
|
|
35
|
+
[int]$PollMs = 250
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
$ErrorActionPreference = 'Continue'
|
|
39
|
+
if ([string]::IsNullOrWhiteSpace($StageRoot)) {
|
|
40
|
+
$StageRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\stage'))
|
|
41
|
+
}
|
|
42
|
+
$reqPath = Join-Path $BridgeDir 'req.json'
|
|
43
|
+
$resPath = Join-Path $BridgeDir 'res.json'
|
|
44
|
+
$logPath = Join-Path $BridgeDir 'bridge.log'
|
|
45
|
+
|
|
46
|
+
function Log([string]$m) {
|
|
47
|
+
$line = "{0:HH:mm:ss.fff} {1}" -f (Get-Date), $m
|
|
48
|
+
Add-Content -Path $logPath -Value $line -Encoding UTF8
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# --- single instance --------------------------------------------------------
|
|
52
|
+
#
|
|
53
|
+
# TWO bridges polling one req.json is a race, and it bit hard: the plugin spawns a
|
|
54
|
+
# bridge when it believes none is running, but a bridge stuck inside a slow verb
|
|
55
|
+
# looks dead, so a second one starts. Both then poll the same file, one consumes a
|
|
56
|
+
# request and (being slow) never answers it, and the caller times out with its work
|
|
57
|
+
# already taken from the queue. Symptom: "timed out waiting for the bridge" with no
|
|
58
|
+
# matching request in the log at all.
|
|
59
|
+
#
|
|
60
|
+
# The lock records the owning pid. A live owner means this process must exit rather
|
|
61
|
+
# than compete; a dead owner leaves a stale lock that is simply taken over.
|
|
62
|
+
$lockPath = Join-Path $BridgeDir 'bridge.lock'
|
|
63
|
+
$lockOwner = 0
|
|
64
|
+
if (Test-Path $lockPath) {
|
|
65
|
+
try { $lockOwner = [int]((Get-Content -Path $lockPath -Raw -ErrorAction Stop).Trim()) } catch { $lockOwner = 0 }
|
|
66
|
+
}
|
|
67
|
+
if ($lockOwner -gt 0) {
|
|
68
|
+
$alive = $null -ne (Get-Process -Id $lockOwner -ErrorAction SilentlyContinue)
|
|
69
|
+
if ($alive) {
|
|
70
|
+
Log "another bridge is already running (pid $lockOwner); exiting instead of competing for the queue"
|
|
71
|
+
exit 0
|
|
72
|
+
}
|
|
73
|
+
Log "stale bridge lock from pid $lockOwner; taking over"
|
|
74
|
+
}
|
|
75
|
+
Set-Content -Path $lockPath -Value $PID -Encoding ASCII
|
|
76
|
+
|
|
77
|
+
function Remove-BridgeLock {
|
|
78
|
+
try {
|
|
79
|
+
if (Test-Path $lockPath) {
|
|
80
|
+
$cur = (Get-Content -Path $lockPath -Raw -ErrorAction SilentlyContinue)
|
|
81
|
+
if ($null -ne $cur -and $cur.Trim() -eq "$PID") { Remove-Item -Path $lockPath -Force -ErrorAction SilentlyContinue }
|
|
82
|
+
}
|
|
83
|
+
} catch { }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function Write-Result($id, [string]$verb, $ok, $data, [string]$err) {
|
|
87
|
+
$payload = [ordered]@{
|
|
88
|
+
id = $id
|
|
89
|
+
verb = $verb
|
|
90
|
+
ok = [bool]$ok
|
|
91
|
+
data = $data
|
|
92
|
+
error = $err
|
|
93
|
+
pid = $PID
|
|
94
|
+
bits = ([IntPtr]::Size * 8)
|
|
95
|
+
ts = (Get-Date).ToString('o')
|
|
96
|
+
}
|
|
97
|
+
# Write to a temp name then move, so a reader never sees a half-written file.
|
|
98
|
+
#
|
|
99
|
+
# `Set-Content -Encoding UTF8` is NOT used: in Windows PowerShell 5.1 that
|
|
100
|
+
# means UTF-8 WITH a BOM, and JSON.parse in the Node half throws on the
|
|
101
|
+
# leading U+FEFF. The bridge would answer in 6ms and the plugin would still
|
|
102
|
+
# time out - a very expensive bug to find. WriteAllText with an explicit
|
|
103
|
+
# no-BOM UTF8Encoding keeps the bytes clean for a JS reader.
|
|
104
|
+
$tmp = "$resPath.tmp"
|
|
105
|
+
$json = $payload | ConvertTo-Json -Depth 8
|
|
106
|
+
[IO.File]::WriteAllText($tmp, $json, (New-Object Text.UTF8Encoding($false)))
|
|
107
|
+
Move-Item -Force $tmp $resPath
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# --- IDE presence guard: only ever touch an IDE that is ALREADY running. ---
|
|
111
|
+
Add-Type -TypeDefinition @'
|
|
112
|
+
using System; using System.Text; using System.Runtime.InteropServices;
|
|
113
|
+
public class MWW {
|
|
114
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr l);
|
|
115
|
+
[DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr parent, EnumWindowsProc cb, IntPtr l);
|
|
116
|
+
public delegate bool EnumWindowsProc(IntPtr h, IntPtr l);
|
|
117
|
+
[DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
|
|
118
|
+
[DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetClassNameW(IntPtr h, StringBuilder s, int n);
|
|
119
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
|
120
|
+
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
|
|
121
|
+
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
|
122
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
123
|
+
|
|
124
|
+
// Licence-dialog dismissal helpers. The unlicensed build shows a modal
|
|
125
|
+
// WindowsForms dialog before the IDE has any window of its own.
|
|
126
|
+
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);
|
|
127
|
+
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l);
|
|
128
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
129
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
130
|
+
[DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr h, uint flags);
|
|
131
|
+
[StructLayout(LayoutKind.Sequential)] public struct GUITHREADINFO {
|
|
132
|
+
public uint cbSize, flags;
|
|
133
|
+
public IntPtr hwndActive, hwndFocus, hwndCapture, hwndMenuOwner, hwndMoveSize, hwndCaret;
|
|
134
|
+
public RECT rcCaret;
|
|
135
|
+
}
|
|
136
|
+
[DllImport("user32.dll")] public static extern bool GetGUIThreadInfo(uint thread, ref GUITHREADINFO info);
|
|
137
|
+
public static IntPtr[] CaptureFocus() {
|
|
138
|
+
IntPtr foreground = GetForegroundWindow(); uint process;
|
|
139
|
+
uint thread = GetWindowThreadProcessId(foreground, out process);
|
|
140
|
+
GUITHREADINFO info = new GUITHREADINFO(); info.cbSize = (uint)Marshal.SizeOf(typeof(GUITHREADINFO));
|
|
141
|
+
if (foreground == IntPtr.Zero || thread == 0 || !GetGUIThreadInfo(thread, ref info))
|
|
142
|
+
throw new InvalidOperationException("Foreground/focus identity unavailable; capture unverified");
|
|
143
|
+
return new IntPtr[] { foreground, info.hwndActive, info.hwndFocus };
|
|
144
|
+
}
|
|
145
|
+
[DllImport("user32.dll")] public static extern IntPtr SetFocus(IntPtr h);
|
|
146
|
+
[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
|
|
147
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
|
|
148
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr LoadLibraryExW(string path, IntPtr file, uint flags);
|
|
149
|
+
[DllImport("kernel32.dll")] public static extern bool FreeLibrary(IntPtr module);
|
|
150
|
+
[DllImport("user32.dll")] public static extern IntPtr LoadMenuW(IntPtr module, IntPtr name);
|
|
151
|
+
[DllImport("user32.dll")] public static extern bool DestroyMenu(IntPtr menu);
|
|
152
|
+
[DllImport("user32.dll")] public static extern IntPtr GetSubMenu(IntPtr menu, int position);
|
|
153
|
+
[DllImport("user32.dll")] public static extern int GetMenuItemCount(IntPtr menu);
|
|
154
|
+
[DllImport("user32.dll")] public static extern uint GetMenuItemID(IntPtr menu, int position);
|
|
155
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetMenuStringW(IntPtr menu, uint position, StringBuilder text, int size, uint flags);
|
|
156
|
+
public static bool HasMessageWindowCommand(string executable) {
|
|
157
|
+
// Read resource data only: never execute another module or guess a command.
|
|
158
|
+
IntPtr module = LoadLibraryExW(executable, IntPtr.Zero, 2);
|
|
159
|
+
if (module == IntPtr.Zero) return false;
|
|
160
|
+
IntPtr menu = IntPtr.Zero;
|
|
161
|
+
try {
|
|
162
|
+
menu = LoadMenuW(module, new IntPtr(501));
|
|
163
|
+
if (menu == IntPtr.Zero) return false;
|
|
164
|
+
int matches = 0;
|
|
165
|
+
for (int i=0; i<GetMenuItemCount(menu); i++) {
|
|
166
|
+
var label = new StringBuilder(512);
|
|
167
|
+
GetMenuStringW(menu, (uint)i, label, 512, 0x400);
|
|
168
|
+
if (label.ToString().Replace("&", "").Split('\t')[0] != "View") continue;
|
|
169
|
+
IntPtr sub = GetSubMenu(menu, i);
|
|
170
|
+
for (int j=0; j<GetMenuItemCount(sub); j++) {
|
|
171
|
+
label.Clear(); GetMenuStringW(sub, (uint)j, label, 512, 0x400);
|
|
172
|
+
if (label.ToString().Replace("&", "").Split('\t')[0] == "Message Window") {
|
|
173
|
+
if (GetMenuItemID(sub, j) != 36554) return false;
|
|
174
|
+
matches++;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return matches == 1;
|
|
179
|
+
} finally { if (menu != IntPtr.Zero) DestroyMenu(menu); FreeLibrary(module); }
|
|
180
|
+
}
|
|
181
|
+
[DllImport("user32.dll")] public static extern IntPtr SetActiveWindow(IntPtr h);
|
|
182
|
+
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
|
|
183
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint p);
|
|
184
|
+
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
|
|
185
|
+
|
|
186
|
+
// Capture a window's OWN content into a DC, even when it is occluded.
|
|
187
|
+
// CopyFromScreen grabs a screen REGION, so a window stacked over the IDE is
|
|
188
|
+
// what you actually get in the PNG - which is how a screenshot of the IDE
|
|
189
|
+
// came back showing an unrelated window. PW_RENDERFULLCONTENT (0x2) asks the
|
|
190
|
+
// window to render itself instead.
|
|
191
|
+
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint nFlags);
|
|
192
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
|
193
|
+
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X,Y; }
|
|
194
|
+
[StructLayout(LayoutKind.Sequential)] public struct CURSORINFO {
|
|
195
|
+
public uint cbSize, flags; public IntPtr hCursor; public POINT ptScreenPos;
|
|
196
|
+
}
|
|
197
|
+
[StructLayout(LayoutKind.Sequential)] public struct ICONINFO {
|
|
198
|
+
[MarshalAs(UnmanagedType.Bool)] public bool fIcon;
|
|
199
|
+
public uint xHotspot,yHotspot; public IntPtr hbmMask,hbmColor;
|
|
200
|
+
}
|
|
201
|
+
[DllImport("user32.dll")] public static extern bool GetCursorInfo(ref CURSORINFO info);
|
|
202
|
+
[DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(POINT point);
|
|
203
|
+
[DllImport("user32.dll")] public static extern IntPtr CopyIcon(IntPtr icon);
|
|
204
|
+
[DllImport("user32.dll")] public static extern bool GetIconInfo(IntPtr icon,out ICONINFO info);
|
|
205
|
+
[DllImport("user32.dll")] public static extern bool DestroyIcon(IntPtr icon);
|
|
206
|
+
[DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr obj);
|
|
207
|
+
[StructLayout(LayoutKind.Sequential)] public struct BITMAP {
|
|
208
|
+
public int type,width,height,widthBytes; public ushort planes,bitsPixel; public IntPtr bits;
|
|
209
|
+
}
|
|
210
|
+
[DllImport("gdi32.dll")] public static extern int GetObject(IntPtr obj,int size,out BITMAP bitmap);
|
|
211
|
+
[DllImport("user32.dll")] public static extern bool DrawIconEx(IntPtr dc,int x,int y,IntPtr icon,int width,int height,uint step,IntPtr brush,uint flags);
|
|
212
|
+
[DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
|
|
213
|
+
public static long[] CaptureCursor() {
|
|
214
|
+
CURSORINFO info=new CURSORINFO(); info.cbSize=(uint)Marshal.SizeOf(typeof(CURSORINFO));
|
|
215
|
+
if(!GetCursorInfo(ref info)) throw new InvalidOperationException("Cursor observation unavailable");
|
|
216
|
+
IntPtr owner=GetAncestor(WindowFromPoint(info.ptScreenPos),3);
|
|
217
|
+
return new long[]{info.flags,info.hCursor.ToInt64(),info.ptScreenPos.X,info.ptScreenPos.Y,owner.ToInt64()};
|
|
218
|
+
}
|
|
219
|
+
public static int[] DrawCapturedCursor(IntPtr dc,long[] cursor,int left,int top) {
|
|
220
|
+
IntPtr copy=CopyIcon(new IntPtr(cursor[1]));
|
|
221
|
+
if(copy==IntPtr.Zero) throw new InvalidOperationException("Cursor image copy failed");
|
|
222
|
+
ICONINFO info=new ICONINFO();
|
|
223
|
+
try {
|
|
224
|
+
if(!GetIconInfo(copy,out info)) throw new InvalidOperationException("Cursor hotspot unavailable");
|
|
225
|
+
BITMAP bitmap; IntPtr image=info.hbmColor!=IntPtr.Zero?info.hbmColor:info.hbmMask;
|
|
226
|
+
if(GetObject(image,Marshal.SizeOf(typeof(BITMAP)),out bitmap)==0) throw new InvalidOperationException("Cursor dimensions unavailable");
|
|
227
|
+
int width=bitmap.width,height=info.hbmColor!=IntPtr.Zero?bitmap.height:bitmap.height/2;
|
|
228
|
+
if(width<=0||height<=0) throw new InvalidOperationException("Cursor dimensions invalid");
|
|
229
|
+
int x=(int)cursor[2]-left-(int)info.xHotspot,y=(int)cursor[3]-top-(int)info.yHotspot;
|
|
230
|
+
if(!DrawIconEx(dc,x,y,copy,width,height,0,IntPtr.Zero,3)) throw new InvalidOperationException("Cursor rendering failed");
|
|
231
|
+
return new int[]{(int)info.xHotspot,(int)info.yHotspot,width,height};
|
|
232
|
+
} finally {
|
|
233
|
+
if(info.hbmMask!=IntPtr.Zero) DeleteObject(info.hbmMask);
|
|
234
|
+
if(info.hbmColor!=IntPtr.Zero) DeleteObject(info.hbmColor);
|
|
235
|
+
DestroyIcon(copy);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Read the Message Window's lists as TEXT.
|
|
240
|
+
//
|
|
241
|
+
// The compiler's messages live in a SysListView32, and a screenshot is useless to
|
|
242
|
+
// an agent that has to act on them - it cannot turn a PNG into a fix. MSAA exposes
|
|
243
|
+
// the rows through IAccessible and accName(i) returns each row verbatim, so the
|
|
244
|
+
// exact compiler text comes back. AccessibleChildren with an [Out] object[] fails
|
|
245
|
+
// from PowerShell with E_INVALIDARG (0x80070057), so children are read by index.
|
|
246
|
+
[DllImport("oleacc.dll")] public static extern int AccessibleObjectFromWindow(IntPtr hwnd, uint id, ref Guid iid, [MarshalAs(UnmanagedType.IUnknown)] out object ppv);
|
|
247
|
+
[DllImport("user32.dll", EntryPoint="SendMessageW")] public static extern IntPtr SendMessagePtr(IntPtr h, uint m, IntPtr w, IntPtr l);
|
|
248
|
+
|
|
249
|
+
public static object GetAccessible(IntPtr hwnd) {
|
|
250
|
+
try {
|
|
251
|
+
Guid iid = new Guid("618736E0-3C3D-11CF-810C-00AA00389B71"); // IAccessible
|
|
252
|
+
object acc;
|
|
253
|
+
int hr = AccessibleObjectFromWindow(hwnd, 0xFFFFFFFC, ref iid, out acc);
|
|
254
|
+
return hr == 0 ? acc : null;
|
|
255
|
+
} catch { return null; }
|
|
256
|
+
}
|
|
257
|
+
// LVM_GETITEMCOUNT = 0x1004
|
|
258
|
+
public static int ListRowCount(IntPtr h) { return (int)SendMessagePtr(h, 0x1004, IntPtr.Zero, IntPtr.Zero); }
|
|
259
|
+
|
|
260
|
+
// Modal-dialog inspection. MotionWorks asks questions in standard Win32 #32770
|
|
261
|
+
// dialogs, whose text and buttons are readable through WM_GETTEXT/GetDlgCtrlID.
|
|
262
|
+
// While one is up the frame window is DISABLED, and the COM API simply reports
|
|
263
|
+
// nothing - which is how an agent concludes "no project" or "IDE closed" when
|
|
264
|
+
// the IDE is really waiting for a button press.
|
|
265
|
+
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr h);
|
|
266
|
+
[DllImport("user32.dll")] public static extern int GetDlgCtrlID(IntPtr h);
|
|
267
|
+
[DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr h);
|
|
268
|
+
[DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h, uint cmd);
|
|
269
|
+
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)]
|
|
270
|
+
public static extern IntPtr SendStr(IntPtr h, uint msg, IntPtr wp, StringBuilder lp);
|
|
271
|
+
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)]
|
|
272
|
+
public static extern IntPtr SendInt(IntPtr h, uint msg, IntPtr wp, IntPtr lp);
|
|
273
|
+
|
|
274
|
+
// Read a control's text across process boundaries. GetWindowTextW returns
|
|
275
|
+
// nothing for another process's controls; WM_GETTEXT does not have that limit.
|
|
276
|
+
//
|
|
277
|
+
// SendMessageTimeout, NOT SendMessage: SendMessage is synchronous and blocks
|
|
278
|
+
// FOREVER if the target is not pumping messages - which is exactly the state a
|
|
279
|
+
// modal MotionWorks dialog leaves it in. That hung the whole bridge for five
|
|
280
|
+
// minutes on a start_ide call, because the failure path inspects dialogs.
|
|
281
|
+
// SMTO_ABORTIFHUNG (0x2) plus a short timeout makes inspection always safe.
|
|
282
|
+
[DllImport("user32.dll", SetLastError=true, EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode)]
|
|
283
|
+
public static extern IntPtr SendTimeoutStr(IntPtr h, uint msg, IntPtr wp, StringBuilder lp, uint flags, uint ms, out IntPtr result);
|
|
284
|
+
[DllImport("user32.dll", SetLastError=true, EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode)]
|
|
285
|
+
public static extern IntPtr SendTimeoutInt(IntPtr h, uint msg, IntPtr wp, IntPtr lp, uint flags, uint ms, out IntPtr result);
|
|
286
|
+
|
|
287
|
+
const uint SMTO_ABORTIFHUNG = 0x0002;
|
|
288
|
+
const uint TEXT_TIMEOUT_MS = 200;
|
|
289
|
+
|
|
290
|
+
public static string ReadText(IntPtr h) { return ReadTextMs(h, TEXT_TIMEOUT_MS); }
|
|
291
|
+
|
|
292
|
+
public static string ReadTextMs(IntPtr h, uint ms) {
|
|
293
|
+
IntPtr result;
|
|
294
|
+
IntPtr sent = SendTimeoutInt(h, 0x000E, IntPtr.Zero, IntPtr.Zero, SMTO_ABORTIFHUNG, ms, out result); // WM_GETTEXTLENGTH
|
|
295
|
+
if (sent == IntPtr.Zero) return ""; // hung or gone: no text
|
|
296
|
+
int n = result.ToInt32();
|
|
297
|
+
if (n <= 0) return "";
|
|
298
|
+
if (n > 65536) n = 65536;
|
|
299
|
+
StringBuilder sb = new StringBuilder(n + 2);
|
|
300
|
+
sent = SendTimeoutStr(h, 0x000D, (IntPtr)(n + 1), sb, SMTO_ABORTIFHUNG, ms, out result); // WM_GETTEXT
|
|
301
|
+
if (sent == IntPtr.Zero) return "";
|
|
302
|
+
return sb.ToString();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Press a button without risking a permanent block. BM_CLICK is synchronous
|
|
306
|
+
// like any SendMessage, so it is sent with a timeout and allowed to fail.
|
|
307
|
+
public static bool ClickButton(IntPtr h) {
|
|
308
|
+
IntPtr result;
|
|
309
|
+
IntPtr sent = SendTimeoutInt(h, 0x00F5, IntPtr.Zero, IntPtr.Zero, SMTO_ABORTIFHUNG, CLICK_TIMEOUT_MS, out result);
|
|
310
|
+
if (sent != IntPtr.Zero) return true;
|
|
311
|
+
// A hung dialog will not take BM_CLICK; posting is asynchronous and at least
|
|
312
|
+
// queues the click for whenever the dialog next pumps.
|
|
313
|
+
return PostMessage(h, 0x00F5, IntPtr.Zero, IntPtr.Zero);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const uint CLICK_TIMEOUT_MS = 2500;
|
|
317
|
+
}
|
|
318
|
+
'@
|
|
319
|
+
|
|
320
|
+
function Get-IdeWindow {
|
|
321
|
+
# NOTE: the callback runs in a child scope, so the result must be stashed in
|
|
322
|
+
# the SCRIPT scope. Assigning to a function-local inside the delegate and
|
|
323
|
+
# returning it here silently always returns $null - that bug made the bridge
|
|
324
|
+
# report "no running IDE" while the IDE was plainly on screen.
|
|
325
|
+
#
|
|
326
|
+
# Match on a PREFIX, not equality: once a project is open the caption becomes
|
|
327
|
+
# "MotionWorks IEC 3 Pro - <project>", so an exact match stops finding the IDE
|
|
328
|
+
# at the exact moment the IDE becomes useful.
|
|
329
|
+
#
|
|
330
|
+
# AND accept "MULTIPROG - <project>": MotionWorks IEC is an OEM build of
|
|
331
|
+
# MULTIPROG, and when Mwt.exe is launched directly (which is exactly what
|
|
332
|
+
# automation does) it titles itself MULTIPROG, not "MotionWorks IEC 3 Pro".
|
|
333
|
+
# Matching only the latter made the bridge blind to an IDE it had just started.
|
|
334
|
+
#
|
|
335
|
+
# Then pick the LARGEST match. Startup creates small auxiliary windows that also
|
|
336
|
+
# carry these captions, and returning one of those produced a 426x166 "IDE
|
|
337
|
+
# screenshot" instead of the 1936x1048 frame.
|
|
338
|
+
$script:cands = @()
|
|
339
|
+
$cb = [MWW+EnumWindowsProc]{
|
|
340
|
+
param($h, $l)
|
|
341
|
+
if ([MWW]::IsWindowVisible($h)) {
|
|
342
|
+
# ReadText, not GetWindowTextW. The latter sends WM_GETTEXT and waits
|
|
343
|
+
# forever on a window that is not pumping, which made the first
|
|
344
|
+
# ide_state of a session time out while some other app was hung.
|
|
345
|
+
$t = [MWW]::ReadTextMs($h, 50)
|
|
346
|
+
if ($t -like 'MotionWorks IEC 3 Pro*' -or $t -like 'MULTIPROG*') {
|
|
347
|
+
$rc = New-Object MWW+RECT
|
|
348
|
+
$area = 0
|
|
349
|
+
if ([MWW]::GetWindowRect($h, [ref]$rc)) {
|
|
350
|
+
$area = [int64]($rc.Right - $rc.Left) * [int64]($rc.Bottom - $rc.Top)
|
|
351
|
+
}
|
|
352
|
+
$script:cands += [pscustomobject]@{ h = $h; area = $area; title = $t }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return $true
|
|
356
|
+
}
|
|
357
|
+
[void][MWW]::EnumWindows($cb, [IntPtr]::Zero)
|
|
358
|
+
if ($script:cands.Count -eq 0) { return $null }
|
|
359
|
+
return ($script:cands | Sort-Object -Property area -Descending | Select-Object -First 1).h
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
# --- licence / trial dialog -------------------------------------------------
|
|
363
|
+
#
|
|
364
|
+
# The UNLICENSED build shows a modal licence dialog BEFORE the IDE creates any
|
|
365
|
+
# window of its own. It is a .NET WinForms window owned by mwctVerify.exe, titled
|
|
366
|
+
# "Motionworks IEC" (lower-case w), with children "Use Trial", "Activate Online",
|
|
367
|
+
# "Activate by Phone", "Find Distributor".
|
|
368
|
+
#
|
|
369
|
+
# Two consequences the bridge must handle:
|
|
370
|
+
# 1. Get-IdeWindow does NOT see it (wrong owner, wrong class, no "3 Pro"), so a
|
|
371
|
+
# launcher that only waits for the IDE window sits there until it times out
|
|
372
|
+
# while a dialog is actually waiting for a human click.
|
|
373
|
+
# 2. Until it is answered, the IDE has no project services and OpenProject fails
|
|
374
|
+
# with "Internal error in 'OpenProject'".
|
|
375
|
+
function Get-TrialDialog {
|
|
376
|
+
$script:trialDlg = $null
|
|
377
|
+
$script:trialBtn = $null
|
|
378
|
+
$script:trialCandidates = @()
|
|
379
|
+
$cb = [MWW+EnumWindowsProc]{
|
|
380
|
+
param($h, $l)
|
|
381
|
+
if ([MWW]::IsWindowVisible($h)) {
|
|
382
|
+
$ownerPid = [uint32]0
|
|
383
|
+
[void][MWW]::GetWindowThreadProcessId($h, [ref]$ownerPid)
|
|
384
|
+
$owner = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
|
|
385
|
+
if (-not $owner -or $owner.ProcessName -ine 'mwctVerify') { return $true }
|
|
386
|
+
$cls = New-Object System.Text.StringBuilder 256
|
|
387
|
+
[void][MWW]::GetClassNameW($h, $cls, 256)
|
|
388
|
+
if ($cls.ToString() -like 'WindowsForms10.Window*') {
|
|
389
|
+
$script:candidateTrialButton = $null
|
|
390
|
+
$cb2 = [MWW+EnumWindowsProc]{
|
|
391
|
+
param($k, $l2)
|
|
392
|
+
if (([MWW]::ReadText($k) -replace '&','').Trim() -ieq 'Use Trial' -and [MWW]::IsWindowVisible($k)) {
|
|
393
|
+
$script:candidateTrialButton = $k
|
|
394
|
+
}
|
|
395
|
+
return $true
|
|
396
|
+
}
|
|
397
|
+
[void][MWW]::EnumChildWindows($h, $cb2, [IntPtr]::Zero)
|
|
398
|
+
if ($script:candidateTrialButton) {
|
|
399
|
+
$script:trialCandidates += [pscustomobject]@{ dialog = $h; button = $script:candidateTrialButton }
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return $true
|
|
404
|
+
}
|
|
405
|
+
[void][MWW]::EnumWindows($cb, [IntPtr]::Zero)
|
|
406
|
+
if ($script:trialCandidates.Count -gt 1) { throw 'Multiple MotionWorks trial dialogs found; refusing an ambiguous action' }
|
|
407
|
+
if ($script:trialCandidates.Count -eq 1) {
|
|
408
|
+
$script:trialDlg = $script:trialCandidates[0].dialog
|
|
409
|
+
$script:trialBtn = $script:trialCandidates[0].button
|
|
410
|
+
}
|
|
411
|
+
return @($script:trialDlg, $script:trialBtn)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function Answer-TrialDialog {
|
|
415
|
+
$pair = Get-TrialDialog
|
|
416
|
+
$dialog = $pair[0]; $button = $pair[1]
|
|
417
|
+
if (-not $dialog) { return [ordered]@{ dialog_present = $false; dismissed = $true; method = 'none-needed'; seconds = 0; strategies_tried = @() } }
|
|
418
|
+
if (-not [MWW]::IsWindowEnabled($button)) { throw 'Use Trial is disabled; no licence action was submitted' }
|
|
419
|
+
# Prefer the control's semantic Invoke API. Only fall back if Invoke is not
|
|
420
|
+
# available BEFORE input; an ambiguous submitted action is never retried.
|
|
421
|
+
$pattern = $null
|
|
422
|
+
try {
|
|
423
|
+
Add-Type -AssemblyName UIAutomationClient
|
|
424
|
+
Add-Type -AssemblyName UIAutomationTypes
|
|
425
|
+
$element = [System.Windows.Automation.AutomationElement]::FromHandle($button)
|
|
426
|
+
[void]$element.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$pattern)
|
|
427
|
+
} catch { $pattern = $null }
|
|
428
|
+
if ($pattern) {
|
|
429
|
+
$pattern.Invoke()
|
|
430
|
+
$method = 'uia_invoke'
|
|
431
|
+
} else {
|
|
432
|
+
Invoke-TrialClick $button $dialog {
|
|
433
|
+
if (-not [MWW]::PostMessage($button, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero)) {
|
|
434
|
+
throw 'Use Trial native button command was refused'
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
$method = 'posted_bm_click'
|
|
438
|
+
}
|
|
439
|
+
$seconds = Test-TrialGone $dialog 10
|
|
440
|
+
$gone = -not [MWW]::IsWindow($dialog) -or -not [MWW]::IsWindowVisible($dialog)
|
|
441
|
+
return [ordered]@{ dialog_present = $true; dismissed = $gone; method = $method; seconds = $seconds; strategies_tried = @($method) }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function Invoke-TrialClick([IntPtr]$target, [IntPtr]$dialog, [scriptblock]$action) {
|
|
445
|
+
# Attach our input thread to the dialog's threads and foreground it first.
|
|
446
|
+
# Without this the synthetic click is ignored: measured, BM_CLICK and the
|
|
447
|
+
# mouse messages are accepted and do nothing while the dialog is not active.
|
|
448
|
+
$pidT = [uint32]0; $tid = [MWW]::GetWindowThreadProcessId($target, [ref]$pidT)
|
|
449
|
+
$pidD = [uint32]0; $tidD = [MWW]::GetWindowThreadProcessId($dialog, [ref]$pidD)
|
|
450
|
+
$me = [MWW]::GetCurrentThreadId()
|
|
451
|
+
if (-not $action) { throw 'Trial fallback requires one explicit button action' }
|
|
452
|
+
$attached = @()
|
|
453
|
+
try {
|
|
454
|
+
# Button and dialog normally share one thread. Attach it only once and
|
|
455
|
+
# detach only successful attachments, even if activation or posting fails.
|
|
456
|
+
foreach ($thread in @($tid, $tidD) | Select-Object -Unique) {
|
|
457
|
+
if ($thread -and $thread -ne $me) {
|
|
458
|
+
try {
|
|
459
|
+
if ([MWW]::AttachThreadInput($me, $thread, $true)) { $attached += $thread }
|
|
460
|
+
} catch { }
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
[void][MWW]::ShowWindow($dialog, 5)
|
|
464
|
+
[void][MWW]::BringWindowToTop($dialog)
|
|
465
|
+
[void][MWW]::SetForegroundWindow($dialog)
|
|
466
|
+
[void][MWW]::SetActiveWindow($dialog)
|
|
467
|
+
[void][MWW]::SetFocus($target)
|
|
468
|
+
Start-Sleep -Milliseconds 700
|
|
469
|
+
& $action
|
|
470
|
+
} finally {
|
|
471
|
+
$detachFailures = @()
|
|
472
|
+
for ($index = $attached.Count - 1; $index -ge 0; $index--) {
|
|
473
|
+
try {
|
|
474
|
+
if (-not [MWW]::AttachThreadInput($me, $attached[$index], $false)) { $detachFailures += $attached[$index] }
|
|
475
|
+
} catch { $detachFailures += $attached[$index] }
|
|
476
|
+
}
|
|
477
|
+
if ($detachFailures.Count) {
|
|
478
|
+
throw "Trial fallback input-thread cleanup failed for $($detachFailures -join ','). The button action may already have been submitted; inspect trial state before further input. No automatic retry was sent."
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function Test-TrialGone([IntPtr]$dialog, [int]$seconds = 20) {
|
|
484
|
+
for ($i = 0; $i -lt ($seconds / 2); $i++) {
|
|
485
|
+
Start-Sleep -Seconds 2
|
|
486
|
+
if (-not [MWW]::IsWindow($dialog) -or -not [MWW]::IsWindowVisible($dialog)) { return [int](($i * 2) + 2) }
|
|
487
|
+
}
|
|
488
|
+
return 0
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
# --- modal dialogs: the state the COM API cannot see ------------------------
|
|
492
|
+
#
|
|
493
|
+
# MotionWorks asks its questions in standard Win32 #32770 dialogs. While one is
|
|
494
|
+
# up the frame window is DISABLED and the automation API simply goes quiet:
|
|
495
|
+
# IsProjectOpen() reports false or throws, OpenProject fails, and an agent that
|
|
496
|
+
# trusts COM alone concludes "no project open" or "the IDE has closed" while the
|
|
497
|
+
# IDE is really sitting there waiting for a button press.
|
|
498
|
+
#
|
|
499
|
+
# These are plain Win32 dialogs, so their text and buttons are EXACTLY readable
|
|
500
|
+
# through WM_GETTEXT + GetDlgCtrlID - no OCR approximation needed. The screenshot
|
|
501
|
+
# remains the backstop for owner-drawn or foreign dialogs (the .NET licence
|
|
502
|
+
# dialog), where text extraction returns nothing.
|
|
503
|
+
$script:dialogHwnds = @{}
|
|
504
|
+
|
|
505
|
+
function Get-IdePid {
|
|
506
|
+
$w = Get-IdeWindow
|
|
507
|
+
if (-not $w) { return 0 }
|
|
508
|
+
$p = [uint32]0
|
|
509
|
+
[void][MWW]::GetWindowThreadProcessId([IntPtr]$w, [ref]$p)
|
|
510
|
+
return [int]$p
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function Get-IdeUiStatus {
|
|
514
|
+
$ideProcessId = Get-IdePid
|
|
515
|
+
try {
|
|
516
|
+
if (-not $ideProcessId) { throw 'No native IDE frame' }
|
|
517
|
+
$process = Get-Process -Id $ideProcessId -ErrorAction Stop
|
|
518
|
+
return @{ pid = $ideProcessId; responding = [bool]$process.Responding; caption = [string]$process.MainWindowTitle }
|
|
519
|
+
} catch {
|
|
520
|
+
return @{ pid = $ideProcessId; responding = $null; caption = $null }
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function Get-WorksheetEditorState($requested, $actual, $beforeModified, $afterModified, $ui, $expectedCaption) {
|
|
525
|
+
$nativeVerified = ($actual -ceq $requested -and $beforeModified -is [bool] -and
|
|
526
|
+
$afterModified -is [bool] -and $beforeModified -eq $afterModified)
|
|
527
|
+
$captionMatches = $null
|
|
528
|
+
if (-not [string]::IsNullOrWhiteSpace([string]$ui.caption)) {
|
|
529
|
+
$captionMatches = ([string]$ui.caption).EndsWith(('[' + $expectedCaption + ']'), [StringComparison]::OrdinalIgnoreCase)
|
|
530
|
+
}
|
|
531
|
+
$ready = ($nativeVerified -and $ui.responding -eq $true -and $captionMatches -eq $true)
|
|
532
|
+
return [ordered]@{ native_navigation_verified = [bool]$nativeVerified; ui_responsive = $ui.responding;
|
|
533
|
+
editor_caption_matches = $captionMatches; editor_caption = $ui.caption; editor_window_ready = [bool]$ready;
|
|
534
|
+
keyboard_focus_verified = $false }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
# A document already open through a task instance may be reused by OpenDocument.
|
|
538
|
+
# Its active view is the instance, not /Pous/<type>/<sheet>. Resolve that exact
|
|
539
|
+
# native instance (adeOtProgramInstance=19); never accept a caption alone.
|
|
540
|
+
function Get-WorksheetInstanceView($app, [string]$actual, [string]$pouName, [string]$sheetName) {
|
|
541
|
+
if($actual -cnotmatch '^/Hardware/([^/.\\]+)/([^/.\\]+)/Tasks/([^/.\\]+)/([^/.\\]+)$'){return $null}
|
|
542
|
+
$config=$Matches[1];$resource=$Matches[2];$task=$Matches[3];$name=$Matches[4]
|
|
543
|
+
try {
|
|
544
|
+
$instance=$app.ActiveProject.GetObjectByLogicalName($actual.TrimStart('/'),19)
|
|
545
|
+
if($null -eq $instance -or [string]$instance.LogicalName -cne $actual -or
|
|
546
|
+
[string]$instance.Name -cne $name -or [string]$instance.Type -cne $pouName){return $null}
|
|
547
|
+
return [ordered]@{logical_name=$actual;instance_name=$name;pou=$pouName;task_name=$task;
|
|
548
|
+
expected_caption=('{0}:{1} - {2}.{3}.{4}.{5}.{0}' -f $sheetName,$pouName,$config,$resource,$task,$name)}
|
|
549
|
+
}catch{return $null}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function Get-IdeDialogs {
|
|
553
|
+
$idePid = Get-IdePid
|
|
554
|
+
$script:dialogHwnds = @{}
|
|
555
|
+
$found = New-Object System.Collections.ArrayList
|
|
556
|
+
if ($idePid -eq 0) { return $found }
|
|
557
|
+
|
|
558
|
+
$cb = [MWW+EnumWindowsProc]{
|
|
559
|
+
param($h, $l)
|
|
560
|
+
if (-not [MWW]::IsWindowVisible($h)) { return $true }
|
|
561
|
+
$p = [uint32]0; [void][MWW]::GetWindowThreadProcessId($h, [ref]$p)
|
|
562
|
+
if ([int]$p -ne $script:wantPid) { return $true }
|
|
563
|
+
$cls = New-Object System.Text.StringBuilder 64
|
|
564
|
+
[void][MWW]::GetClassNameW($h, $cls, 64)
|
|
565
|
+
if ($cls.ToString() -ne '#32770') { return $true }
|
|
566
|
+
|
|
567
|
+
$buttons = New-Object System.Collections.ArrayList
|
|
568
|
+
$texts = New-Object System.Collections.ArrayList
|
|
569
|
+
$title = [MWW]::ReadText($h)
|
|
570
|
+
$hex = "0x{0:X}" -f ([int64]$h)
|
|
571
|
+
$script:dialogHwnds[$hex] = $h
|
|
572
|
+
$script:curDlgHex = $hex
|
|
573
|
+
|
|
574
|
+
$cb2 = [MWW+EnumWindowsProc]{
|
|
575
|
+
param($k, $l2)
|
|
576
|
+
if (-not [MWW]::IsWindowVisible($k)) { return $true }
|
|
577
|
+
$c = New-Object System.Text.StringBuilder 64
|
|
578
|
+
[void][MWW]::GetClassNameW($k, $c, 64)
|
|
579
|
+
$cn = $c.ToString()
|
|
580
|
+
# Only these two classes carry the question and its answers, and the class
|
|
581
|
+
# is checked BEFORE reading text on purpose: every cross-process read can
|
|
582
|
+
# wait out its full timeout while the IDE is busy loading a project, so
|
|
583
|
+
# reading text for controls we do not care about made one inspection pass
|
|
584
|
+
# take seconds and hung the whole open call.
|
|
585
|
+
if ($cn -ne 'Button' -and $cn -ne 'Static') { return $true }
|
|
586
|
+
$txt = [MWW]::ReadText($k)
|
|
587
|
+
if (-not $txt) { return $true }
|
|
588
|
+
if ($cn -eq 'Button') {
|
|
589
|
+
$bid = [MWW]::GetDlgCtrlID($k)
|
|
590
|
+
# The handle is needed to press it, and an IntPtr is not JSON, so it
|
|
591
|
+
# travels in a side map keyed by dialog|id rather than in the result.
|
|
592
|
+
$script:dialogButtons["$script:curDlgHex|$bid"] = $k
|
|
593
|
+
[void]$buttons.Add([ordered]@{
|
|
594
|
+
id = $bid
|
|
595
|
+
label = ($txt -replace '&', '').Trim()
|
|
596
|
+
})
|
|
597
|
+
} elseif ($cn -eq 'Static' -and $txt.Trim()) {
|
|
598
|
+
[void]$texts.Add($txt.Trim())
|
|
599
|
+
}
|
|
600
|
+
return $true
|
|
601
|
+
}
|
|
602
|
+
[void][MWW]::EnumChildWindows($h, $cb2, [IntPtr]::Zero)
|
|
603
|
+
|
|
604
|
+
[void]$found.Add([ordered]@{
|
|
605
|
+
handle = $hex
|
|
606
|
+
title = $title
|
|
607
|
+
message = (($texts | Select-Object -Unique) -join "`n")
|
|
608
|
+
buttons = $buttons
|
|
609
|
+
enabled = [MWW]::IsWindowEnabled($h)
|
|
610
|
+
})
|
|
611
|
+
return $true
|
|
612
|
+
}
|
|
613
|
+
$script:wantPid = $idePid
|
|
614
|
+
$script:dialogButtons = @{}
|
|
615
|
+
[void][MWW]::EnumWindows($cb, [IntPtr]::Zero)
|
|
616
|
+
return $found
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
# One screen of truth about the IDE, suitable for a tool result.
|
|
620
|
+
function Get-IdeState {
|
|
621
|
+
$trial = Get-TrialDialog
|
|
622
|
+
$trialPresent = [bool]$trial[0]
|
|
623
|
+
$verifierRunning = [bool](Get-Process -Name mwctVerify -ErrorAction SilentlyContinue)
|
|
624
|
+
$w = Get-IdeWindow
|
|
625
|
+
$dialogs = Get-IdeDialogs
|
|
626
|
+
$blocked = $trialPresent
|
|
627
|
+
$enabled = $null
|
|
628
|
+
if ($w) {
|
|
629
|
+
$enabled = [MWW]::IsWindowEnabled([IntPtr]$w)
|
|
630
|
+
$blocked = $trialPresent -or (-not $enabled) -or ($dialogs.Count -gt 0)
|
|
631
|
+
}
|
|
632
|
+
$t = ''
|
|
633
|
+
if ($w) {
|
|
634
|
+
$t = [MWW]::ReadText([IntPtr]$w)
|
|
635
|
+
}
|
|
636
|
+
return [ordered]@{
|
|
637
|
+
ide_running = [bool]$w
|
|
638
|
+
ide_window = $(if ($w) { "0x{0:X}" -f ([int64]$w) } else { $null })
|
|
639
|
+
window_title = $t
|
|
640
|
+
ide_enabled = $enabled
|
|
641
|
+
blocked = $blocked
|
|
642
|
+
dialog_count = $dialogs.Count
|
|
643
|
+
dialogs = @($dialogs)
|
|
644
|
+
trial_dialog = $trialPresent
|
|
645
|
+
trial_dialog_hwnd = $(if ($trialPresent) { "0x{0:X}" -f ([int64]$trial[0]) } else { $null })
|
|
646
|
+
verifier_running = $verifierRunning
|
|
647
|
+
hint = $(if ($trialPresent) {
|
|
648
|
+
'The MotionWorks trial verifier is waiting. Use mw_ide_trial(attempt:true) for one native Use Trial action and verified closure. Do not launch another IDE or use generic answer_dialog.'
|
|
649
|
+
} elseif ($blocked) {
|
|
650
|
+
'A modal dialog is blocking the IDE. The automation API returns nothing while it is up, so "no project" / "IDE closed" MUST NOT be concluded. Answer it with answer_dialog.'
|
|
651
|
+
} else { $null })
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
# Prompts MotionWorks raises on open that have one obviously-safe answer. Each is
|
|
656
|
+
# a recoverable question about loading a COPY in the staging area, so stopping to
|
|
657
|
+
# ask a human for every one of them is what made opening a project look broken.
|
|
658
|
+
# Anything not in this table is left strictly alone and reported, so the agent
|
|
659
|
+
# decides rather than the bridge guessing.
|
|
660
|
+
$script:knownAnswers = @(
|
|
661
|
+
@{ match = 'defragment'; button = 'No' }
|
|
662
|
+
@{ match = 'would you like to load the project anyway'; button = 'Yes' }
|
|
663
|
+
@{ match = 'currently loaded by'; button = 'Yes' }
|
|
664
|
+
@{ match = 'abnormal termination'; button = 'Yes' }
|
|
665
|
+
@{ match = 'disabled/enabled by software key'; button = 'OK' }
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
function Resolve-KnownDialogs {
|
|
669
|
+
# ONE enumeration, answering everything that matches. An earlier version looped
|
|
670
|
+
# up to six passes internally, and each pass re-enumerated and re-read every
|
|
671
|
+
# dialog - so a single call could cost tens of seconds while the IDE was busy
|
|
672
|
+
# loading (every cross-process read waits out its timeout), which is what made
|
|
673
|
+
# an open call run for minutes. Callers repeat, so one pass is enough.
|
|
674
|
+
#
|
|
675
|
+
# Inspection is also globally budgeted: past $script:dialogBudget the IDE is
|
|
676
|
+
# plainly too busy to interrogate, and spending more of the call's deadline on
|
|
677
|
+
# it guarantees a timeout instead of an answer.
|
|
678
|
+
if ($null -eq $script:dialogSpent) { $script:dialogSpent = 0.0 }
|
|
679
|
+
if ($script:dialogSpent -gt 25.0) { return @() }
|
|
680
|
+
$swD = [Diagnostics.Stopwatch]::StartNew()
|
|
681
|
+
try {
|
|
682
|
+
$answered = New-Object System.Collections.ArrayList
|
|
683
|
+
$dialogs = @(Get-IdeDialogs)
|
|
684
|
+
if ($dialogs.Count -eq 0) { return $answered }
|
|
685
|
+
foreach ($d in $dialogs) {
|
|
686
|
+
$hay = ("$($d.title)`n$($d.message)").ToLower()
|
|
687
|
+
foreach ($k in $script:knownAnswers) {
|
|
688
|
+
if ($hay -notlike "*$($k.match)*") { continue }
|
|
689
|
+
$btn = $d.buttons | Where-Object { $_.label -ieq $k.button } | Select-Object -First 1
|
|
690
|
+
if ($btn) {
|
|
691
|
+
$bh = $script:dialogButtons["$($d.handle)|$($btn.id)"]
|
|
692
|
+
if ($bh -and [MWW]::ClickButton([IntPtr]$bh)) {
|
|
693
|
+
[void]$answered.Add([ordered]@{
|
|
694
|
+
dialog = $d.handle
|
|
695
|
+
matched = $k.match
|
|
696
|
+
pressed = $btn.label
|
|
697
|
+
message = $d.message
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
break
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if ($answered.Count -gt 0) { Start-Sleep -Milliseconds 700 }
|
|
705
|
+
return $answered
|
|
706
|
+
} finally {
|
|
707
|
+
$swD.Stop()
|
|
708
|
+
$script:dialogSpent += $swD.Elapsed.TotalSeconds
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
# --- COM connection, held open across requests ---
|
|
713
|
+
$script:App = $null
|
|
714
|
+
|
|
715
|
+
function Connect-App {
|
|
716
|
+
if ($script:App -ne $null) {
|
|
717
|
+
# A cached connection can outlive the IDE it points at: the coding loop
|
|
718
|
+
# closes the IDE to write code, then starts it again. The dead proxy stays
|
|
719
|
+
# valid as an object, so without this probe every later call fails with
|
|
720
|
+
# "The RPC server is unavailable" (0x800706BA) until the bridge restarts.
|
|
721
|
+
#
|
|
722
|
+
# Require a REAL answer, not merely "did not throw": a half-dead proxy
|
|
723
|
+
# returns an empty Version and then fails the next call, which is exactly
|
|
724
|
+
# how a stale connection slipped through the earlier check.
|
|
725
|
+
try {
|
|
726
|
+
$probe = [string]$script:App.Version
|
|
727
|
+
if (-not [string]::IsNullOrWhiteSpace($probe)) { return $script:App }
|
|
728
|
+
Log "cached connection answered with an empty Version; dropping it"
|
|
729
|
+
} catch {
|
|
730
|
+
Log "cached connection failed its probe ($($_.Exception.Message)); dropping it"
|
|
731
|
+
}
|
|
732
|
+
$script:App = $null
|
|
733
|
+
}
|
|
734
|
+
if (-not (Get-IdeWindow)) {
|
|
735
|
+
# A freshly launched IDE has a live Mwt process before it has a top-level window,
|
|
736
|
+
# and the coding loop closes and restarts the IDE constantly. Polling while the
|
|
737
|
+
# process exists turns "start_ide then open" from a spurious refusal into a wait.
|
|
738
|
+
# When no Mwt process exists there is genuinely no IDE, and the refusal stands at
|
|
739
|
+
# once rather than making every request pay a timeout.
|
|
740
|
+
$deadline = (Get-Date).AddSeconds(20)
|
|
741
|
+
while ((Get-Date) -lt $deadline -and -not (Get-IdeWindow) -and (Get-Process -Name Mwt -ErrorAction SilentlyContinue)) {
|
|
742
|
+
Start-Sleep -Milliseconds 400
|
|
743
|
+
}
|
|
744
|
+
if (Get-IdeWindow) { Log 'IDE window appeared while waiting for a freshly started IDE' }
|
|
745
|
+
}
|
|
746
|
+
if (-not (Get-IdeWindow)) {
|
|
747
|
+
throw "no running MotionWorks IDE window; refusing to instantiate (that would launch a new IDE and a trial licence permits only one instance). Use the 'start_ide' verb to launch one deliberately."
|
|
748
|
+
}
|
|
749
|
+
$script:App = New-Object -ComObject Ade.Application.550
|
|
750
|
+
Log "connected: Version=$($script:App.Version)"
|
|
751
|
+
return $script:App
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function Get-VariableSheet($app,[string]$pou) {
|
|
755
|
+
if ($pou) {
|
|
756
|
+
$matches=@()
|
|
757
|
+
for($i=1;$i -le $app.ActiveProject.Pous.Count;$i++) {
|
|
758
|
+
$p=$app.ActiveProject.Pous.Item($i)
|
|
759
|
+
if([string]$p.Name -ieq $pou){$matches+=,$p}
|
|
760
|
+
}
|
|
761
|
+
if($matches.Count -ne 1){throw 'REFUSED: exact POU not found'}
|
|
762
|
+
return ,($matches[0].Variables)
|
|
763
|
+
}
|
|
764
|
+
$resource=$app.ActiveProject.GetObjectByLogicalName('Hardware/Configuration/Resource',10)
|
|
765
|
+
if(-not $resource){throw 'REFUSED: exact resource not found'}
|
|
766
|
+
return ,($resource.Variables)
|
|
767
|
+
}
|
|
768
|
+
function Get-VariableSnapshot($app,[string]$pou,$requireSaved=$false) {
|
|
769
|
+
if($requireSaved -isnot [bool]){throw 'REFUSED: require_saved must be an explicit boolean'}
|
|
770
|
+
if($requireSaved -and $app.ActiveProject.IsModified -ne $false){throw 'REFUSED: reconcile/save native edits before snapshot'}
|
|
771
|
+
$vars=Get-VariableSheet $app $pou
|
|
772
|
+
$groups=@()
|
|
773
|
+
for($i=1;$i -le $vars.Groups.Count;$i++) {
|
|
774
|
+
$g=$vars.Groups.Item($i)
|
|
775
|
+
$groups+=@{name=[string]$g.Name;read_only=[bool]$g.ReadOnly}
|
|
776
|
+
}
|
|
777
|
+
$data=[ordered]@{variables=(Get-VariableRows $vars);groups=$groups}
|
|
778
|
+
if($requireSaved) {
|
|
779
|
+
if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: native saved state changed during snapshot'}
|
|
780
|
+
$data.is_modified=$false
|
|
781
|
+
}
|
|
782
|
+
return $data
|
|
783
|
+
}
|
|
784
|
+
function Initialize-SdkVariableReader {
|
|
785
|
+
if ($script:SdkReaderAttempted) { return }
|
|
786
|
+
$script:SdkReaderAttempted=$true
|
|
787
|
+
$script:SdkReaderEnabled=$false
|
|
788
|
+
if ($env:MW_DISABLE_SDK_READER -eq '1') {
|
|
789
|
+
Log 'SDK variable reader disabled by diagnostic override; using complete legacy reader'
|
|
790
|
+
return
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
$sdk=Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Yaskawa\MotionWorks IEC 3 Pro\Ade.tlb'
|
|
794
|
+
# Only the exact SDK whose full native rows and signatures were verified.
|
|
795
|
+
$supported='7ccec37b8fcff5d1a55198f51a7f97ded0429b2eb0ab740b5cede8cfd9950c6e'
|
|
796
|
+
if (-not (Test-Path -LiteralPath $sdk) -or (Get-MwSha256 $sdk) -ine $supported) {
|
|
797
|
+
Log 'SDK variable reader unavailable: unverified SDK; using complete legacy reader'
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
$directory=Join-Path $BridgeDir ('sdk-reader-'+[guid]::NewGuid().ToString())
|
|
801
|
+
[void](New-Item -ItemType Directory -Path $directory -ErrorAction Stop)
|
|
802
|
+
Add-Type -Path (Join-Path $PSScriptRoot 'sdk-interop-importer.cs') -ErrorAction Stop
|
|
803
|
+
[void][MwSdkInteropImporter]::Generate($sdk,$directory)
|
|
804
|
+
if ((Get-MwSha256 $sdk) -ine $supported) { throw 'SDK changed during generation' }
|
|
805
|
+
$assembly=Join-Path $directory 'ADELib.dll'
|
|
806
|
+
$assemblyHash=Get-MwSha256 $assembly
|
|
807
|
+
[void][Reflection.Assembly]::LoadFrom($assembly)
|
|
808
|
+
Add-Type -Path (Join-Path $PSScriptRoot 'sdk-variable-reader.cs') -ReferencedAssemblies $assembly -ErrorAction Stop
|
|
809
|
+
if ((Get-MwSha256 $assembly) -ine $assemblyHash) { throw 'Generated assembly changed during loading' }
|
|
810
|
+
$script:SdkReaderFile=$sdk
|
|
811
|
+
$script:SdkReaderHash=$supported
|
|
812
|
+
$script:SdkReaderEnabled=$true
|
|
813
|
+
Log 'SDK variable reader enabled: verified installed SDK and locally generated interop'
|
|
814
|
+
} catch {
|
|
815
|
+
# Initialization reads SDK metadata only. No native declaration read has
|
|
816
|
+
# started, so the complete existing reader remains a safe fallback.
|
|
817
|
+
Log ('SDK variable reader initialization unavailable; using complete legacy reader: '+$_.Exception.Message)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function Get-VariableRows($vars) {
|
|
822
|
+
if ($script:SdkReaderEnabled) {
|
|
823
|
+
if ((Get-MwSha256 $script:SdkReaderFile) -ine $script:SdkReaderHash) { throw 'SDK changed after reader initialization; inspect before further native operations' }
|
|
824
|
+
# Never fall back or retry if a typed native read fails partway through.
|
|
825
|
+
return ,([MwSdkVariableReader]::Read($vars))
|
|
826
|
+
}
|
|
827
|
+
$sections=@{1='VAR';2='VAR_INPUT';3='VAR_OUTPUT';4='VAR_IN_OUT';5='VAR_EXTERNAL';6='VAR_GLOBAL'}
|
|
828
|
+
$rows=@()
|
|
829
|
+
$count=[int]$vars.Count
|
|
830
|
+
for($i=1;$i -le $count;$i++) {
|
|
831
|
+
$v=$vars.Item($i); $section=$sections[[int]$v.BlockType]
|
|
832
|
+
if(-not $section){throw 'Unsupported native declaration usage; no mutation allowed'}
|
|
833
|
+
$row=[ordered]@{name=[string]$v.Name;type=[string]$v.DataType;section=$section;group=[string]$v.Group.Name;
|
|
834
|
+
address=[string]$v.IecAddress;initial_value=[string]$v.InitialValue;description=[string]$v.Comment;
|
|
835
|
+
retain=[bool]$v.Retain;pdd=[bool]$v.PDD;opc=[bool]$v.OPC;disabled=[bool]$v.Disabled;not_on_plc=[bool]$v.NotOnPlc;redundant=[bool]$v.Redundant}
|
|
836
|
+
foreach($field in @('address','initial_value','description')){if($row[$field] -eq ''){$row[$field]=$null}}
|
|
837
|
+
$rows+=,$row
|
|
838
|
+
}
|
|
839
|
+
if([int]$vars.Count -ne $count){throw 'Native declaration count changed during inspection'}
|
|
840
|
+
return ,$rows
|
|
841
|
+
}
|
|
842
|
+
function Get-GroupMemberRows($vars) {
|
|
843
|
+
$groups=@();$collection=$vars.Groups;$count=[int]$collection.Count
|
|
844
|
+
for($i=1;$i -le $count;$i++){
|
|
845
|
+
$g=$collection.Item($i);$members=@();$variables=$g.Variables;$memberCount=[int]$variables.Count
|
|
846
|
+
for($j=1;$j -le $memberCount;$j++){$members+=,[string]$variables.Item($j).Name}
|
|
847
|
+
if([int]$variables.Count -ne $memberCount){throw 'Native group members changed during inspection'}
|
|
848
|
+
$groups+=,[ordered]@{name=[string]$g.Name;read_only=[bool]$g.ReadOnly;members=$members}
|
|
849
|
+
}
|
|
850
|
+
if([int]$collection.Count -ne $count){throw 'Native group count changed during inspection'}
|
|
851
|
+
return ,$groups
|
|
852
|
+
}
|
|
853
|
+
function Get-VariableGroupSnapshot($app,[string]$pou) {
|
|
854
|
+
$vars=Get-VariableSheet $app $pou;$structure=Get-NativeStructure $app
|
|
855
|
+
$membership=[ordered]@{globals=(Get-GroupMemberRows (Get-VariableSheet $app ''))}
|
|
856
|
+
$pous=$app.ActiveProject.Pous;$pouCount=[int]$pous.Count
|
|
857
|
+
if($pouCount -ne $structure.pous.Count){throw 'Native POU count changed during group inspection'}
|
|
858
|
+
for($i=1;$i -le $pouCount;$i++){
|
|
859
|
+
$p=$pous.Item($i);$name=[string]$p.Name
|
|
860
|
+
if($name -cne $structure.pous[$i-1].name){throw 'Native POU identity changed during group inspection'}
|
|
861
|
+
$membership['pou:'+$name]=Get-GroupMemberRows $p.Variables
|
|
862
|
+
}
|
|
863
|
+
$key=if($pou){'pou:'+$pou}else{'globals'}
|
|
864
|
+
$groups=@($membership[$key] | ForEach-Object {[ordered]@{name=$_.name;read_only=$_.read_only}})
|
|
865
|
+
$libraries=@();$collection=$app.ActiveProject.Libraries;$count=[int]$collection.Count
|
|
866
|
+
for($i=1;$i -le $count;$i++){$lib=$collection.Item($i);$libraries+=,[ordered]@{name=[string]$lib.Name;path=[string]$lib.FullName;logical_name=[string]$lib.LogicalName}}
|
|
867
|
+
if([int]$collection.Count -ne $count){throw 'Native library count changed during inspection'}
|
|
868
|
+
if(($structure | ConvertTo-Json -Depth 20 -Compress) -cne ((Get-NativeStructure $app) | ConvertTo-Json -Depth 20 -Compress)){throw 'Native structure changed during group inspection'}
|
|
869
|
+
return [ordered]@{variables=(Get-VariableRows $vars);groups=$groups;structure=$structure;libraries=$libraries;group_membership=$membership}
|
|
870
|
+
}
|
|
871
|
+
function Assert-VariableBaseline($expected,$actual) {
|
|
872
|
+
if($null -eq $expected -or $expected.Count -ne $actual.Count){throw 'REFUSED: native declaration baseline changed'}
|
|
873
|
+
foreach($row in $expected) {
|
|
874
|
+
$match=@($actual | Where-Object {$_.name -ieq $row.name})
|
|
875
|
+
if($match.Count -ne 1){throw 'REFUSED: native declaration baseline changed'}
|
|
876
|
+
foreach($field in @('name','type','section','group','address','initial_value','description','retain','pdd','opc','disabled','not_on_plc','redundant')) {
|
|
877
|
+
if($row.$field -cne $match[0].$field){throw "REFUSED: native baseline changed: $($row.name).$field"}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
function Get-NativeStructure($app) {
|
|
882
|
+
$pous=@();$tasks=@();$types=@{7='PROGRAM';6='FUNCTION_BLOCK';24='FUNCTION'}
|
|
883
|
+
$collection=$app.ActiveProject.Pous
|
|
884
|
+
for($i=1;$i -le $collection.Count;$i++) {
|
|
885
|
+
$p=$collection.Item($i);$type=$types[[int]$p.PouType]
|
|
886
|
+
if(-not $type){throw 'Unsupported native POU type'}
|
|
887
|
+
$pous+=,[ordered]@{name=[string]$p.Name;type=$type;language=[int]$p.PouLanguage;
|
|
888
|
+
plc_type=[string]$p.PlcType;processor_type=[string]$p.ProcessorType;read_only=[bool]$p.ReadOnly}
|
|
889
|
+
}
|
|
890
|
+
$resource=$app.ActiveProject.GetObjectByLogicalName('Hardware/Configuration/Resource',10)
|
|
891
|
+
if(-not $resource){throw 'Exact resource not found'}
|
|
892
|
+
$collection=$resource.Tasks
|
|
893
|
+
for($i=1;$i -le $collection.Count;$i++) {
|
|
894
|
+
$t=$collection.Item($i);$instances=@()
|
|
895
|
+
for($j=1;$j -le $t.ProgramInstances.Count;$j++) {
|
|
896
|
+
$p=$t.ProgramInstances.Item($j)
|
|
897
|
+
$instances+=,[ordered]@{name=[string]$p.Name;type=[string]$p.Type}
|
|
898
|
+
}
|
|
899
|
+
$tasks+=,[ordered]@{name=[string]$t.Name;kind=[string]$t.Type;instances=$instances}
|
|
900
|
+
}
|
|
901
|
+
return [ordered]@{pous=$pous;tasks=$tasks}
|
|
902
|
+
}
|
|
903
|
+
function Normalize-MwPath([string]$path) {
|
|
904
|
+
if ([string]::IsNullOrWhiteSpace($path)) { return '' }
|
|
905
|
+
try { return [IO.Path]::GetFullPath($path).TrimEnd('\') } catch { return $path.Trim().TrimEnd('\') }
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function Set-RequestScope($req) {
|
|
909
|
+
$verb = [string]$req.verb
|
|
910
|
+
# Scope is supplied for EACH request, never inherited from the last chat.
|
|
911
|
+
$script:WorkspaceRoot = $null
|
|
912
|
+
$script:StageRoot = ''
|
|
913
|
+
if ($verb -notin @('ping', 'stop')) {
|
|
914
|
+
if ([string]::IsNullOrWhiteSpace([string]$req.workspace) -or
|
|
915
|
+
-not [IO.Path]::IsPathRooted([string]$req.workspace)) {
|
|
916
|
+
throw 'REFUSED: the bridge request has no absolute session workspace.'
|
|
917
|
+
}
|
|
918
|
+
Assert-NoLinkedPath ([string]$req.workspace)
|
|
919
|
+
$script:WorkspaceRoot = Normalize-MwPath ([string]$req.workspace)
|
|
920
|
+
$script:StageRoot = Join-Path $script:WorkspaceRoot '.motionworks\stage'
|
|
921
|
+
Assert-NoLinkedPath $StageRoot
|
|
922
|
+
if (-not ((Normalize-MwPath ([string]$req.stage_root)).Equals((Normalize-MwPath $StageRoot), [StringComparison]::OrdinalIgnoreCase))) {
|
|
923
|
+
throw 'REFUSED: bridge stage does not match the request workspace.'
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function Assert-NoLinkedPath([string]$path) {
|
|
929
|
+
$cursor = [IO.Path]::GetFullPath($path)
|
|
930
|
+
while ($cursor) {
|
|
931
|
+
# Check each ancestor afresh. FileSystem provider cmdlets are expensive
|
|
932
|
+
# across hundreds of backup members; the native attributes preserve the
|
|
933
|
+
# same reparse-point guard without caching paths across IDE operations.
|
|
934
|
+
$attributes = $null
|
|
935
|
+
try { $attributes = [IO.File]::GetAttributes($cursor) }
|
|
936
|
+
catch [IO.FileNotFoundException] { }
|
|
937
|
+
catch [IO.DirectoryNotFoundException] { }
|
|
938
|
+
if ($null -ne $attributes -and ($attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
|
939
|
+
throw "REFUSED: linked path cannot be used by the IDE bridge: $cursor"
|
|
940
|
+
}
|
|
941
|
+
$parent = [IO.Path]::GetDirectoryName($cursor)
|
|
942
|
+
if ($parent -eq $cursor) { break }
|
|
943
|
+
$cursor = $parent
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function Get-PouPackageState($app) {
|
|
948
|
+
$timing=[Diagnostics.Stopwatch]::StartNew()
|
|
949
|
+
Log 'package snapshot begin'
|
|
950
|
+
$project=$app.ActiveProject
|
|
951
|
+
$structure=Get-NativeStructure $app
|
|
952
|
+
Log ("package snapshot structure elapsed_ms={0}" -f $timing.ElapsedMilliseconds)
|
|
953
|
+
$pous=$project.Pous
|
|
954
|
+
$pouCount=[int]$pous.Count
|
|
955
|
+
if($pouCount -ne $structure.pous.Count){throw 'Native POU count changed during inspection'}
|
|
956
|
+
$resource=$project.GetObjectByLogicalName('Hardware/Configuration/Resource',10)
|
|
957
|
+
if(-not $resource){throw 'Exact resource not found'}
|
|
958
|
+
$sheets=[ordered]@{globals=$resource.Variables}
|
|
959
|
+
for($i=1;$i -le $pouCount;$i++){
|
|
960
|
+
$pou=$pous.Item($i);$name=[string]$pou.Name
|
|
961
|
+
if($name -cne $structure.pous[$i-1].name){throw 'Native POU identity changed during inspection'}
|
|
962
|
+
$sheets['pou:'+$name]=$pou.Variables
|
|
963
|
+
}
|
|
964
|
+
$declarations=[ordered]@{}
|
|
965
|
+
foreach($key in $sheets.Keys) {
|
|
966
|
+
$sheet=$sheets[$key];$groups=@();$collection=$sheet.Groups
|
|
967
|
+
$groupCount=[int]$collection.Count
|
|
968
|
+
Log ("package snapshot sheet={0} begin elapsed_ms={1}" -f $key,$timing.ElapsedMilliseconds)
|
|
969
|
+
for($i=1;$i -le $groupCount;$i++){$group=$collection.Item($i);$groups+=,[ordered]@{name=[string]$group.Name;read_only=[bool]$group.ReadOnly}}
|
|
970
|
+
if([int]$collection.Count -ne $groupCount){throw 'Native declaration groups changed during inspection'}
|
|
971
|
+
$declarations[$key]=[ordered]@{variables=(Get-VariableRows $sheet);groups=$groups}
|
|
972
|
+
Log ("package snapshot sheet={0} complete rows={1} elapsed_ms={2}" -f $key,$declarations[$key].variables.Count,$timing.ElapsedMilliseconds)
|
|
973
|
+
}
|
|
974
|
+
$libraries=@();$collection=$project.Libraries;$libraryCount=[int]$collection.Count
|
|
975
|
+
for($i=1;$i -le $libraryCount;$i++){$library=$collection.Item($i);$libraries+=,[ordered]@{name=[string]$library.Name;full_name=[string]$library.FullName;logical_name=[string]$library.LogicalName}}
|
|
976
|
+
if([int]$collection.Count -ne $libraryCount -or [int]$pous.Count -ne $pouCount){throw 'Native inventory changed during inspection'}
|
|
977
|
+
Log ("package snapshot complete elapsed_ms={0}" -f $timing.ElapsedMilliseconds)
|
|
978
|
+
return [ordered]@{structure=$structure;declarations=$declarations;libraries=$libraries}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function Get-PouPackageManifest([string]$directory) {
|
|
982
|
+
Assert-NoLinkedPath $directory
|
|
983
|
+
$files=@()
|
|
984
|
+
foreach($item in @(Get-ChildItem -LiteralPath $directory -Recurse -Force | Sort-Object FullName)) {
|
|
985
|
+
Assert-NoLinkedPath $item.FullName
|
|
986
|
+
if($item.PSIsContainer){continue}
|
|
987
|
+
$files+=,[ordered]@{path=$item.FullName.Substring($directory.TrimEnd('\').Length+1).Replace('\','/');bytes=[long]$item.Length;sha256=(Get-MwSha256 $item.FullName).ToLowerInvariant()}
|
|
988
|
+
}
|
|
989
|
+
return ,$files
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function Test-InsideWorkspace([string]$path) {
|
|
993
|
+
if ([string]::IsNullOrWhiteSpace($path) -or -not $script:WorkspaceRoot) { return $false }
|
|
994
|
+
Assert-NoLinkedPath $path
|
|
995
|
+
$full = Normalize-MwPath $path
|
|
996
|
+
$root = Normalize-MwPath $script:WorkspaceRoot
|
|
997
|
+
return ($full.Equals($root, [StringComparison]::OrdinalIgnoreCase) -or
|
|
998
|
+
$full.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase))
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function Test-InsideStage([string]$path) {
|
|
1002
|
+
if (-not (Test-InsideWorkspace $path)) { return $false }
|
|
1003
|
+
$full = Normalize-MwPath $path
|
|
1004
|
+
$root = Normalize-MwPath $StageRoot
|
|
1005
|
+
if (-not $full -or -not $root) { return $false }
|
|
1006
|
+
if ($full.Equals($root, [StringComparison]::OrdinalIgnoreCase) -or
|
|
1007
|
+
$full.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase)) { return $true }
|
|
1008
|
+
# Legacy callers use this guard for every authorized IDE project. A direct
|
|
1009
|
+
# workspace attachment is authorized only with its verified backup identity.
|
|
1010
|
+
return ($null -ne (Get-DirectIdentity $path))
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function Get-DirectIdentity([string]$path) {
|
|
1014
|
+
if (-not (Test-InsideWorkspace $path)) { return $null }
|
|
1015
|
+
$folder = Join-Path $script:WorkspaceRoot '.motionworks\attached\records'
|
|
1016
|
+
if (-not (Test-Path -LiteralPath $folder)) { return $null }
|
|
1017
|
+
foreach ($file in Get-ChildItem -LiteralPath $folder -Filter '*.json' -File) {
|
|
1018
|
+
Assert-NoLinkedPath $file.FullName
|
|
1019
|
+
try { $raw = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json }
|
|
1020
|
+
catch { continue }
|
|
1021
|
+
if ([string]$raw.mode -cne 'direct' -or -not (Test-SameProject ([string]$raw.mwt) $path)) { continue }
|
|
1022
|
+
$backupRoot = Normalize-MwPath (Join-Path $script:WorkspaceRoot '.motionworks\attached\backups')
|
|
1023
|
+
$backup = Normalize-MwPath ([string]$raw.backup)
|
|
1024
|
+
$manifestPath = Normalize-MwPath ([string]$raw.backup_manifest)
|
|
1025
|
+
if (-not (Test-InsideWorkspace $backup) -or -not (Test-InsideWorkspace $manifestPath)) { return $null }
|
|
1026
|
+
if (-not (Test-InsideWorkspace ([string]$raw.mwt)) -or
|
|
1027
|
+
-not (Test-InsideWorkspace ([string]$raw.directory)) -or
|
|
1028
|
+
-not ((Normalize-MwPath ([string]$raw.workspace)).Equals((Normalize-MwPath $script:WorkspaceRoot), [StringComparison]::OrdinalIgnoreCase)) -or
|
|
1029
|
+
-not (Test-SameProject ([string]$raw.directory) $path) -or
|
|
1030
|
+
-not (Test-Path -LiteralPath ([string]$raw.mwt)) -or
|
|
1031
|
+
-not (Test-Path -LiteralPath ([string]$raw.directory)) -or
|
|
1032
|
+
-not $backup.StartsWith($backupRoot + '\', [StringComparison]::OrdinalIgnoreCase) -or
|
|
1033
|
+
-not $manifestPath.StartsWith($backup + '\', [StringComparison]::OrdinalIgnoreCase) -or
|
|
1034
|
+
-not (Test-Path -LiteralPath ([string]$raw.backup_manifest))) { return $null }
|
|
1035
|
+
if ((Get-MwSha256 ([string]$raw.backup_manifest)) -ine [string]$raw.backup_manifest_sha256) { return $null }
|
|
1036
|
+
try {
|
|
1037
|
+
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
1038
|
+
foreach ($item in @($manifest.files)) {
|
|
1039
|
+
$member = Normalize-MwPath (Join-Path $backup ([string]$item.path))
|
|
1040
|
+
if (-not $member.StartsWith($backup + '\', [StringComparison]::OrdinalIgnoreCase) -or
|
|
1041
|
+
-not (Test-InsideWorkspace $member) -or
|
|
1042
|
+
-not (Test-Path -LiteralPath $member)) { return $null }
|
|
1043
|
+
}
|
|
1044
|
+
} catch { return $null }
|
|
1045
|
+
return $raw
|
|
1046
|
+
}
|
|
1047
|
+
return $null
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function Test-SameProject([string]$active, [string]$requested) {
|
|
1051
|
+
$a = Normalize-MwPath $active
|
|
1052
|
+
$r = Normalize-MwPath $requested
|
|
1053
|
+
if (-not $a -or -not $r) { return $false }
|
|
1054
|
+
$bare = {
|
|
1055
|
+
param($p)
|
|
1056
|
+
if ($p.EndsWith('.mwt', [StringComparison]::OrdinalIgnoreCase)) { return $p.Substring(0, $p.Length - 4) }
|
|
1057
|
+
return $p
|
|
1058
|
+
}
|
|
1059
|
+
$aBare = & $bare $a
|
|
1060
|
+
$rBare = & $bare $r
|
|
1061
|
+
return $aBare.Equals($rBare, [StringComparison]::OrdinalIgnoreCase)
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function Get-MwSha256([string]$path) {
|
|
1065
|
+
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
1066
|
+
$stream = [System.IO.File]::OpenRead($path)
|
|
1067
|
+
try { return [System.BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-', '') }
|
|
1068
|
+
finally { $stream.Dispose(); $sha.Dispose() }
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function Get-OpenProjectPath($app) {
|
|
1072
|
+
try {
|
|
1073
|
+
if (-not $app.IsProjectOpen()) { return $null }
|
|
1074
|
+
$name = [string]$app.ActiveProject.FullName
|
|
1075
|
+
if ([string]::IsNullOrWhiteSpace($name)) { return $null }
|
|
1076
|
+
return $name
|
|
1077
|
+
} catch { return $null }
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
# Every live IDE verb that compiles, saves, or reads the open project calls this.
|
|
1081
|
+
# A project outside stage/ is the wrong project — often one MotionWorks restored,
|
|
1082
|
+
# or one the .mwt wrapper still pointed at — and it is not edited from here.
|
|
1083
|
+
function Assert-ProvenCopy([string]$path) {
|
|
1084
|
+
if (Get-DirectIdentity $path) { return }
|
|
1085
|
+
# A directory under stage/ is not provenance. mw_ide_stage writes <name>.identity.json
|
|
1086
|
+
# with the workspace source. Compile and save refuse a copy that has none.
|
|
1087
|
+
$full = Normalize-MwPath $path
|
|
1088
|
+
if ($full.EndsWith('.mwt', [StringComparison]::OrdinalIgnoreCase)) {
|
|
1089
|
+
$full = $full.Substring(0, $full.Length - 4)
|
|
1090
|
+
}
|
|
1091
|
+
$name = [IO.Path]::GetFileName($full)
|
|
1092
|
+
$ident = Join-Path (Normalize-MwPath $StageRoot) ($name + '.identity.json')
|
|
1093
|
+
Assert-NoLinkedPath $ident
|
|
1094
|
+
if (-not (Test-Path -LiteralPath $ident)) {
|
|
1095
|
+
throw "REFUSED: '$path' has no identity file. Stage it from the workspace with mw_ide_stage. A project dropped into stage is not compiled or saved."
|
|
1096
|
+
}
|
|
1097
|
+
try { $raw = Get-Content -LiteralPath $ident -Raw -Encoding UTF8 | ConvertFrom-Json } catch {
|
|
1098
|
+
throw "REFUSED: could not read the identity for '$name'."
|
|
1099
|
+
}
|
|
1100
|
+
if ([string]::IsNullOrWhiteSpace([string]$raw.source)) {
|
|
1101
|
+
throw "REFUSED: staged project '$name' has no recorded source. Stage it again from the workspace project before compiling or saving it."
|
|
1102
|
+
}
|
|
1103
|
+
if (-not (Test-InsideStage $path) -or
|
|
1104
|
+
-not (Test-InsideWorkspace ([string]$raw.source)) -or
|
|
1105
|
+
-not (Test-InsideWorkspace ([string]$raw.source_directory)) -or
|
|
1106
|
+
-not ((Normalize-MwPath ([string]$raw.workspace)).Equals((Normalize-MwPath $script:WorkspaceRoot), [StringComparison]::OrdinalIgnoreCase)) -or
|
|
1107
|
+
-not (Test-SameProject ([string]$raw.staged_mwt) $path) -or
|
|
1108
|
+
-not (Test-SameProject ([string]$raw.staged_directory) $path) -or
|
|
1109
|
+
-not (Test-SameProject ([string]$raw.bound_to) $path)) {
|
|
1110
|
+
throw "REFUSED: project identity does not belong to this request's workspace."
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function Assert-StagedOpen($app, [string]$what) {
|
|
1116
|
+
$path = Get-OpenProjectPath $app
|
|
1117
|
+
if (-not $path) { throw 'no project is open in the IDE' }
|
|
1118
|
+
# A direct identity already proves workspace containment and the backup.
|
|
1119
|
+
# Avoid traversing that same backup again through both legacy stage guards.
|
|
1120
|
+
# This is local to this invocation: later verbs validate a fresh identity.
|
|
1121
|
+
if (Get-DirectIdentity $path) { return $path }
|
|
1122
|
+
if (-not (Test-InsideStage $path)) {
|
|
1123
|
+
throw "REFUSED: $what would act on unverified project '$path' in workspace '$script:WorkspaceRoot'. Attach the exact workspace project with mw_ide_attach and a verified backup before editing."
|
|
1124
|
+
}
|
|
1125
|
+
Assert-ProvenCopy $path
|
|
1126
|
+
return $path
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function Assert-CloseConsent($request, [string]$current, [string]$what) {
|
|
1130
|
+
if (-not [bool]$request.user_approved) {
|
|
1131
|
+
throw "REFUSED: $what requires the user's approval to close MotionWorks. Ask first, then pass user_approved=true."
|
|
1132
|
+
}
|
|
1133
|
+
$expected = [string]$request.expected_project
|
|
1134
|
+
if (-not $current -and $expected) {
|
|
1135
|
+
throw "REFUSED: MotionWorks no longer has the approved project '$expected' open. Check IDE status and ask again before closing it."
|
|
1136
|
+
}
|
|
1137
|
+
if ($current) {
|
|
1138
|
+
if (-not $expected -or -not (Test-SameProject $expected $current)) {
|
|
1139
|
+
throw "REFUSED: MotionWorks now has '$current' open, which does not match the approved project '$expected'. Ask the user again before closing it."
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
Log "bridge started pid=$PID bits=$([IntPtr]::Size * 8) stage=$StageRoot"
|
|
1145
|
+
|
|
1146
|
+
while ($true) {
|
|
1147
|
+
if (-not (Test-Path $reqPath)) { Start-Sleep -Milliseconds $PollMs; continue }
|
|
1148
|
+
|
|
1149
|
+
$raw = $null; $req = $null
|
|
1150
|
+
try { $raw = Get-Content -Path $reqPath -Raw -Encoding UTF8; $req = $raw | ConvertFrom-Json }
|
|
1151
|
+
catch { Start-Sleep -Milliseconds $PollMs; continue } # half-written; retry
|
|
1152
|
+
if ($null -eq $req -or $null -eq $req.verb) { Start-Sleep -Milliseconds $PollMs; continue }
|
|
1153
|
+
|
|
1154
|
+
$id = $req.id
|
|
1155
|
+
$verb = [string]$req.verb
|
|
1156
|
+
Log "req id=$id verb=$verb"
|
|
1157
|
+
|
|
1158
|
+
$data = $null; $err = $null; $ok = $false
|
|
1159
|
+
try {
|
|
1160
|
+
Set-RequestScope $req
|
|
1161
|
+
if ($verb -in @('variable_snapshot','variable_mutate','variable_group_snapshot','variable_group_mutate','pou_package_snapshot','pou_package_mutate','pou_convert','code_mutate')) {
|
|
1162
|
+
Initialize-SdkVariableReader
|
|
1163
|
+
}
|
|
1164
|
+
switch ($verb) {
|
|
1165
|
+
|
|
1166
|
+
'ping' {
|
|
1167
|
+
$ok = $true
|
|
1168
|
+
$data = [ordered]@{ alive = $true; workspace_protocol = 6; ide_window = (Get-IdeWindow -ne $null) }
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
'stop' {
|
|
1172
|
+
# Consume the request BEFORE exiting. Leaving req.json behind means
|
|
1173
|
+
# the next bridge to start immediately reads this stale stop and
|
|
1174
|
+
# dies on launch.
|
|
1175
|
+
Remove-Item -Force $reqPath -ErrorAction SilentlyContinue
|
|
1176
|
+
Write-Result $id $verb $true ([ordered]@{ stopping = $true }) $null
|
|
1177
|
+
Log "stop requested; exiting"
|
|
1178
|
+
Remove-BridgeLock
|
|
1179
|
+
if ($script:App -ne $null) { try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($script:App) } catch { } }
|
|
1180
|
+
exit 0
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
'status' {
|
|
1184
|
+
$app = Connect-App
|
|
1185
|
+
$ideW = Get-IdeWindow
|
|
1186
|
+
$isOpen = $null; $activeName = $null
|
|
1187
|
+
try { $isOpen = $app.IsProjectOpen() } catch { $isOpen = $null }
|
|
1188
|
+
if ($isOpen) { try { $activeName = $app.ActiveProject.FullName } catch { } }
|
|
1189
|
+
$directIdentity = if ($activeName) { Get-DirectIdentity $activeName } else { $null }
|
|
1190
|
+
$inStage = $false
|
|
1191
|
+
if ($directIdentity) { $inStage = $true }
|
|
1192
|
+
elseif ($activeName) { $inStage = Test-InsideStage $activeName }
|
|
1193
|
+
$ok = $true
|
|
1194
|
+
$data = [ordered]@{
|
|
1195
|
+
version = [string]$app.Version
|
|
1196
|
+
ide_window = ("0x{0:X}" -f ([int64]$ideW))
|
|
1197
|
+
is_project_open = $isOpen
|
|
1198
|
+
active_project = $activeName
|
|
1199
|
+
in_stage = [bool]$inStage
|
|
1200
|
+
project_mode = $(if ($directIdentity) { 'direct' } elseif ($inStage) { 'stage' } else { 'unverified' })
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
'active_view' {
|
|
1205
|
+
$app = Connect-App
|
|
1206
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1207
|
+
$view = $null; $viewError = $null
|
|
1208
|
+
try { $view = [string]$app.ActiveProject.GetLogicalNameOfActiveView() }
|
|
1209
|
+
catch { $viewError = $_.Exception.Message }
|
|
1210
|
+
if (-not $view -and -not $viewError) { $viewError = 'No active worksheet reported by the IDE' }
|
|
1211
|
+
$ok = $true
|
|
1212
|
+
$ui=Get-IdeUiStatus
|
|
1213
|
+
$data = [ordered]@{ active_project = [string]$app.ActiveProject.FullName; logical_name = $view; error = $viewError;
|
|
1214
|
+
ui_responsive=$ui.responding; editor_caption=$ui.caption; keyboard_focus_verified=$false }
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
'open_worksheet' {
|
|
1218
|
+
if($null -ne $req.inspect_only -and $req.inspect_only -isnot [bool]){throw 'REFUSED: inspect_only must be a boolean'}
|
|
1219
|
+
$app=Connect-App
|
|
1220
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1221
|
+
if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine [IO.Path]::GetFullPath([string]$req.project+'.mwt')) {throw 'REFUSED: wrong native project'}
|
|
1222
|
+
$view=[string]$req.logical_name
|
|
1223
|
+
$urn=[string]$req.urn
|
|
1224
|
+
$documentView=[string]$req.document_logical_name
|
|
1225
|
+
$pouName=$null;$sheetName=$null
|
|
1226
|
+
if($documentView -cmatch '^/Pous/([^/.\\]+)/([^/.\\]+)$') {
|
|
1227
|
+
$pouName=$Matches[1];$sheetName=$Matches[2]
|
|
1228
|
+
$expectedCaption='{0}:{1}' -f $sheetName,$pouName
|
|
1229
|
+
[void](Get-VariableSheet $app $pouName)
|
|
1230
|
+
if($urn -cne ('@POUS.'+$pouName+'.'+$sheetName)){throw 'REFUSED: inconsistent worksheet identity'}
|
|
1231
|
+
$expectedView=if($req.kind -eq 'code'){'/Pous/'+$pouName}else{$documentView}
|
|
1232
|
+
if($view -cne $expectedView){throw 'REFUSED: inconsistent active-view identity'}
|
|
1233
|
+
} elseif($documentView -cmatch '^/Hardware/([^/.\\]+)/([^/.\\]+)/([^/.\\]+)$') {
|
|
1234
|
+
$config=$Matches[1];$resource=$Matches[2];$sheet=$Matches[3]
|
|
1235
|
+
$expectedCaption='{0}:{1}.{2}' -f $sheet,$config,$resource
|
|
1236
|
+
if($req.kind -ne 'variables' -or $urn -cne ('@HW.'+$config+'.'+$resource+'.'+$sheet)){throw 'REFUSED: inconsistent globals identity'}
|
|
1237
|
+
if($view -cne $documentView){throw 'REFUSED: inconsistent active-view identity'}
|
|
1238
|
+
[void]$app.ActiveProject.GetObjectByLogicalName(('Hardware/'+$config+'/'+$resource),10)
|
|
1239
|
+
} else {throw 'REFUSED: unsupported worksheet identity'}
|
|
1240
|
+
$uiBefore=Get-IdeUiStatus
|
|
1241
|
+
if($uiBefore.responding -ne $true){throw 'REFUSED: IDE frame responsiveness is false or unknown; inspect the live window before navigation'}
|
|
1242
|
+
$beforeModified=$app.ActiveProject.IsModified
|
|
1243
|
+
$inspectOnly=($req.inspect_only -eq $true)
|
|
1244
|
+
if(-not $inspectOnly){
|
|
1245
|
+
$userData=[object[]]@()
|
|
1246
|
+
[void]$app.OpenDocument($urn,$true,[ref]$userData)
|
|
1247
|
+
}
|
|
1248
|
+
# COM can report the new view before its visible editor settles.
|
|
1249
|
+
# Observe two responsive, matching frames without repeating OpenDocument.
|
|
1250
|
+
$deadline=(Get-Date).AddSeconds(3);$readySamples=0;$instanceView=$null
|
|
1251
|
+
do {
|
|
1252
|
+
$actual=[string]$app.ActiveProject.GetLogicalNameOfActiveView()
|
|
1253
|
+
$afterModified=$app.ActiveProject.IsModified
|
|
1254
|
+
$acceptedView=$view;$acceptedCaption=$expectedCaption
|
|
1255
|
+
if($pouName -and $actual -cne $view){
|
|
1256
|
+
if(-not $instanceView -or $instanceView.logical_name -cne $actual){
|
|
1257
|
+
$instanceView=Get-WorksheetInstanceView $app $actual $pouName $sheetName
|
|
1258
|
+
}
|
|
1259
|
+
if($instanceView){$acceptedView=$instanceView.logical_name;$acceptedCaption=$instanceView.expected_caption}
|
|
1260
|
+
}
|
|
1261
|
+
$editorState=Get-WorksheetEditorState $acceptedView $actual $beforeModified $afterModified (Get-IdeUiStatus) $acceptedCaption
|
|
1262
|
+
if($editorState.editor_window_ready){$readySamples++}else{$readySamples=0}
|
|
1263
|
+
if($readySamples -ge 2){break}
|
|
1264
|
+
Start-Sleep -Milliseconds 250
|
|
1265
|
+
}while((Get-Date) -lt $deadline)
|
|
1266
|
+
$ok=$true
|
|
1267
|
+
$data=[ordered]@{accepted=($readySamples -ge 2);
|
|
1268
|
+
action_performed=(-not $inspectOnly);method=$(if($inspectOnly){'native_com_inspection'}else{'native_com_urn'});active_project=[string]$app.ActiveProject.FullName;
|
|
1269
|
+
requested_logical_name=$view;logical_name=$actual;urn=$urn;is_modified=$afterModified;
|
|
1270
|
+
modified_state_unchanged=($beforeModified -is [bool] -and $afterModified -is [bool] -and $beforeModified -eq $afterModified);
|
|
1271
|
+
editor_settled=($readySamples -ge 2);ready_observations=$readySamples;
|
|
1272
|
+
instance_view_verified=([bool]$instanceView -and $instanceView.logical_name -ceq $actual);
|
|
1273
|
+
next_step=$(if($readySamples -ge 2){'Observe current editor focus through the connected computer tool before keyboard input. Native declaration/code APIs do not need editor focus.'}else{'STOP: the current visible editor does not match or has not settled. Use inspect_only:true to check readiness without repeating navigation; do not send keys automatically.'})}
|
|
1274
|
+
foreach($key in $editorState.Keys){$data[$key]=$editorState[$key]}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
'open' {
|
|
1278
|
+
# The cached Application can describe a project that no longer
|
|
1279
|
+
# exists once this has run; drop it so the next verb reconnects.
|
|
1280
|
+
$script:App = $null
|
|
1281
|
+
$path = [string]$req.path
|
|
1282
|
+
if ([string]::IsNullOrWhiteSpace($path)) { throw 'open requires "path"' }
|
|
1283
|
+
# Fresh dialog budget for this call, so a long session of opens cannot
|
|
1284
|
+
# inherit a spent budget and silently stop answering prompts.
|
|
1285
|
+
$script:dialogSpent = 0.0
|
|
1286
|
+
$swOpen = [Diagnostics.Stopwatch]::StartNew()
|
|
1287
|
+
$full = [IO.Path]::GetFullPath($path)
|
|
1288
|
+
$stageFull = [IO.Path]::GetFullPath($StageRoot)
|
|
1289
|
+
if (-not (Test-InsideStage $full)) {
|
|
1290
|
+
throw "REFUSED: '$full' is outside the staging root '$stageFull'. Only staged copies may be opened."
|
|
1291
|
+
}
|
|
1292
|
+
if (-not (Test-Path $full)) { throw "project not found: $full" }
|
|
1293
|
+
Assert-ProvenCopy $full
|
|
1294
|
+
$digest = Get-MwSha256 $full
|
|
1295
|
+
if (-not $req.wrapper_sha256 -or $digest -ne [string]$req.wrapper_sha256) {
|
|
1296
|
+
throw 'REFUSED: wrapper binding must be verified by mw_ide_open before opening.'
|
|
1297
|
+
}
|
|
1298
|
+
$app = Connect-App
|
|
1299
|
+
# A project already in the window may belong to the user. Never replace it
|
|
1300
|
+
# without approval tied to its exact path, even when it is unmodified.
|
|
1301
|
+
$dismissed = $null
|
|
1302
|
+
$prior = Get-OpenProjectPath $app
|
|
1303
|
+
if ($prior -and -not (Test-SameProject $prior $full)) {
|
|
1304
|
+
Assert-CloseConsent $req $prior 'opening another project'
|
|
1305
|
+
try {
|
|
1306
|
+
# Consent covers saving and closing the named current project.
|
|
1307
|
+
$app.ActiveProject.Save()
|
|
1308
|
+
$app.ActiveProject.Close($false)
|
|
1309
|
+
$dismissed = $prior
|
|
1310
|
+
Log "closed other project before open: $prior"
|
|
1311
|
+
} catch {
|
|
1312
|
+
throw "REFUSED: the IDE has '$prior' open and it could not be closed ($($_.Exception.Message)). The staged project '$full' was not opened."
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
# From Ade.tlb: OpenProject(Name, ConfirmConvert). ConfirmConvert=$false
|
|
1316
|
+
# suppresses the conversion prompt, which would otherwise block an
|
|
1317
|
+
# unattended flow on a modal dialog nobody is there to answer.
|
|
1318
|
+
#
|
|
1319
|
+
# RETRY on "Internal error": a freshly launched IDE has not finished
|
|
1320
|
+
# initialising its project services, and OpenProject fails with
|
|
1321
|
+
# "Internal error in 'OpenProject'" until it has. Reporting that as a
|
|
1322
|
+
# failure made start->open look broken when it only needed time.
|
|
1323
|
+
$opened = $false; $lastErr = $null
|
|
1324
|
+
# A PARALLEL WATCHER, because this thread is about to be blocked.
|
|
1325
|
+
#
|
|
1326
|
+
# OpenProject is a synchronous out-of-process COM call. When
|
|
1327
|
+
# MotionWorks raises a prompt DURING the load ("this project was not
|
|
1328
|
+
# closed cleanly, load anyway?"), the server enters a modal loop and
|
|
1329
|
+
# the call does not return until that prompt is answered. So the retry
|
|
1330
|
+
# loop below cannot help - no catch ever fires, no timeout applies.
|
|
1331
|
+
# Measured: the request was logged and then sat for minutes unanswered.
|
|
1332
|
+
# Only another process can press that button, so start one.
|
|
1333
|
+
$watcher = $null
|
|
1334
|
+
try {
|
|
1335
|
+
# Never let watchers accumulate. A watcher is started per open and
|
|
1336
|
+
# killed when the load finishes, but any path that throws first
|
|
1337
|
+
# leaves one behind - and FOUR were observed running at once after
|
|
1338
|
+
# a run of failed opens. Overlapping watchers all click whatever
|
|
1339
|
+
# dialog they find, which is exactly the kind of interference that
|
|
1340
|
+
# makes an open fail for no visible reason. Clear them first.
|
|
1341
|
+
try {
|
|
1342
|
+
Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue |
|
|
1343
|
+
Where-Object { $_.CommandLine -and $_.CommandLine -match 'watch_dialogs' } |
|
|
1344
|
+
ForEach-Object {
|
|
1345
|
+
Log "clearing stale dialog watcher pid=$($_.ProcessId)"
|
|
1346
|
+
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
|
1347
|
+
}
|
|
1348
|
+
} catch { }
|
|
1349
|
+
|
|
1350
|
+
$watchCmd = Join-Path $BridgeDir 'watch_dialogs.cmd'
|
|
1351
|
+
if (Test-Path $watchCmd) {
|
|
1352
|
+
$watcher = Start-Process -FilePath 'cmd.exe' `
|
|
1353
|
+
-ArgumentList '/c', "`"$watchCmd`" 200" `
|
|
1354
|
+
-WindowStyle Hidden -PassThru
|
|
1355
|
+
Log "dialog watcher started pid=$($watcher.Id)"
|
|
1356
|
+
}
|
|
1357
|
+
} catch { Log "dialog watcher failed to start: $($_.Exception.Message)" }
|
|
1358
|
+
|
|
1359
|
+
# 60s, not 90s: the retry window, the post-load dialog answers and the
|
|
1360
|
+
# verification loop all share the caller's timeout, and the client
|
|
1361
|
+
# gives up at 150s. Sizing each part as if it had the budget alone is
|
|
1362
|
+
# what produced "timed out waiting for the bridge".
|
|
1363
|
+
#
|
|
1364
|
+
# The attempt cap matters as much as the deadline. A blocked
|
|
1365
|
+
# OpenProject takes ~30s to fail, and the deadline is only consulted
|
|
1366
|
+
# BETWEEN attempts, so an unbounded loop overshoots wildly: measured
|
|
1367
|
+
# a "90s" loop running 443 seconds and a "60s" loop running 233.
|
|
1368
|
+
# Three attempts is enough to cover IDE warm-up and still return
|
|
1369
|
+
# inside the caller's budget.
|
|
1370
|
+
$deadline = (Get-Date).AddSeconds(60)
|
|
1371
|
+
$pass = 0
|
|
1372
|
+
while ((Get-Date) -lt $deadline -and $pass -lt 3) {
|
|
1373
|
+
$pass++
|
|
1374
|
+
# Answer any prompt the IDE is waiting on BEFORE calling again.
|
|
1375
|
+
# While a modal dialog is up the frame is disabled and
|
|
1376
|
+
# OpenProject cannot succeed, so retrying blindly just burns
|
|
1377
|
+
# the deadline - which is what made start->open look broken.
|
|
1378
|
+
# Checked on a stride, because inspection is not free while the
|
|
1379
|
+
# IDE's UI thread is busy.
|
|
1380
|
+
if ($pass % 3 -eq 1) { [void](Resolve-KnownDialogs) }
|
|
1381
|
+
try { $app.OpenProject($full, $false); $opened = $true; break }
|
|
1382
|
+
catch {
|
|
1383
|
+
$lastErr = $_.Exception.Message
|
|
1384
|
+
if ($lastErr -notmatch 'Internal error') { throw }
|
|
1385
|
+
Start-Sleep -Seconds 2
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
Log "open attempt(s)=$pass opened=$opened elapsed=$([Math]::Round($swOpen.Elapsed.TotalSeconds,1))s requested=$full"
|
|
1389
|
+
if (-not $opened) {
|
|
1390
|
+
if ($watcher) { try { Stop-Process -Id $watcher.Id -Force -ErrorAction SilentlyContinue } catch { } }
|
|
1391
|
+
throw "OpenProject never succeeded within 60s; last error: $lastErr"
|
|
1392
|
+
}
|
|
1393
|
+
# Verify what actually happened rather than trusting the call.
|
|
1394
|
+
# MotionWorks asks "defragment?" / "load anyway?" DURING and AFTER
|
|
1395
|
+
# the load, so keep answering while we wait for services to appear.
|
|
1396
|
+
#
|
|
1397
|
+
# Dialogs are inspected every 4th pass, not every pass: while the IDE
|
|
1398
|
+
# is loading, its UI thread is busy and each cross-process text read
|
|
1399
|
+
# waits out its timeout, so inspecting on all 40 passes cost more than
|
|
1400
|
+
# the whole budget and the open call never returned.
|
|
1401
|
+
$verified = $null; $activeName = $null
|
|
1402
|
+
for ($i = 0; $i -lt 40; $i++) {
|
|
1403
|
+
Start-Sleep -Milliseconds 500
|
|
1404
|
+
if ($i % 4 -eq 0) { [void](Resolve-KnownDialogs) }
|
|
1405
|
+
try { $verified = $app.IsProjectOpen(); if ($verified) { $activeName = $app.ActiveProject.FullName; break } } catch { }
|
|
1406
|
+
}
|
|
1407
|
+
# The load is over - either way the watcher has nothing left to do, and
|
|
1408
|
+
# leaving it running would let it answer a prompt raised by some later,
|
|
1409
|
+
# unrelated step.
|
|
1410
|
+
if ($watcher) {
|
|
1411
|
+
try { Stop-Process -Id $watcher.Id -Force -ErrorAction SilentlyContinue; Log "dialog watcher stopped" } catch { }
|
|
1412
|
+
}
|
|
1413
|
+
$matches = Test-SameProject $activeName $full
|
|
1414
|
+
$inStage = Test-InsideStage $activeName
|
|
1415
|
+
Log "open loaded active=$activeName matches=$matches inStage=$inStage"
|
|
1416
|
+
if ($verified -and (-not $matches -or -not $inStage)) {
|
|
1417
|
+
throw "REFUSED: asked to open '$full' but the IDE loaded '$activeName'. It was left open; ask the user before saving or closing it."
|
|
1418
|
+
}
|
|
1419
|
+
$ok = [bool]$verified -and $matches -and $inStage
|
|
1420
|
+
$data = [ordered]@{
|
|
1421
|
+
requested = $full
|
|
1422
|
+
is_project_open = [bool]$verified
|
|
1423
|
+
active_project = $activeName
|
|
1424
|
+
matches_request = [bool]$matches
|
|
1425
|
+
in_stage = [bool]$inStage
|
|
1426
|
+
dismissed_project = $dismissed
|
|
1427
|
+
}
|
|
1428
|
+
if (-not $verified) {
|
|
1429
|
+
$blocking = @(Get-IdeDialogs)
|
|
1430
|
+
if ($blocking.Count -gt 0) {
|
|
1431
|
+
throw "OpenProject did not complete: the IDE is blocked by an unanswered dialog - '$($blocking[0].message)' (buttons: $(($blocking[0].buttons | ForEach-Object { $_.label }) -join ', ')). The IDE is NOT closed. Answer it, then re-check."
|
|
1432
|
+
}
|
|
1433
|
+
$err = 'OpenProject was called but IsProjectOpen never became true'
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
'pous' {
|
|
1438
|
+
$app = Connect-App
|
|
1439
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1440
|
+
$col = $app.ActiveProject.Pous
|
|
1441
|
+
$items = @()
|
|
1442
|
+
for ($i = 1; $i -le $col.Count; $i++) {
|
|
1443
|
+
$p = $col.Item($i)
|
|
1444
|
+
$items += [ordered]@{ index = $i; name = [string]$p.Name; language = [string]$p.PouLanguage }
|
|
1445
|
+
}
|
|
1446
|
+
$ok = $true
|
|
1447
|
+
$data = [ordered]@{ count = $col.Count; pous = $items }
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
# Save the open project through the IDE.
|
|
1451
|
+
#
|
|
1452
|
+
# Worth having for its own sake, but added to test a specific suspicion: the
|
|
1453
|
+
# plugin edits the project ON DISK while the IDE is closed, so when the IDE
|
|
1454
|
+
# reopens it may hold a model that disagrees with the files. A compile then
|
|
1455
|
+
# runs against an inconsistent view and can report errors that are not in the
|
|
1456
|
+
# project - which would explain why adding a program to a task turns a clean
|
|
1457
|
+
# build into 125 unresolved-external errors while the files are provably fine.
|
|
1458
|
+
'task_model' {
|
|
1459
|
+
$app = Connect-App
|
|
1460
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1461
|
+
$tasks = [ordered]@{}
|
|
1462
|
+
$taskNames = @()
|
|
1463
|
+
# The resource lookup is the one call here that can fail outright, and it used to
|
|
1464
|
+
# take the whole verb down with it: an object model that does not answer for this
|
|
1465
|
+
# path returned a response the caller could not read, so `mw_code_task_model`
|
|
1466
|
+
# failed with a type error rather than reporting anything about the tasks. The
|
|
1467
|
+
# Fail explicitly instead of inventing a task inventory.
|
|
1468
|
+
try {
|
|
1469
|
+
$resource = $app.ActiveProject.GetObjectByLogicalName(
|
|
1470
|
+
'Hardware/Configuration/Resource', 10)
|
|
1471
|
+
} catch {
|
|
1472
|
+
throw "task_model: resource lookup failed ($($_.Exception.Message))"
|
|
1473
|
+
}
|
|
1474
|
+
if ($resource -ne $null) {
|
|
1475
|
+
try {
|
|
1476
|
+
$coll = $resource.Tasks
|
|
1477
|
+
for ($i = 1; $i -le $coll.Count; $i++) { $taskNames += $coll.Item($i).Name }
|
|
1478
|
+
} catch {
|
|
1479
|
+
throw "task_model: reading the task collection failed ($($_.Exception.Message))"
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
foreach ($name in $taskNames) {
|
|
1483
|
+
$path = "Hardware/Configuration/Resource/Tasks/$name"
|
|
1484
|
+
try {
|
|
1485
|
+
$task = $app.ActiveProject.GetObjectByLogicalName($path, 11)
|
|
1486
|
+
if ($task -eq $null) { continue }
|
|
1487
|
+
$pi = $task.ProgramInstances
|
|
1488
|
+
$instances = @()
|
|
1489
|
+
for ($j = 1; $j -le $pi.Count; $j++) {
|
|
1490
|
+
$inst = $pi.Item($j)
|
|
1491
|
+
$instances += [ordered]@{
|
|
1492
|
+
name = [string]$inst.Name
|
|
1493
|
+
type = [string]$inst.Type
|
|
1494
|
+
logical_name = [string]$inst.LogicalName
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
$tasks[$name] = [ordered]@{
|
|
1498
|
+
name = [string]$task.Name
|
|
1499
|
+
cycle = [string]$task.Type
|
|
1500
|
+
logical_name = [string]$task.LogicalName
|
|
1501
|
+
instances = $instances
|
|
1502
|
+
}
|
|
1503
|
+
} catch {
|
|
1504
|
+
$tasks[$name] = [ordered]@{ error = $_.Exception.Message }
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
$ok = $true
|
|
1508
|
+
$data = [ordered]@{ tasks = $tasks; source = 'com' }
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
'create_task' {
|
|
1512
|
+
$app = Connect-App
|
|
1513
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1514
|
+
$name = [string]$req.name
|
|
1515
|
+
$kind = [string]$req.kind
|
|
1516
|
+
if ([string]::IsNullOrWhiteSpace($name)) { throw 'create_task requires "name"' }
|
|
1517
|
+
if ($name.Length -gt 7) { throw "MotionWorks IEC task names are limited to 7 characters; '$name' has $($name.Length)." }
|
|
1518
|
+
if ([string]::IsNullOrWhiteSpace($kind)) { $kind = 'CYCLIC' }
|
|
1519
|
+
|
|
1520
|
+
$resource = $app.ActiveProject.GetObjectByLogicalName(
|
|
1521
|
+
'Hardware/Configuration/Resource', 10)
|
|
1522
|
+
if ($resource -eq $null) { throw 'no resource found in the active project' }
|
|
1523
|
+
$tasks = $resource.Tasks
|
|
1524
|
+
$before = $tasks.Count
|
|
1525
|
+
|
|
1526
|
+
foreach ($i in 1..$before) {
|
|
1527
|
+
if ([string]$tasks.Item($i).Name -eq $name) {
|
|
1528
|
+
throw "a task named '$name' already exists"
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
$tasks.GetType().InvokeMember('Create', 'InvokeMethod', $null, $tasks,
|
|
1533
|
+
@([string]$name, [string]$kind)) | Out-Null
|
|
1534
|
+
$after = $tasks.Count
|
|
1535
|
+
|
|
1536
|
+
$app.ActiveProject.Save()
|
|
1537
|
+
Start-Sleep -Milliseconds 400
|
|
1538
|
+
|
|
1539
|
+
$names = @()
|
|
1540
|
+
for ($i = 1; $i -le $tasks.Count; $i++) { $names += [string]$tasks.Item($i).Name }
|
|
1541
|
+
$made = $null
|
|
1542
|
+
for ($i = 1; $i -le $tasks.Count; $i++) {
|
|
1543
|
+
if ([string]$tasks.Item($i).Name -eq $name) { $made = $tasks.Item($i) }
|
|
1544
|
+
}
|
|
1545
|
+
$ok = $true
|
|
1546
|
+
$data = [ordered]@{
|
|
1547
|
+
created = $true
|
|
1548
|
+
name = $name
|
|
1549
|
+
cycle = $kind
|
|
1550
|
+
before = $before
|
|
1551
|
+
after = $after
|
|
1552
|
+
tasks = $names
|
|
1553
|
+
logical_name = if ($made) { [string]$made.LogicalName } else { $null }
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
'delete_task' {
|
|
1558
|
+
$app = Connect-App
|
|
1559
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1560
|
+
$name = [string]$req.name
|
|
1561
|
+
if ([string]::IsNullOrWhiteSpace($name)) { throw 'delete_task requires "name"' }
|
|
1562
|
+
|
|
1563
|
+
$resource = $app.ActiveProject.GetObjectByLogicalName(
|
|
1564
|
+
'Hardware/Configuration/Resource', 10)
|
|
1565
|
+
if ($resource -eq $null) { throw 'no resource found in the active project' }
|
|
1566
|
+
$tasks = $resource.Tasks
|
|
1567
|
+
$before = $tasks.Count
|
|
1568
|
+
|
|
1569
|
+
$hit = -1
|
|
1570
|
+
for ($i = 1; $i -le $tasks.Count; $i++) {
|
|
1571
|
+
if ([string]$tasks.Item($i).Name -eq $name) { $hit = $i; break }
|
|
1572
|
+
}
|
|
1573
|
+
if ($hit -lt 0) {
|
|
1574
|
+
$names = @()
|
|
1575
|
+
for ($i = 1; $i -le $tasks.Count; $i++) { $names += [string]$tasks.Item($i).Name }
|
|
1576
|
+
throw "no task named '$name'. Tasks: $($names -join ', ')"
|
|
1577
|
+
}
|
|
1578
|
+
$task = $tasks.Item($hit)
|
|
1579
|
+
$kids = $task.ProgramInstances.Count
|
|
1580
|
+
if ($kids -gt 0) {
|
|
1581
|
+
throw "task '$name' still has $kids program instance(s) assigned; unassign them first"
|
|
1582
|
+
}
|
|
1583
|
+
$task.Delete()
|
|
1584
|
+
$after = $tasks.Count
|
|
1585
|
+
|
|
1586
|
+
$app.ActiveProject.Save()
|
|
1587
|
+
Start-Sleep -Milliseconds 400
|
|
1588
|
+
|
|
1589
|
+
$names = @()
|
|
1590
|
+
for ($i = 1; $i -le $tasks.Count; $i++) { $names += [string]$tasks.Item($i).Name }
|
|
1591
|
+
$ok = $true
|
|
1592
|
+
$data = [ordered]@{
|
|
1593
|
+
deleted = $true
|
|
1594
|
+
name = $name
|
|
1595
|
+
before = $before
|
|
1596
|
+
after = $after
|
|
1597
|
+
tasks = $names
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
'assign_pou' {
|
|
1602
|
+
$app = Connect-App
|
|
1603
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1604
|
+
$taskName = [string]$req.task
|
|
1605
|
+
$pouName = [string]$req.pou
|
|
1606
|
+
$instName = $req.instance
|
|
1607
|
+
if ([string]::IsNullOrWhiteSpace($instName)) { $instName = $pouName }
|
|
1608
|
+
$progType = $req.type
|
|
1609
|
+
if ([string]::IsNullOrWhiteSpace($progType)) { $progType = $pouName }
|
|
1610
|
+
if ([string]::IsNullOrWhiteSpace($taskName)) { throw 'assign requires "task"' }
|
|
1611
|
+
if ([string]::IsNullOrWhiteSpace($pouName)) { throw 'assign requires "pou"' }
|
|
1612
|
+
|
|
1613
|
+
$path = "Hardware/Configuration/Resource/Tasks/$taskName"
|
|
1614
|
+
$task = $app.ActiveProject.GetObjectByLogicalName($path, 11)
|
|
1615
|
+
if ($task -eq $null) { throw "no task named '$taskName' (looked for $path)" }
|
|
1616
|
+
|
|
1617
|
+
$pi = $task.ProgramInstances
|
|
1618
|
+
$before = $pi.Count
|
|
1619
|
+
$pi.GetType().InvokeMember('Create', 'InvokeMethod', $null, $pi,
|
|
1620
|
+
@([string]$instName, [string]$progType)) | Out-Null
|
|
1621
|
+
$after = $pi.Count
|
|
1622
|
+
|
|
1623
|
+
$app.ActiveProject.Save()
|
|
1624
|
+
Start-Sleep -Milliseconds 400
|
|
1625
|
+
|
|
1626
|
+
$instances = @()
|
|
1627
|
+
for ($j = 1; $j -le $pi.Count; $j++) { $instances += [string]$pi.Item($j).Name }
|
|
1628
|
+
$ok = $true
|
|
1629
|
+
$data = [ordered]@{
|
|
1630
|
+
assigned = $true
|
|
1631
|
+
task = $taskName
|
|
1632
|
+
pou = $pouName
|
|
1633
|
+
instance = [string]$instName
|
|
1634
|
+
type = [string]$progType
|
|
1635
|
+
before = $before
|
|
1636
|
+
after = $after
|
|
1637
|
+
instances = $instances
|
|
1638
|
+
logical_name = [string]$pi.Item($after).LogicalName
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
'unassign_pou' {
|
|
1643
|
+
$app = Connect-App
|
|
1644
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1645
|
+
$taskName = [string]$req.task
|
|
1646
|
+
$pouName = [string]$req.pou
|
|
1647
|
+
if ([string]::IsNullOrWhiteSpace($taskName)) { throw 'unassign requires "task"' }
|
|
1648
|
+
if ([string]::IsNullOrWhiteSpace($pouName)) { throw 'unassign requires "pou"' }
|
|
1649
|
+
|
|
1650
|
+
$path = "Hardware/Configuration/Resource/Tasks/$taskName"
|
|
1651
|
+
$task = $app.ActiveProject.GetObjectByLogicalName($path, 11)
|
|
1652
|
+
if ($task -eq $null) { throw "no task named '$taskName' (looked for $path)" }
|
|
1653
|
+
|
|
1654
|
+
$pi = $task.ProgramInstances
|
|
1655
|
+
$before = $pi.Count
|
|
1656
|
+
$hit = -1
|
|
1657
|
+
for ($j = 1; $j -le $pi.Count; $j++) {
|
|
1658
|
+
if ([string]$pi.Item($j).Name -eq $pouName) { $hit = $j; break }
|
|
1659
|
+
}
|
|
1660
|
+
if ($hit -lt 0) {
|
|
1661
|
+
$names = @()
|
|
1662
|
+
for ($j = 1; $j -le $pi.Count; $j++) { $names += [string]$pi.Item($j).Name }
|
|
1663
|
+
throw "'$pouName' is not assigned to task '$taskName'. Assigned: $($names -join ', ')"
|
|
1664
|
+
}
|
|
1665
|
+
$pi.Item($hit).Delete()
|
|
1666
|
+
$after = $pi.Count
|
|
1667
|
+
|
|
1668
|
+
$app.ActiveProject.Save()
|
|
1669
|
+
Start-Sleep -Milliseconds 400
|
|
1670
|
+
|
|
1671
|
+
$instances = @()
|
|
1672
|
+
for ($j = 1; $j -le $pi.Count; $j++) { $instances += [string]$pi.Item($j).Name }
|
|
1673
|
+
$ok = $true
|
|
1674
|
+
$data = [ordered]@{
|
|
1675
|
+
unassigned = $true
|
|
1676
|
+
task = $taskName
|
|
1677
|
+
pou = $pouName
|
|
1678
|
+
before = $before
|
|
1679
|
+
after = $after
|
|
1680
|
+
instances = $instances
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
'save' {
|
|
1685
|
+
$app = Connect-App
|
|
1686
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1687
|
+
$swSave = [Diagnostics.Stopwatch]::StartNew()
|
|
1688
|
+
$app.ActiveProject.Save()
|
|
1689
|
+
$swSave.Stop()
|
|
1690
|
+
Start-Sleep -Milliseconds 500
|
|
1691
|
+
$ok = $true
|
|
1692
|
+
$data = [ordered]@{
|
|
1693
|
+
saved = $true
|
|
1694
|
+
elapsed_s = [math]::Round($swSave.Elapsed.TotalSeconds, 2)
|
|
1695
|
+
is_modified = [bool]$app.ActiveProject.IsModified
|
|
1696
|
+
is_compiled = [bool]$app.ActiveProject.IsCompiled
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
'compile_state' {
|
|
1701
|
+
$app = Connect-App
|
|
1702
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1703
|
+
$ok = $true
|
|
1704
|
+
$data = [ordered]@{
|
|
1705
|
+
is_compiled = [bool]$app.ActiveProject.IsCompiled
|
|
1706
|
+
is_modified = [bool]$app.ActiveProject.IsModified
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
# Compile types, read from Ade.tlb. The earlier 1=Build / 2=Rebuild guess
|
|
1711
|
+
# was WRONG:
|
|
1712
|
+
# 1 adeCtMake 2 adeCtBuild 3 adeCtPatch 4 adeCtWorksheet 5 adeCtDataTypes
|
|
1713
|
+
# There is no Rebuild compile type: Rebuild is a COMMAND
|
|
1714
|
+
# (adeCmdBuildRebuildProject = 36570), and ExecuteCommand is a stub in
|
|
1715
|
+
# this build ("The method or operation is not implemented"), so Rebuild
|
|
1716
|
+
# is not reachable programmatically at all.
|
|
1717
|
+
'make' { $mode = 1 }
|
|
1718
|
+
'build' { $mode = 2 }
|
|
1719
|
+
'rebuild' { $mode = $null }
|
|
1720
|
+
'patch' { $mode = 3 }
|
|
1721
|
+
'worksheet' { $mode = 4 }
|
|
1722
|
+
'datatypes' { $mode = 5 }
|
|
1723
|
+
|
|
1724
|
+
# Resolve a menu command NAME to its numeric id without running it.
|
|
1725
|
+
# This is the safe half of ExecuteCommand and the way to discover
|
|
1726
|
+
# which commands this build of the IDE actually exposes.
|
|
1727
|
+
'command_id' {
|
|
1728
|
+
$name = [string]$req.name
|
|
1729
|
+
if ([string]::IsNullOrWhiteSpace($name)) { throw 'command_id requires "name"' }
|
|
1730
|
+
$app = Connect-App
|
|
1731
|
+
$cid = $app.GetIdOfCommand($name)
|
|
1732
|
+
$ok = $true
|
|
1733
|
+
$data = [ordered]@{ name = $name; command_id = $cid; known = ($cid -ne 0) }
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
# Drive an IDE menu command by name - the programmatic equivalent of
|
|
1737
|
+
# clicking the menu, without synthesising any input.
|
|
1738
|
+
'command' {
|
|
1739
|
+
$name = [string]$req.name
|
|
1740
|
+
if ([string]::IsNullOrWhiteSpace($name)) { throw 'command requires "name"' }
|
|
1741
|
+
if ($name -ne 'adeCmdBuildRebuildProject') { throw 'Only the offline Rebuild command is allowed; use the dedicated rebuild tool' }
|
|
1742
|
+
$app = Connect-App
|
|
1743
|
+
[void](Assert-StagedOpen $app 'command')
|
|
1744
|
+
$cid = $app.GetIdOfCommand($name)
|
|
1745
|
+
if ($cid -eq 0) { throw "unknown command '$name' (GetIdOfCommand returned 0)" }
|
|
1746
|
+
$app.ExecuteCommand($cid)
|
|
1747
|
+
$ok = $true
|
|
1748
|
+
$data = [ordered]@{ name = $name; command_id = $cid; executed = $true }
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
'feature_state' {
|
|
1752
|
+
$name = [string]$req.name
|
|
1753
|
+
if ([string]::IsNullOrWhiteSpace($name)) { throw 'feature_state requires "name"' }
|
|
1754
|
+
$app = Connect-App
|
|
1755
|
+
$ok = $true
|
|
1756
|
+
$data = [ordered]@{ name = $name; state = $app.GetFeatureState($name) }
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
# Live variable model. This is the part that makes editing THROUGH the
|
|
1760
|
+
# IDE possible: reading variables from the running project avoids
|
|
1761
|
+
# hand-maintaining .VGR/.VB/PROJECT.TRE consistency entirely.
|
|
1762
|
+
# Optional "pou" narrows to one POU; omitted reads all of them.
|
|
1763
|
+
'variable_snapshot' {
|
|
1764
|
+
$app=Connect-App
|
|
1765
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1766
|
+
$requireSaved=if($null -eq $req.require_saved){$false}else{$req.require_saved}
|
|
1767
|
+
$data=Get-VariableSnapshot $app ([string]$req.pou) $requireSaved
|
|
1768
|
+
$ok=$true
|
|
1769
|
+
}
|
|
1770
|
+
'library_snapshot' {
|
|
1771
|
+
$app=Connect-App
|
|
1772
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1773
|
+
$modified=[bool]$app.ActiveProject.IsModified
|
|
1774
|
+
$libraries=@()
|
|
1775
|
+
for($i=1;$i -le $app.ActiveProject.Libraries.Count;$i++){
|
|
1776
|
+
$library=$app.ActiveProject.Libraries.Item($i)
|
|
1777
|
+
$libraries+=,[ordered]@{name=[string]$library.Name;full_name=[string]$library.FullName;logical_name=[string]$library.LogicalName}
|
|
1778
|
+
}
|
|
1779
|
+
if([bool]$app.ActiveProject.IsModified -ne $modified){throw 'Native library inspection changed modified state'}
|
|
1780
|
+
$data=[ordered]@{libraries=$libraries;is_modified=$modified;action_performed=$false}
|
|
1781
|
+
$ok=$true
|
|
1782
|
+
}
|
|
1783
|
+
'structure_snapshot' {
|
|
1784
|
+
$app=Connect-App
|
|
1785
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1786
|
+
$data=Get-NativeStructure $app
|
|
1787
|
+
$ok=$true
|
|
1788
|
+
}
|
|
1789
|
+
'pou_package_snapshot' {
|
|
1790
|
+
$app=Connect-App
|
|
1791
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1792
|
+
if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: package snapshot requires known saved state'}
|
|
1793
|
+
$data=Get-PouPackageState $app
|
|
1794
|
+
if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: package inspection changed modified state'}
|
|
1795
|
+
$ok=$true
|
|
1796
|
+
}
|
|
1797
|
+
'pou_convert' {
|
|
1798
|
+
$app=Connect-App
|
|
1799
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1800
|
+
$project=[IO.Path]::GetFullPath([string]$req.project)
|
|
1801
|
+
if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine ($project+'.mwt') -or $app.ActiveProject.IsModified -ne $false -or $app.ActiveProject.IsCompiled -ne $true){throw 'REFUSED: wrong, unsaved or uncompiled project'}
|
|
1802
|
+
$name=[string]$req.pou;$language=[string]$req.language
|
|
1803
|
+
if($req.conversion_reviewed -ne $true -or $name -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$' -or $language -notin @('FBD','LD')){throw 'REFUSED: reviewed conversion identity/language required'}
|
|
1804
|
+
$now=Get-PouPackageState $app
|
|
1805
|
+
if(($now | ConvertTo-Json -Depth 30 -Compress) -cne ($req.before_state | ConvertTo-Json -Depth 30 -Compress)){throw 'REFUSED: native conversion baseline changed'}
|
|
1806
|
+
foreach($property in $req.before_files.PSObject.Properties){
|
|
1807
|
+
$file=[IO.Path]::GetFullPath((Join-Path $project $property.Name))
|
|
1808
|
+
if(-not $file.StartsWith($project.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: conversion source outside project'}
|
|
1809
|
+
Assert-NoLinkedPath $file
|
|
1810
|
+
if((Get-MwSha256 $file) -ine [string]$property.Value){throw 'REFUSED: saved conversion baseline changed'}
|
|
1811
|
+
}
|
|
1812
|
+
$present=@($now.structure.pous | Where-Object {$_.name -ceq $name})
|
|
1813
|
+
$destination=if($language -eq 'FBD'){3}else{4}
|
|
1814
|
+
if($present.Count -ne 1 -or $present[0].read_only -ne $false -or $present[0].language -notin @(2,3,4) -or $present[0].language -eq $destination){throw 'REFUSED: exact writable POU in a different supported language required'}
|
|
1815
|
+
$converted=$app.ActiveProject.Pous.Item($name).Convert($destination)
|
|
1816
|
+
$convertedName=if($converted -is [string]){$converted}else{[string]$converted.Name}
|
|
1817
|
+
if($convertedName -cne $name){throw 'Native conversion returned a different identity; inspect state before another action'}
|
|
1818
|
+
$app.ActiveProject.Save()
|
|
1819
|
+
$data=[ordered]@{name=$convertedName;language=[int]$app.ActiveProject.Pous.Item($name).PouLanguage;saved=($app.ActiveProject.IsModified -eq $false);method='native_com_pou_convert'}
|
|
1820
|
+
$ok=$true
|
|
1821
|
+
}
|
|
1822
|
+
'pou_package_mutate' {
|
|
1823
|
+
$app=Connect-App
|
|
1824
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1825
|
+
$project=[IO.Path]::GetFullPath([string]$req.project)
|
|
1826
|
+
if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine ($project+'.mwt') -or $app.ActiveProject.IsModified -ne $false){throw 'REFUSED: wrong or unsaved project'}
|
|
1827
|
+
$name=[string]$req.pou;$operation=[string]$req.operation
|
|
1828
|
+
if($name -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$' -or $operation -notin @('export','import')){throw 'REFUSED: unsupported package operation/identity'}
|
|
1829
|
+
$now=Get-PouPackageState $app
|
|
1830
|
+
if(($now | ConvertTo-Json -Depth 30 -Compress) -cne ($req.before_state | ConvertTo-Json -Depth 30 -Compress)){throw 'REFUSED: native package baseline changed'}
|
|
1831
|
+
foreach($property in $req.before_files.PSObject.Properties){
|
|
1832
|
+
$file=[IO.Path]::GetFullPath((Join-Path $project $property.Name))
|
|
1833
|
+
if(-not $file.StartsWith($project.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: baseline outside project'}
|
|
1834
|
+
Assert-NoLinkedPath $file
|
|
1835
|
+
if((Get-MwSha256 $file) -ine [string]$property.Value){throw 'REFUSED: saved package baseline changed'}
|
|
1836
|
+
}
|
|
1837
|
+
$directory=[IO.Path]::GetFullPath([string]$req.package_directory)
|
|
1838
|
+
$parent=[IO.Path]::GetFullPath((Join-Path $script:WorkspaceRoot '.motionworks'))
|
|
1839
|
+
if([IO.Path]::GetDirectoryName($directory) -ine $parent -or [IO.Path]::GetFileName($directory) -notmatch '^pk-[a-f0-9]{8}$' -or $directory.Length -ge 128 -or $directory -match '[^\x20-\x7e]|["\r\n]'){throw 'REFUSED: unsupported private package directory'}
|
|
1840
|
+
Assert-NoLinkedPath $directory
|
|
1841
|
+
if(-not (Test-Path -LiteralPath $directory -PathType Container)){throw 'REFUSED: package directory missing'}
|
|
1842
|
+
$manifest=Get-PouPackageManifest $directory
|
|
1843
|
+
$present=@($now.structure.pous | Where-Object {$_.name -ieq $name})
|
|
1844
|
+
if($operation -eq 'export'){
|
|
1845
|
+
if($manifest.Count -ne 0 -or $present.Count -ne 1 -or $present[0].read_only){throw 'REFUSED: export requires empty destination and writable POU'}
|
|
1846
|
+
$command='ExportPou '+$name+' "'+$directory+'"'
|
|
1847
|
+
}else{
|
|
1848
|
+
if($present.Count -ne 0 -or (Test-Path -LiteralPath (Join-Path $project ('POE\'+$name)))){throw 'REFUSED: package import identity/directory already exists'}
|
|
1849
|
+
if($manifest.Count -ne $req.package_manifest.Count){throw 'REFUSED: native package file inventory changed'}
|
|
1850
|
+
foreach($expected in $req.package_manifest){
|
|
1851
|
+
$actual=@($manifest | Where-Object {$_.path -ceq $expected.path})
|
|
1852
|
+
if($actual.Count -ne 1 -or $actual[0].bytes -ne $expected.bytes -or $actual[0].sha256 -cne $expected.sha256){throw 'REFUSED: native package manifest changed'}
|
|
1853
|
+
}
|
|
1854
|
+
$command='ImportPou "'+$directory+'"'
|
|
1855
|
+
}
|
|
1856
|
+
$returnCode=$app.ExecuteDdeCommand($command)
|
|
1857
|
+
if([int]$returnCode -ne 0){throw ('Native POU '+$operation+' returned '+$returnCode+'; inspect retained state before another action')}
|
|
1858
|
+
if($operation -eq 'import'){$app.ActiveProject.Save()}
|
|
1859
|
+
$data=[ordered]@{return_code=[int]$returnCode;saved=($app.ActiveProject.IsModified -eq $false);is_modified=$app.ActiveProject.IsModified;method=('native_dde_'+$operation+'Pou')}
|
|
1860
|
+
$ok=$true
|
|
1861
|
+
}
|
|
1862
|
+
'code_mutate' {
|
|
1863
|
+
$app=Connect-App
|
|
1864
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1865
|
+
$project=[IO.Path]::GetFullPath([string]$req.project)
|
|
1866
|
+
if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine ($project+'.mwt') -or [bool]$app.ActiveProject.IsModified){throw 'REFUSED: wrong or unsaved project'}
|
|
1867
|
+
$name=[string]$req.pou;$worksheet=[string]$req.worksheet
|
|
1868
|
+
if($name -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$' -or $worksheet -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$'){throw 'REFUSED: unsupported native code identity'}
|
|
1869
|
+
$target=$app.ActiveProject.Pous.Item($name)
|
|
1870
|
+
$language=[string]$req.language
|
|
1871
|
+
$languages=@{ST=2;IL=1}
|
|
1872
|
+
if(-not $languages.ContainsKey($language) -or [bool]$target.ReadOnly -or [int]$target.PouLanguage -ne $languages[$language]){throw 'REFUSED: code import requires matching writable ST/IL POU'}
|
|
1873
|
+
$now=Get-NativeStructure $app
|
|
1874
|
+
if(($now | ConvertTo-Json -Depth 20 -Compress) -cne ($req.before_native | ConvertTo-Json -Depth 20 -Compress)){throw 'REFUSED: native baseline changed'}
|
|
1875
|
+
Assert-VariableBaseline $req.before_declarations.variables (Get-VariableRows (Get-VariableSheet $app $name))
|
|
1876
|
+
foreach($property in $req.before_files.PSObject.Properties){
|
|
1877
|
+
$file=[IO.Path]::GetFullPath((Join-Path $project $property.Name))
|
|
1878
|
+
if(-not $file.StartsWith($project.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: source outside project'}
|
|
1879
|
+
Assert-NoLinkedPath $file
|
|
1880
|
+
$algorithm=[Security.Cryptography.SHA256]::Create()
|
|
1881
|
+
try{$hash=[BitConverter]::ToString($algorithm.ComputeHash([IO.File]::ReadAllBytes($file))).Replace('-','')}finally{$algorithm.Dispose()}
|
|
1882
|
+
if($hash -ine [string]$property.Value){throw 'REFUSED: saved code baseline changed'}
|
|
1883
|
+
}
|
|
1884
|
+
$inputFile=[IO.Path]::GetFullPath([string]$req.code_import_path)
|
|
1885
|
+
$inputRoot=[IO.Path]::GetFullPath((Join-Path $script:WorkspaceRoot '.motionworks\native-input'))
|
|
1886
|
+
if(-not $inputFile.StartsWith($inputRoot.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase) -or $inputFile -match '["\r\n]'){throw 'REFUSED: invalid code input path'}
|
|
1887
|
+
Assert-NoLinkedPath $inputFile
|
|
1888
|
+
if([string]$req.input_text -match '[^\x09\x0a\x0d\x20-\x7e]' -or [IO.File]::ReadAllText($inputFile) -cne [string]$req.input_text){throw 'REFUSED: code input changed or unsupported encoding'}
|
|
1889
|
+
$command='ChangeCodeWS '+$name+' '+$language+' '+$worksheet+' "'+$inputFile+'"'
|
|
1890
|
+
$returnCode=$app.ExecuteDdeCommand($command)
|
|
1891
|
+
if([int]$returnCode -ne 0){throw ('Native code import returned '+$returnCode+'; inspect state before another action')}
|
|
1892
|
+
$app.ActiveProject.Save()
|
|
1893
|
+
$data=[ordered]@{saved=$true;is_modified=[bool]$app.ActiveProject.IsModified;snapshot=(Get-NativeStructure $app);return_code=[int]$returnCode;method='native_dde_ChangeCodeWS'}
|
|
1894
|
+
$ok=$true
|
|
1895
|
+
}
|
|
1896
|
+
'structure_mutate' {
|
|
1897
|
+
$app=Connect-App
|
|
1898
|
+
[void](Assert-StagedOpen $app $verb)
|
|
1899
|
+
$project=[IO.Path]::GetFullPath([string]$req.project)
|
|
1900
|
+
if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine ($project+'.mwt')){throw 'REFUSED: wrong native project'}
|
|
1901
|
+
if([bool]$app.ActiveProject.IsModified){throw 'REFUSED: reconcile/save native edits'}
|
|
1902
|
+
$now=Get-NativeStructure $app
|
|
1903
|
+
if(($now | ConvertTo-Json -Depth 20 -Compress) -cne ($req.before_native | ConvertTo-Json -Depth 20 -Compress)){throw 'REFUSED: structural baseline changed'}
|
|
1904
|
+
foreach($property in $req.before_files.PSObject.Properties) {
|
|
1905
|
+
$file=[IO.Path]::GetFullPath((Join-Path $project $property.Name))
|
|
1906
|
+
if(-not $file.StartsWith($project.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: baseline source outside project'}
|
|
1907
|
+
Assert-NoLinkedPath $file
|
|
1908
|
+
if(-not (Test-Path -LiteralPath $file)){throw 'REFUSED: saved structural source missing'}
|
|
1909
|
+
$algorithm=[Security.Cryptography.SHA256]::Create()
|
|
1910
|
+
try {$hash=[BitConverter]::ToString($algorithm.ComputeHash([IO.File]::ReadAllBytes($file))).Replace('-','')}
|
|
1911
|
+
finally {$algorithm.Dispose()}
|
|
1912
|
+
if($hash -ine [string]$property.Value){throw 'REFUSED: saved structural source changed'}
|
|
1913
|
+
}
|
|
1914
|
+
$name=[string]$req.name;$op=[string]$req.operation
|
|
1915
|
+
$max=if($req.scope -eq 'task'){7}else{30}
|
|
1916
|
+
if($name -notmatch ('^[A-Za-z_][A-Za-z0-9_]{0,'+($max-1)+'}$')){throw 'REFUSED: invalid native name'}
|
|
1917
|
+
if($req.scope -eq 'pou') {
|
|
1918
|
+
$pous=$app.ActiveProject.Pous
|
|
1919
|
+
if($op -eq 'create') {
|
|
1920
|
+
if([bool]$pous.ReadOnlyAttribute){throw 'REFUSED: POU collection read-only'}
|
|
1921
|
+
$types=@{PROGRAM=7;FUNCTION_BLOCK=6;FUNCTION=24}
|
|
1922
|
+
$kind=if($req.pou_type){[string]$req.pou_type}else{'PROGRAM'}
|
|
1923
|
+
if(-not $types.ContainsKey($kind)){throw 'REFUSED: invalid POU type'}
|
|
1924
|
+
$languages=@{IL=1;ST=2;FBD=3;LD=4}
|
|
1925
|
+
$language=if($req.language){[string]$req.language}else{'ST'}
|
|
1926
|
+
if(-not $languages.ContainsKey($language)){throw 'REFUSED: invalid POU language'}
|
|
1927
|
+
$created=$pous.Create($name,[int]$types[$kind],[int]$languages[$language],[string]$req.return_type,'','')
|
|
1928
|
+
# New native variable storage is lazy. Initialize it as
|
|
1929
|
+
# part of this authorized creation, before its Save,
|
|
1930
|
+
# so a later read does not dirty the new empty POU.
|
|
1931
|
+
[void]$created.Variables.Count
|
|
1932
|
+
[void]$created.Variables.Groups.Count
|
|
1933
|
+
}else{
|
|
1934
|
+
$target=$pous.Item($name)
|
|
1935
|
+
if([bool]$target.ReadOnly){throw 'REFUSED: POU read-only'}
|
|
1936
|
+
if($op -in @('rename','copy') -and [string]$req.new_name -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$'){throw 'REFUSED: invalid new name'}
|
|
1937
|
+
if($op -eq 'copy'){[void]$target.Copy([string]$req.new_name)}
|
|
1938
|
+
elseif($op -eq 'rename') {
|
|
1939
|
+
if($req.references_reviewed -ne $true){throw 'REFUSED: rename reference review required'}
|
|
1940
|
+
$target.Name=[string]$req.new_name
|
|
1941
|
+
}elseif($op -eq 'delete') {
|
|
1942
|
+
if($req.user_approved -ne $true -or $req.references_reviewed -ne $true){throw 'REFUSED: deletion approval/review required'}
|
|
1943
|
+
$target.Delete()
|
|
1944
|
+
}else{throw 'Unknown POU operation'}
|
|
1945
|
+
}
|
|
1946
|
+
}elseif($req.scope -eq 'task') {
|
|
1947
|
+
$resource=$app.ActiveProject.GetObjectByLogicalName('Hardware/Configuration/Resource',10)
|
|
1948
|
+
if($op -eq 'create') {
|
|
1949
|
+
$kind=if($req.kind){[string]$req.kind}else{'CYCLIC'}
|
|
1950
|
+
[void]$resource.Tasks.Create($name,$kind)
|
|
1951
|
+
}else{
|
|
1952
|
+
$target=$resource.Tasks.Item($name)
|
|
1953
|
+
if($op -eq 'delete') {
|
|
1954
|
+
if($req.user_approved -ne $true -or $target.ProgramInstances.Count -ne 0){throw 'REFUSED: task deletion approval or empty task required'}
|
|
1955
|
+
$target.Delete()
|
|
1956
|
+
}elseif($op -eq 'assign') {
|
|
1957
|
+
$instance=if($req.instance){[string]$req.instance}else{[string]$req.pou}
|
|
1958
|
+
$pou=$app.ActiveProject.Pous.Item([string]$req.pou)
|
|
1959
|
+
if([int]$pou.PouType -ne 7){throw 'REFUSED: assignment requires PROGRAM'}
|
|
1960
|
+
[void]$target.ProgramInstances.Create($instance,[string]$pou.Name)
|
|
1961
|
+
}elseif($op -eq 'unassign') {
|
|
1962
|
+
if($req.user_approved -ne $true){throw 'REFUSED: unassignment approval required'}
|
|
1963
|
+
$target.ProgramInstances.Item([string]$req.instance).Delete()
|
|
1964
|
+
}elseif($op -eq 'edit') {
|
|
1965
|
+
$inputFile=[IO.Path]::GetFullPath([string]$req.settings_import_path)
|
|
1966
|
+
$inputRoot=Join-Path $script:WorkspaceRoot '.motionworks\native-input'
|
|
1967
|
+
if(-not $inputFile.StartsWith([IO.Path]::GetFullPath($inputRoot).TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: task input outside private workspace input directory'}
|
|
1968
|
+
Assert-NoLinkedPath $inputFile
|
|
1969
|
+
if([IO.File]::ReadAllText($inputFile) -cne [string]$req.settings_text){throw 'REFUSED: task input changed'}
|
|
1970
|
+
if([string]$req.settings_text -notmatch ('^TASK '+[regex]::Escape($name)+'\r?\n')){throw 'REFUSED: task input names another task'}
|
|
1971
|
+
$target.ImportSettingsFile($inputFile)
|
|
1972
|
+
}else{throw 'Unknown task operation'}
|
|
1973
|
+
}
|
|
1974
|
+
}else{throw 'Unknown native structural scope'}
|
|
1975
|
+
$app.ActiveProject.Save()
|
|
1976
|
+
$deadline=[DateTime]::UtcNow.AddSeconds(10)
|
|
1977
|
+
do {
|
|
1978
|
+
Start-Sleep -Milliseconds 250
|
|
1979
|
+
$after=Get-NativeStructure $app
|
|
1980
|
+
$matches=(($after | ConvertTo-Json -Depth 20 -Compress) -ceq ($req.expected_native | ConvertTo-Json -Depth 20 -Compress))
|
|
1981
|
+
}while(-not $matches -and [DateTime]::UtcNow -lt $deadline)
|
|
1982
|
+
$data=[ordered]@{operation=$op;saved=$true;snapshot=$after;is_modified=[bool]$app.ActiveProject.IsModified;method='native_com';native_matches_plan=$matches}
|
|
1983
|
+
$ok=$true
|
|
1984
|
+
}
|
|
1985
|
+
'variable_group_snapshot' {
|
|
1986
|
+
$app=Connect-App;[void](Assert-StagedOpen $app $verb)
|
|
1987
|
+
if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: unsaved native group baseline'}
|
|
1988
|
+
$data=Get-VariableGroupSnapshot $app ([string]$req.pou)
|
|
1989
|
+
if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: group inspection changed modified state'};$ok=$true
|
|
1990
|
+
}
|
|
1991
|
+
'variable_group_mutate' {
|
|
1992
|
+
$app=Connect-App;[void](Assert-StagedOpen $app $verb)
|
|
1993
|
+
$project=[IO.Path]::GetFullPath([string]$req.project)
|
|
1994
|
+
if($req.baseline_saved -ne $true -or [IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine ($project+'.mwt') -or $app.ActiveProject.IsModified -ne $false){throw 'REFUSED: wrong or unsaved project'}
|
|
1995
|
+
$pou=[string]$req.pou;$op=[string]$req.operation;$name=[string]$req.name;$destination=if($op -eq 'rename'){[string]$req.new_name}else{$name}
|
|
1996
|
+
if($op -notin @('create','rename','delete') -or [string]::IsNullOrWhiteSpace($name) -or $name.Length -gt 255 -or $name -match '[\x00-\x1f]'){throw 'REFUSED: invalid group operation/name'}
|
|
1997
|
+
if($op -ne 'delete' -and ([string]::IsNullOrWhiteSpace($destination) -or $destination -cne $destination.Trim() -or $destination.Length -gt 255 -or $destination -match '[\x00-\x1f]')){throw 'REFUSED: invalid new group name'}
|
|
1998
|
+
$now=Get-VariableGroupSnapshot $app $pou
|
|
1999
|
+
if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: native state became unsaved during inspection'}
|
|
2000
|
+
if(($now | ConvertTo-Json -Depth 30 -Compress) -cne ($req.before_state | ConvertTo-Json -Depth 30 -Compress)){throw 'REFUSED: native group baseline changed'}
|
|
2001
|
+
if($pou -and @($now.structure.pous | Where-Object {$_.name -ceq $pou -and $_.read_only -eq $false}).Count -ne 1){throw 'REFUSED: exact writable POU required'}
|
|
2002
|
+
foreach($property in $req.before_files.PSObject.Properties){
|
|
2003
|
+
$file=[IO.Path]::GetFullPath((Join-Path $project $property.Name))
|
|
2004
|
+
if(-not $file.StartsWith($project.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: group baseline outside project'}
|
|
2005
|
+
Assert-NoLinkedPath $file
|
|
2006
|
+
if((Get-MwSha256 $file) -ine [string]$property.Value){throw 'REFUSED: saved group baseline changed'}
|
|
2007
|
+
}
|
|
2008
|
+
$vars=Get-VariableSheet $app $pou;$target=$null;$matches=0;$collision=$false;$count=[int]$vars.Groups.Count
|
|
2009
|
+
for($i=1;$i -le $count;$i++){$g=$vars.Groups.Item($i);if([string]$g.Name -ceq $name){$target=$g;$matches++};if([string]$g.Name -ieq $destination -and ($op -eq 'create' -or [string]$g.Name -cne $name)){$collision=$true}}
|
|
2010
|
+
if($collision){throw 'REFUSED: group destination collision'}
|
|
2011
|
+
if($op -eq 'create'){
|
|
2012
|
+
if($matches){throw 'REFUSED: group already exists'}
|
|
2013
|
+
[void]$vars.Groups.Create($destination)
|
|
2014
|
+
}else{
|
|
2015
|
+
if($matches -ne 1 -or $target.ReadOnly -ne $false){throw 'REFUSED: exact writable group required'}
|
|
2016
|
+
if($op -eq 'rename'){if($name -ceq $destination){throw 'REFUSED: group already named'};$target.Name=$destination}
|
|
2017
|
+
else{if($req.user_approved -ne $true -or $target.Variables.Count -ne 0 -or $count -le 1){throw 'REFUSED: approved empty group with another remaining group required'};$target.Delete()}
|
|
2018
|
+
}
|
|
2019
|
+
$app.ActiveProject.Save()
|
|
2020
|
+
$data=[ordered]@{saved=($app.ActiveProject.IsModified -eq $false);is_modified=[bool]$app.ActiveProject.IsModified;operation=$op;method='native_com_group'};$ok=$true
|
|
2021
|
+
}
|
|
2022
|
+
'variable_mutate' {
|
|
2023
|
+
$app=Connect-App
|
|
2024
|
+
[void](Assert-StagedOpen $app $verb)
|
|
2025
|
+
if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine [IO.Path]::GetFullPath([string]$req.project+'.mwt')) {throw 'REFUSED: wrong native project'}
|
|
2026
|
+
if([bool]$app.ActiveProject.IsModified){throw 'REFUSED: reconcile/save unsaved native edits first'}
|
|
2027
|
+
$vars=Get-VariableSheet $app ([string]$req.pou)
|
|
2028
|
+
Assert-VariableBaseline $req.before_native (Get-VariableRows $vars)
|
|
2029
|
+
$op=[string]$req.operation; $decl=$req.declaration
|
|
2030
|
+
if($op -notin @('add','edit','delete')){throw 'Unknown native variable operation'}
|
|
2031
|
+
$flagMap=@{retain='Retain';pdd='PDD';opc='OPC';disabled='Disabled';not_on_plc='NotOnPlc';redundant='Redundant'}
|
|
2032
|
+
if($null -ne $req.flags) {
|
|
2033
|
+
if($req.flags -isnot [pscustomobject]){throw 'REFUSED: native flags must be an object'}
|
|
2034
|
+
if($op -eq 'delete'){throw 'REFUSED: flag changes require add/edit'}
|
|
2035
|
+
foreach($flag in $req.flags.PSObject.Properties) {
|
|
2036
|
+
if(-not $flagMap.ContainsKey($flag.Name) -or $flag.Value -isnot [bool]){throw 'REFUSED: unknown or non-boolean native variable flag'}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
$target=$null
|
|
2040
|
+
for($i=1;$i -le $vars.Count;$i++) {if([string]$vars.Item($i).Name -ieq [string]$req.name){$target=$vars.Item($i)}}
|
|
2041
|
+
if($op -ne 'add' -and -not $target){throw 'REFUSED: exact variable not found'}
|
|
2042
|
+
$groupName=if($op -eq 'delete'){[string]$target.Group.Name}else{[string]$decl.group}
|
|
2043
|
+
$group=$null
|
|
2044
|
+
for($i=1;$i -le $vars.Groups.Count;$i++){if([string]$vars.Groups.Item($i).Name -ceq $groupName){$group=$vars.Groups.Item($i)}}
|
|
2045
|
+
if(-not $group -or [bool]$group.ReadOnly){throw 'REFUSED: target variable group absent or read-only'}
|
|
2046
|
+
if($op -eq 'delete') {
|
|
2047
|
+
if($req.user_approved -ne $true -or $req.references_reviewed -ne $true){throw 'REFUSED: deletion approval and reference review required'}
|
|
2048
|
+
$target.Delete()
|
|
2049
|
+
} else {
|
|
2050
|
+
$sections=@{VAR=1;VAR_INPUT=2;VAR_OUTPUT=3;VAR_IN_OUT=4;VAR_EXTERNAL=5;VAR_GLOBAL=6}
|
|
2051
|
+
$block=$sections[[string]$decl.section]
|
|
2052
|
+
if(-not $block -or ($req.pou -and $block -eq 6) -or (-not $req.pou -and $block -ne 6)){throw 'REFUSED: declaration usage does not match native scope'}
|
|
2053
|
+
if([string]$decl.name -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$'){throw 'REFUSED: invalid IEC name'}
|
|
2054
|
+
if($block -eq 5 -and ($decl.address -or $decl.initial_value)){throw 'REFUSED: external address/initializer forbidden'}
|
|
2055
|
+
for($i=1;$i -le $vars.Count;$i++) {
|
|
2056
|
+
if([string]$vars.Item($i).Name -ieq [string]$decl.name -and ($op -eq 'add' -or [string]$target.Name -ine [string]$decl.name)){throw 'REFUSED: duplicate declaration name'}
|
|
2057
|
+
}
|
|
2058
|
+
if($op -eq 'add') {
|
|
2059
|
+
$group.Variables.Create([string]$decl.name,[string]$decl.type,[int]$block,[string]$decl.description,[string]$decl.initial_value,[string]$decl.address,$false) | Out-Null
|
|
2060
|
+
for($i=1;$i -le $vars.Count;$i++){if([string]$vars.Item($i).Name -ceq [string]$decl.name){$target=$vars.Item($i)}}
|
|
2061
|
+
if(-not $target){throw 'Native created variable cannot be resolved; inspect before another action'}
|
|
2062
|
+
} else {
|
|
2063
|
+
if([string]$target.Group.Name -cne $groupName){throw 'REFUSED: native Variable.Group is read-only; use the observed worksheet move workflow and full verification'}
|
|
2064
|
+
if([string]$target.Name -ine [string]$decl.name -and $req.rename_reviewed -ne $true){throw 'REFUSED: rename reference review required'}
|
|
2065
|
+
$target.Name=[string]$decl.name
|
|
2066
|
+
$target.DataType=[string]$decl.type
|
|
2067
|
+
$target.BlockType=[int]$block
|
|
2068
|
+
$target.Comment=[string]$decl.description
|
|
2069
|
+
$target.InitialValue=[string]$decl.initial_value
|
|
2070
|
+
$target.IecAddress=[string]$decl.address
|
|
2071
|
+
}
|
|
2072
|
+
if($null -ne $req.flags) {
|
|
2073
|
+
foreach($flag in $req.flags.PSObject.Properties) {
|
|
2074
|
+
$nativeFlag=$flagMap[$flag.Name]
|
|
2075
|
+
$target.$nativeFlag=[bool]$flag.Value
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
$app.ActiveProject.Save()
|
|
2080
|
+
$data=[ordered]@{operation=$op;saved=$true;variables=(Get-VariableRows $vars);is_modified=[bool]$app.ActiveProject.IsModified;method='native_com'}
|
|
2081
|
+
$ok=$true
|
|
2082
|
+
}
|
|
2083
|
+
'variables' {
|
|
2084
|
+
$app = Connect-App
|
|
2085
|
+
[void](Assert-StagedOpen $app $verb)
|
|
2086
|
+
$want = [string]$req.pou
|
|
2087
|
+
$pous = $app.ActiveProject.Pous
|
|
2088
|
+
$result = @()
|
|
2089
|
+
for ($i = 1; $i -le $pous.Count; $i++) {
|
|
2090
|
+
$p = $pous.Item($i)
|
|
2091
|
+
$pname = [string]$p.Name
|
|
2092
|
+
if (-not [string]::IsNullOrWhiteSpace($want) -and $pname -ne $want) { continue }
|
|
2093
|
+
|
|
2094
|
+
$items = @()
|
|
2095
|
+
try {
|
|
2096
|
+
$vs = $p.Variables
|
|
2097
|
+
foreach ($v in (Get-VariableRows $vs)) {
|
|
2098
|
+
$items += [ordered]@{
|
|
2099
|
+
name = $v.name
|
|
2100
|
+
data_type = $v.type
|
|
2101
|
+
initial_value = $v.initial_value
|
|
2102
|
+
iec_address = $v.address
|
|
2103
|
+
section = $v.section
|
|
2104
|
+
group = $v.group
|
|
2105
|
+
description = $v.description
|
|
2106
|
+
retain = $v.retain
|
|
2107
|
+
pdd = $v.pdd
|
|
2108
|
+
opc = $v.opc
|
|
2109
|
+
disabled = $v.disabled
|
|
2110
|
+
not_on_plc = $v.not_on_plc
|
|
2111
|
+
redundant = $v.redundant
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
} catch { throw ('Native variable model incomplete: '+$_.Exception.Message) }
|
|
2115
|
+
|
|
2116
|
+
$groups = @()
|
|
2117
|
+
try {
|
|
2118
|
+
$gs = $p.Variables.Groups
|
|
2119
|
+
for ($g = 1; $g -le $gs.Count; $g++) {
|
|
2120
|
+
$grp = $gs.Item($g)
|
|
2121
|
+
$gv = @()
|
|
2122
|
+
try {
|
|
2123
|
+
$gvv = $grp.Variables
|
|
2124
|
+
for ($m = 1; $m -le $gvv.Count; $m++) { $gv += [string]$gvv.Item($m).Name }
|
|
2125
|
+
} catch { throw ('Native group members incomplete: '+$_.Exception.Message) }
|
|
2126
|
+
$groups += [ordered]@{ name = [string]$grp.Name; count = $gv.Count; variables = $gv; read_only = [bool]$grp.ReadOnly }
|
|
2127
|
+
}
|
|
2128
|
+
} catch { throw ('Native variable groups incomplete: '+$_.Exception.Message) }
|
|
2129
|
+
|
|
2130
|
+
$result += [ordered]@{ pou = $pname; count = $items.Count; variables = $items; groups = $groups }
|
|
2131
|
+
}
|
|
2132
|
+
$ok = $true
|
|
2133
|
+
$data = [ordered]@{ pous = $result }
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
# Launch the interactive IDE deliberately with its verified wrapper.
|
|
2137
|
+
# Connect-App refuses implicit launch. Background bridge helpers stay
|
|
2138
|
+
# hidden; the IDE frame must be visible for native readiness/trial checks.
|
|
2139
|
+
'start_ide' {
|
|
2140
|
+
$launchProject = [string]$req.path
|
|
2141
|
+
Assert-ProvenCopy $launchProject
|
|
2142
|
+
$digest = Get-MwSha256 $launchProject
|
|
2143
|
+
if (-not $req.wrapper_sha256 -or $digest -ne [string]$req.wrapper_sha256) {
|
|
2144
|
+
throw 'REFUSED: startup requires a verified workspace wrapper.'
|
|
2145
|
+
}
|
|
2146
|
+
$exe = [string]$req.exe
|
|
2147
|
+
if ([string]::IsNullOrWhiteSpace($exe)) {
|
|
2148
|
+
$exe = 'C:\Program Files (x86)\Yaskawa\MotionWorks IEC 3 Pro\Mwt.exe'
|
|
2149
|
+
}
|
|
2150
|
+
$already = [bool](Get-Process -Name Mwt -ErrorAction SilentlyContinue)
|
|
2151
|
+
$trialAnswered = $null
|
|
2152
|
+
$trialMethod = $null
|
|
2153
|
+
$trialSeen = $false
|
|
2154
|
+
if (-not $already) {
|
|
2155
|
+
if (-not (Test-Path $exe)) { throw "Mwt.exe not found at $exe" }
|
|
2156
|
+
Start-Process -FilePath $exe -ArgumentList ('"' + $launchProject + '"') -WindowStyle Normal | Out-Null
|
|
2157
|
+
}
|
|
2158
|
+
# A running process or blocked frame is not a ready IDE. Inspect
|
|
2159
|
+
# the licence owner before COM and never start a duplicate instance.
|
|
2160
|
+
$deadline = (Get-Date).AddSeconds(300)
|
|
2161
|
+
while ((Get-Date) -lt $deadline) {
|
|
2162
|
+
$pair = Get-TrialDialog
|
|
2163
|
+
if ($pair[0]) {
|
|
2164
|
+
if ($trialSeen) { throw 'Trial action was submitted but the dialog remains; no automatic retry or duplicate IDE launch' }
|
|
2165
|
+
$trialSeen = $true
|
|
2166
|
+
$answer = Answer-TrialDialog
|
|
2167
|
+
$trialAnswered = [bool]$answer.dismissed
|
|
2168
|
+
$trialMethod = [string]$answer.method
|
|
2169
|
+
if (-not $trialAnswered) { throw 'Use Trial was submitted but closure was not verified; inspect mw_ide_trial before further input' }
|
|
2170
|
+
}
|
|
2171
|
+
$readyWindow = Get-IdeWindow
|
|
2172
|
+
if ($readyWindow -and [MWW]::IsWindowEnabled($readyWindow)) { break }
|
|
2173
|
+
if ($already -and $readyWindow) {
|
|
2174
|
+
$blockingDialogs = @(Get-IdeDialogs | Where-Object { $_.enabled -and $_.buttons.Count -gt 0 })
|
|
2175
|
+
if ($blockingDialogs.Count -gt 0 -and (Get-IdeWindow) -eq $readyWindow -and -not [MWW]::IsWindowEnabled($readyWindow)) {
|
|
2176
|
+
$frameKey = '0x{0:X}' -f ([int64]$readyWindow)
|
|
2177
|
+
$dialogNames = ($blockingDialogs | ForEach-Object { "$($_.title) [$($_.handle)]" }) -join ', '
|
|
2178
|
+
throw "Existing IDE frame $frameKey is blocked by an observed modal: $dialogNames. Inspect mw_ide_state and resolve that exact dialog deliberately before another start request. No COM operation, dialog answer or duplicate IDE launch was attempted."
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
Start-Sleep -Milliseconds 500
|
|
2182
|
+
}
|
|
2183
|
+
$w = Get-IdeWindow
|
|
2184
|
+
if (-not $w) {
|
|
2185
|
+
$pair = Get-TrialDialog
|
|
2186
|
+
if ($pair[0]) {
|
|
2187
|
+
throw 'Startup remains blocked by the licence dialog. Inspect mw_ide_state and use mw_ide_trial(attempt:true) for one exact native Use Trial action. Do not start another IDE or use a generic dialog answer.'
|
|
2188
|
+
}
|
|
2189
|
+
throw 'No visible IDE frame was observed within 300s. Inspect mw_ide_state and the existing Mwt process before any retry; observation timeout does not prove process exit. Do not start a duplicate IDE.'
|
|
2190
|
+
}
|
|
2191
|
+
if (-not [MWW]::IsWindowEnabled($w)) {
|
|
2192
|
+
throw 'The IDE frame remains disabled after startup observation. Inspect mw_ide_state for the owning modal; no COM project operation or duplicate launch was attempted.'
|
|
2193
|
+
}
|
|
2194
|
+
# Force a fresh connection: the previous one pointed at the old process.
|
|
2195
|
+
$script:App = $null
|
|
2196
|
+
$app = Connect-App
|
|
2197
|
+
# A bare Mwt.exe launch can restore the last project. Leave it open
|
|
2198
|
+
# until the user authorizes saving and closing that exact project.
|
|
2199
|
+
$dismissed = $null
|
|
2200
|
+
$foreign = $null
|
|
2201
|
+
$openPath = Get-OpenProjectPath $app
|
|
2202
|
+
Log "start_ide already=$already openPath=$openPath"
|
|
2203
|
+
if ($openPath -and -not (Test-SameProject $openPath $launchProject)) {
|
|
2204
|
+
$foreign = $openPath
|
|
2205
|
+
}
|
|
2206
|
+
if ($foreign) {
|
|
2207
|
+
throw "REFUSED: MotionWorks has '$foreign' open. Ask the user whether the agent may save and close that project, then call mw_ide_open with user_approved=true and expected_project='$foreign'. No project operation was performed."
|
|
2208
|
+
}
|
|
2209
|
+
$ok = $true
|
|
2210
|
+
$data = [ordered]@{
|
|
2211
|
+
already_running = $already
|
|
2212
|
+
ide_window = ("0x{0:X}" -f ([int64]$w))
|
|
2213
|
+
version = [string]$app.Version
|
|
2214
|
+
launched_exe = $exe
|
|
2215
|
+
trial_dialog = $trialSeen
|
|
2216
|
+
trial_answered = $trialAnswered
|
|
2217
|
+
dismissed_project = $dismissed
|
|
2218
|
+
foreign_project = $foreign
|
|
2219
|
+
}
|
|
2220
|
+
# Cached callers retain their existing closed output schema.
|
|
2221
|
+
if ($req.include_trial_method -eq $true) { $data.trial_method=$trialMethod }
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
# What the IDE is ACTUALLY doing, including modal dialogs the COM API
|
|
2225
|
+
# cannot see. Call this after every IDE step.
|
|
2226
|
+
#
|
|
2227
|
+
# While a dialog is up, the automation API reports nothing useful:
|
|
2228
|
+
# IsProjectOpen() is false or throws and OpenProject fails. An agent
|
|
2229
|
+
# reading only COM concludes "no project open" or "the IDE has closed"
|
|
2230
|
+
# when the IDE is in fact waiting for a button. This is the antidote.
|
|
2231
|
+
'ide_state' {
|
|
2232
|
+
$ok = $true
|
|
2233
|
+
$data = Get-IdeState
|
|
2234
|
+
# Opt in so a cached caller's closed result schema is unchanged.
|
|
2235
|
+
if ($req.include_window_state -eq $true) {
|
|
2236
|
+
$frame = Get-IdeWindow
|
|
2237
|
+
$frameKey = if ($frame) { '0x{0:X}' -f ([int64]$frame) } else { $null }
|
|
2238
|
+
if ($frameKey -cne $data.ide_window) { throw 'IDE frame changed during window-state observation; reobserve' }
|
|
2239
|
+
$data.ide_minimized = if ($frame) { [MWW]::IsIconic([IntPtr]$frame) } else { $null }
|
|
2240
|
+
$data.ide_visible = if ($frame) { [MWW]::IsWindowVisible([IntPtr]$frame) } else { $null }
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
# Answer a modal dialog the IDE is waiting on.
|
|
2245
|
+
#
|
|
2246
|
+
# These are standard Win32 dialogs, so a BM_CLICK to the button handle
|
|
2247
|
+
# is enough - no synthetic input, no focus games.
|
|
2248
|
+
'answer_dialog' {
|
|
2249
|
+
$dialogs = Get-IdeDialogs
|
|
2250
|
+
if ($dialogs.Count -eq 0) {
|
|
2251
|
+
$ok = $true
|
|
2252
|
+
$data = [ordered]@{ answered = $false; reason = 'no modal dialog is up' }
|
|
2253
|
+
} else {
|
|
2254
|
+
$target = $null
|
|
2255
|
+
if ($req.handle) {
|
|
2256
|
+
$target = $dialogs | Where-Object { $_.handle -eq [string]$req.handle } | Select-Object -First 1
|
|
2257
|
+
}
|
|
2258
|
+
if (-not $target) { $target = $dialogs | Where-Object { $_.enabled } | Select-Object -First 1 }
|
|
2259
|
+
if (-not $target) { $target = $dialogs | Select-Object -First 1 }
|
|
2260
|
+
|
|
2261
|
+
$wantLabel = if ($req.button) { ([string]$req.button) -replace '&', '' } else { $null }
|
|
2262
|
+
$wantId = $req.button_id
|
|
2263
|
+
|
|
2264
|
+
$btn = $null
|
|
2265
|
+
if ($null -ne $wantId) {
|
|
2266
|
+
$btn = $target.buttons | Where-Object { $_.id -eq [int]$wantId } | Select-Object -First 1
|
|
2267
|
+
}
|
|
2268
|
+
if (-not $btn -and $wantLabel) {
|
|
2269
|
+
$btn = $target.buttons | Where-Object {
|
|
2270
|
+
$_.label -ieq $wantLabel -or $_.label -ilike "$wantLabel*"
|
|
2271
|
+
} | Select-Object -First 1
|
|
2272
|
+
}
|
|
2273
|
+
if (-not $btn -and $wantLabel -eq 'Yes') {
|
|
2274
|
+
# Win32 message boxes use IDYES=6 / IDNO=7 / IDOK=1 / IDCANCEL=2.
|
|
2275
|
+
$wellKnown = @{ Yes = 6; No = 7; OK = 1; Cancel = 2; Retry = 4; Ignore = 5; Abort = 3 }
|
|
2276
|
+
if ($wellKnown.ContainsKey($wantLabel)) {
|
|
2277
|
+
$btn = $target.buttons | Where-Object { $_.id -eq $wellKnown[$wantLabel] } | Select-Object -First 1
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
if (-not $btn) {
|
|
2282
|
+
$labels = ($target.buttons | ForEach-Object { $_.label }) -join ', '
|
|
2283
|
+
throw "no button matching '$wantLabel' on dialog $($target.handle); it offers: $labels"
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
$bh = $script:dialogButtons["$($target.handle)|$($btn.id)"]
|
|
2287
|
+
if (-not $bh) { throw "button handle for '$($btn.label)' was not captured" }
|
|
2288
|
+
|
|
2289
|
+
[void][MWW]::SendMessage([IntPtr]$bh, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero) # BM_CLICK
|
|
2290
|
+
Start-Sleep -Milliseconds 600
|
|
2291
|
+
|
|
2292
|
+
$stayed = [MWW]::IsWindow([IntPtr]$script:dialogHwnds[$target.handle])
|
|
2293
|
+
$ok = $true
|
|
2294
|
+
$data = [ordered]@{
|
|
2295
|
+
answered = (-not $stayed)
|
|
2296
|
+
dialog = $target.handle
|
|
2297
|
+
pressed = $btn.label
|
|
2298
|
+
pressed_id = $btn.id
|
|
2299
|
+
dialog_closed = (-not $stayed)
|
|
2300
|
+
message = $target.message
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
# this is how you tell "no IDE yet" apart from "the IDE is waiting for
|
|
2306
|
+
# a human to answer the licence dialog".
|
|
2307
|
+
'trial_state' {
|
|
2308
|
+
$pair = Get-TrialDialog
|
|
2309
|
+
$d = $pair[0]; $b = $pair[1]
|
|
2310
|
+
$ideW = Get-IdeWindow
|
|
2311
|
+
$ok = $true
|
|
2312
|
+
$data = [ordered]@{
|
|
2313
|
+
dialog_present = [bool]$d
|
|
2314
|
+
dialog_hwnd = $(if ($d) { "0x{0:X}" -f ([int64]$d) } else { $null })
|
|
2315
|
+
use_trial_hwnd = $(if ($b) { "0x{0:X}" -f ([int64]$b) } else { $null })
|
|
2316
|
+
ide_window = $(if ($ideW) { "0x{0:X}" -f ([int64]$ideW) } else { $null })
|
|
2317
|
+
verifier_running = [bool](Get-Process -Name mwctVerify -ErrorAction SilentlyContinue)
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
# Try to answer the licence dialog with "Use Trial".
|
|
2322
|
+
#
|
|
2323
|
+
# Every strategy is VERIFIED by re-checking the window, and if none of
|
|
2324
|
+
# them close it this reports that honestly instead of claiming success.
|
|
2325
|
+
# The dialog is a .NET WinForms window in ANOTHER process; from an
|
|
2326
|
+
# automated process, clicking it is best-effort, not guaranteed.
|
|
2327
|
+
'dismiss_trial' {
|
|
2328
|
+
$data = Answer-TrialDialog
|
|
2329
|
+
$ok = [bool]$data.dismissed
|
|
2330
|
+
if (-not $ok) { $err = 'Use Trial action was submitted but the dialog remains. No automatic retry was sent. Inspect the exact dialog before further action.' }
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
# List the IDE's output windows (Build/Errors/Warnings/Infos/...).
|
|
2334
|
+
'output_windows' {
|
|
2335
|
+
$app = Connect-App
|
|
2336
|
+
$ow = $app.OutputWindows
|
|
2337
|
+
$names = @()
|
|
2338
|
+
for ($i = 1; $i -le $ow.Count; $i++) {
|
|
2339
|
+
try { $names += [string]$ow.Item($i).Name } catch { }
|
|
2340
|
+
}
|
|
2341
|
+
$ok = $true
|
|
2342
|
+
$data = [ordered]@{ count = $names.Count; names = $names }
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
# Bring an output window to the front.
|
|
2346
|
+
#
|
|
2347
|
+
# This is how compile ERRORS are read: the automation API exposes the
|
|
2348
|
+
# verdict (IsCompiled) but never the messages, and the output windows
|
|
2349
|
+
# have AddEntry/Clear/Activate but no read accessor. Activating the pane
|
|
2350
|
+
# and then screenshotting it is the only route to the error text.
|
|
2351
|
+
'activate_output' {
|
|
2352
|
+
$name = [string]$req.name
|
|
2353
|
+
if ([string]::IsNullOrWhiteSpace($name)) { throw 'activate_output requires "name"' }
|
|
2354
|
+
$app = Connect-App
|
|
2355
|
+
$ow = $app.OutputWindows
|
|
2356
|
+
$hit = $null
|
|
2357
|
+
for ($i = 1; $i -le $ow.Count; $i++) {
|
|
2358
|
+
try {
|
|
2359
|
+
$w = $ow.Item($i)
|
|
2360
|
+
if ("$($w.Name)" -eq $name) { $w.Activate(); $hit = $w; break }
|
|
2361
|
+
} catch { }
|
|
2362
|
+
}
|
|
2363
|
+
if ($null -eq $hit) { throw "no output window named '$name'" }
|
|
2364
|
+
$ok = $true
|
|
2365
|
+
$data = [ordered]@{ name = $name; caption = [string]$hit.Caption; activated = $true }
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
# Read an output pane AS TEXT - this is how the agent gets compiler messages.
|
|
2369
|
+
#
|
|
2370
|
+
# Activating the pane brings its list to the front, then the visible
|
|
2371
|
+
# SysListView32 with rows is read through MSAA. Verified against a real
|
|
2372
|
+
# failing build: it returns the messages verbatim, for example
|
|
2373
|
+
# "No matching global variable found for 'x:y' in resource 'Resource'!"
|
|
2374
|
+
# "Instance 'CalcSplineMatrix' is used more than once!"
|
|
2375
|
+
# which a screenshot could never give an agent that has to act on them.
|
|
2376
|
+
'read_output' {
|
|
2377
|
+
$name = if ($req.pane) { [string]$req.pane } else { 'Errors' }
|
|
2378
|
+
$limit = if ($req.limit) { [int]$req.limit } else { 200 }
|
|
2379
|
+
if ($null -ne $req.limit -and ($req.limit -isnot [int] -and $req.limit -isnot [long] -or $req.limit -lt 1 -or $req.limit -gt 5000)) { throw 'Output limit must be an integer from 1 to 5000' }
|
|
2380
|
+
$app = Connect-App
|
|
2381
|
+
[void](Assert-StagedOpen $app 'read_output')
|
|
2382
|
+
$ideWin = Get-IdeWindow
|
|
2383
|
+
if (-not $ideWin) { throw 'Exact IDE frame unavailable; diagnostics unverified' }
|
|
2384
|
+
if ([MWW]::IsIconic([IntPtr]$ideWin)) {
|
|
2385
|
+
throw ('IDE is minimized (0x{0:X}); diagnostics unverified, no pane activation performed. Restore this exact fixture frame deliberately with the connected Windows tool, reobserve, then read diagnostics. Do not repeat this request while minimized or infer missing hidden-dock recovery.' -f ([int64]$ideWin))
|
|
2386
|
+
}
|
|
2387
|
+
# Activate first: a pane that is not showing has no visible list.
|
|
2388
|
+
$activated = $false
|
|
2389
|
+
$ow = $app.OutputWindows
|
|
2390
|
+
for ($i = 1; $i -le $ow.Count; $i++) {
|
|
2391
|
+
$w = $ow.Item($i)
|
|
2392
|
+
if ("$($w.Name)" -ceq $name) { $activated = $true; break }
|
|
2393
|
+
}
|
|
2394
|
+
if (-not $activated) { throw "No exact output pane '$name'; no diagnostic list was read" }
|
|
2395
|
+
$idePid = Get-IdePid
|
|
2396
|
+
|
|
2397
|
+
# Find the MESSAGE WINDOW first, then read only its lists.
|
|
2398
|
+
#
|
|
2399
|
+
# Picking "the visible list with the most rows" across the whole IDE is
|
|
2400
|
+
# wrong: the Edit Wizard owns a SysListView32 too, so activating a pane
|
|
2401
|
+
# that happens to be empty returned the Edit Wizard's contents instead.
|
|
2402
|
+
# Scoping to the Message Window excludes every other list in the app.
|
|
2403
|
+
$script:msgWin = $null
|
|
2404
|
+
$ideWin = Get-IdeWindow
|
|
2405
|
+
if ($ideWin) {
|
|
2406
|
+
# The Message Window is a CHILD of the IDE frame (an AfxControlBar120
|
|
2407
|
+
# dock pane), not a top-level window, so EnumWindows never sees it.
|
|
2408
|
+
$cbTop = [MWW+EnumWindowsProc]{
|
|
2409
|
+
param($h, $l)
|
|
2410
|
+
$title = [MWW]::ReadText($h)
|
|
2411
|
+
if ($title -eq 'Message Window' -or $title -eq 'Messages') { $script:msgWin = $h }
|
|
2412
|
+
return $true
|
|
2413
|
+
}
|
|
2414
|
+
[void][MWW]::EnumChildWindows([IntPtr]$ideWin, $cbTop, [IntPtr]::Zero)
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
# Activate() can select a tab without exposing its hidden dock bar.
|
|
2418
|
+
# Recover exactly View > Message Window via the installed menu
|
|
2419
|
+
# resource, once, with no focus dependence, mouse or keyboard input.
|
|
2420
|
+
if ($script:msgWin -and -not [MWW]::IsWindowVisible([IntPtr]$script:msgWin)) {
|
|
2421
|
+
if (-not [MWW]::IsWindowEnabled([IntPtr]$ideWin)) { throw 'IDE is modal; Message Window recovery refused' }
|
|
2422
|
+
$executable = (Get-Process -Id $idePid -ErrorAction Stop).Path
|
|
2423
|
+
if (-not $executable -or -not [MWW]::HasMessageWindowCommand($executable)) { throw 'Exact installed View > Message Window command not verified; recovery refused' }
|
|
2424
|
+
$modifiedBefore = [bool]$app.ActiveProject.IsModified
|
|
2425
|
+
if (-not [MWW]::PostMessage([IntPtr]$ideWin, 0x0111, [IntPtr]36554, [IntPtr]::Zero)) { throw 'Message Window command not accepted' }
|
|
2426
|
+
$recoveryDeadline = (Get-Date).AddSeconds(4)
|
|
2427
|
+
while (-not [MWW]::IsWindowVisible([IntPtr]$script:msgWin) -and (Get-Date) -lt $recoveryDeadline) { Start-Sleep -Milliseconds 100 }
|
|
2428
|
+
if (-not [MWW]::IsWindowVisible([IntPtr]$script:msgWin)) { throw 'Message Window recovery unverified; do not repeat the toggle' }
|
|
2429
|
+
[void](Assert-StagedOpen $app 'read_output')
|
|
2430
|
+
if ([bool]$app.ActiveProject.IsModified -ne $modifiedBefore) { throw 'Project modified state changed during Message Window recovery' }
|
|
2431
|
+
Log 'read_output exposed hidden Message Window via verified native command 36554'
|
|
2432
|
+
}
|
|
2433
|
+
# Select the requested tab only after visibility recovery. This
|
|
2434
|
+
# keeps activation side effects from masking a hidden baseline.
|
|
2435
|
+
$w.Activate()
|
|
2436
|
+
Start-Sleep -Milliseconds 700
|
|
2437
|
+
|
|
2438
|
+
$script:best = $null
|
|
2439
|
+
$script:readableLists = @()
|
|
2440
|
+
$root = if ($script:msgWin) { [IntPtr]$script:msgWin } else { [IntPtr]::Zero }
|
|
2441
|
+
if ($root -ne [IntPtr]::Zero) {
|
|
2442
|
+
$cbList = [MWW+EnumWindowsProc]{
|
|
2443
|
+
param($k, $l2)
|
|
2444
|
+
$cls = New-Object System.Text.StringBuilder 64
|
|
2445
|
+
[void][MWW]::GetClassNameW($k, $cls, 64)
|
|
2446
|
+
if ($cls.ToString() -ne 'SysListView32') { return $true }
|
|
2447
|
+
if (-not [MWW]::IsWindowVisible($k)) { return $true }
|
|
2448
|
+
$rc = New-Object MWW+RECT
|
|
2449
|
+
if (-not [MWW]::GetWindowRect($k, [ref]$rc)) { return $true }
|
|
2450
|
+
# Inactive panes retain WS_VISIBLE but collapse to zero area.
|
|
2451
|
+
# Row count cannot identify the selected diagnostic pane.
|
|
2452
|
+
if ($rc.Right -le $rc.Left -or $rc.Bottom -le $rc.Top) { return $true }
|
|
2453
|
+
$script:readableLists += $k
|
|
2454
|
+
return $true
|
|
2455
|
+
}
|
|
2456
|
+
[void][MWW]::EnumChildWindows($root, $cbList, [IntPtr]::Zero)
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
if ($script:readableLists.Count -ne 1) {
|
|
2460
|
+
throw "Cannot uniquely identify the activated '$name' pane list ($($script:readableLists.Count) candidates); diagnostics unverified"
|
|
2461
|
+
} else {
|
|
2462
|
+
$script:best = $script:readableLists[0]
|
|
2463
|
+
$acc = [MWW]::GetAccessible([IntPtr]$script:best)
|
|
2464
|
+
if ($null -eq $acc) { throw "Activated '$name' pane is not accessible; diagnostics unverified" }
|
|
2465
|
+
$lines = New-Object System.Collections.ArrayList
|
|
2466
|
+
if ($null -ne $acc) {
|
|
2467
|
+
$n = [MWW]::ListRowCount([IntPtr]$script:best)
|
|
2468
|
+
for ($i = 1; $i -le [Math]::Min($n, $limit); $i++) {
|
|
2469
|
+
$t = ''
|
|
2470
|
+
try { $t = [string]$acc.accName($i) } catch { }
|
|
2471
|
+
if (-not $t) { throw "Cannot read row $i of '$name'; diagnostics incomplete" }
|
|
2472
|
+
[void]$lines.Add($t)
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
$ok = $true
|
|
2476
|
+
$data = [ordered]@{
|
|
2477
|
+
pane = $name
|
|
2478
|
+
count = $lines.Count
|
|
2479
|
+
lines = @($lines)
|
|
2480
|
+
note = $(if ($lines.Count -eq 0) { "the '$name' pane has no messages; inspect compile state separately" } else { $null })
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
# Close the IDE so a file-level code write can proceed. The write engine
|
|
2486
|
+
# refuses while the IDE holds the project, because the IDE's cached state
|
|
2487
|
+
# would overwrite an external edit.
|
|
2488
|
+
#
|
|
2489
|
+
# The PID is taken from the WINDOW, not from a process-name lookup:
|
|
2490
|
+
# process enumeration is unreliable in this environment and omits Mwt
|
|
2491
|
+
# entirely, which would make this report success while nothing closed.
|
|
2492
|
+
'close_ide' {
|
|
2493
|
+
if (-not (Get-IdeWindow)) {
|
|
2494
|
+
$ok = $true; $data = @{ closed = $true; window_remaining = $false }
|
|
2495
|
+
} else {
|
|
2496
|
+
$app = Connect-App
|
|
2497
|
+
$current = Get-OpenProjectPath $app
|
|
2498
|
+
Assert-CloseConsent $req $current 'closing the IDE'
|
|
2499
|
+
if ($current) { $app.ActiveProject.Save() }
|
|
2500
|
+
# WM_CLOSE permits native save/modal handling; never kill the process.
|
|
2501
|
+
[void][MWW]::PostMessage([IntPtr](Get-IdeWindow), 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
|
|
2502
|
+
$deadline = (Get-Date).AddSeconds(15)
|
|
2503
|
+
while ((Get-IdeWindow) -and (Get-Date) -lt $deadline) {
|
|
2504
|
+
if (@(Get-IdeDialogs).Count -gt 0) { break }
|
|
2505
|
+
Start-Sleep -Milliseconds 300
|
|
2506
|
+
}
|
|
2507
|
+
$still = [bool](Get-IdeWindow)
|
|
2508
|
+
$ok = -not $still
|
|
2509
|
+
$data = @{ closed = (-not $still); window_remaining = $still }
|
|
2510
|
+
if ($still) { $err = 'IDE close is pending; inspect ide_state and resolve the native dialog before editing' }
|
|
2511
|
+
if (-not $still) { $script:App = $null }
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
# Capture the IDE window's OWN content.
|
|
2516
|
+
#
|
|
2517
|
+
# CopyFromScreen copies a screen REGION, so whatever window happens to
|
|
2518
|
+
# be stacked over the IDE is what lands in the PNG - a "screenshot of
|
|
2519
|
+
# the IDE" that shows an unrelated application. PrintWindow asks the
|
|
2520
|
+
# window to render itself into a DC and is occlusion-independent, so
|
|
2521
|
+
# it is tried first; a blank bitmap (measured on some builds) falls
|
|
2522
|
+
# back to a screen copy taken with the IDE foregrounded.
|
|
2523
|
+
'screenshot' {
|
|
2524
|
+
$priorDpi = [IntPtr]::Zero
|
|
2525
|
+
if ($req.capture_cursor -eq $true) {
|
|
2526
|
+
$priorDpi = [MWW]::SetThreadDpiAwarenessContext([IntPtr](-4))
|
|
2527
|
+
if ($priorDpi -eq [IntPtr]::Zero) { throw 'Physical-pixel cursor capture context unavailable' }
|
|
2528
|
+
}
|
|
2529
|
+
try {
|
|
2530
|
+
$out = [string]$req.path
|
|
2531
|
+
if ([string]::IsNullOrWhiteSpace($out)) { throw 'screenshot requires "path"' }
|
|
2532
|
+
$ideW = Get-IdeWindow
|
|
2533
|
+
if (-not $ideW) { throw 'no running MotionWorks IDE window to capture' }
|
|
2534
|
+
$ideW = [IntPtr]$ideW
|
|
2535
|
+
|
|
2536
|
+
# Observing must not focus the main frame and dismiss an owned
|
|
2537
|
+
# graphical inline editor. No activation or implicit restoration.
|
|
2538
|
+
if ([MWW]::IsIconic($ideW)) { throw 'IDE is minimized; capture would require a window-state change. Restore deliberately before observing again' }
|
|
2539
|
+
$focusBefore = @([MWW]::CaptureFocus())
|
|
2540
|
+
$cursorBefore = $null
|
|
2541
|
+
$cursorBytes = $null; $cursorPath = $null
|
|
2542
|
+
if ($req.capture_cursor -eq $true) { $cursorBefore = [MWW]::CaptureCursor() }
|
|
2543
|
+
|
|
2544
|
+
Add-Type -AssemblyName System.Drawing
|
|
2545
|
+
$rc = New-Object MWW+RECT
|
|
2546
|
+
if (-not [MWW]::GetWindowRect($ideW, [ref]$rc)) { throw 'GetWindowRect failed' }
|
|
2547
|
+
$w = $rc.Right - $rc.Left
|
|
2548
|
+
$h = $rc.Bottom - $rc.Top
|
|
2549
|
+
if ($w -le 0 -or $h -le 0) { throw "unusable window rect ${w}x${h}" }
|
|
2550
|
+
|
|
2551
|
+
$dir = Split-Path -Parent $out
|
|
2552
|
+
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
|
|
2553
|
+
|
|
2554
|
+
# True when every sampled pixel has the same colour, i.e. the
|
|
2555
|
+
# capture produced a flat image rather than the window.
|
|
2556
|
+
function Test-Blank([System.Drawing.Bitmap]$b) {
|
|
2557
|
+
$first = $b.GetPixel(0, 0).ToArgb()
|
|
2558
|
+
for ($y = 1; $y -lt 6; $y++) {
|
|
2559
|
+
for ($x = 1; $x -lt 6; $x++) {
|
|
2560
|
+
$px = [int]($b.Width * $x / 6); $py = [int]($b.Height * $y / 6)
|
|
2561
|
+
if ($b.GetPixel($px, $py).ToArgb() -ne $first) { return $false }
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
return $true
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
$method = $null
|
|
2568
|
+
# Native GDI cursor drawing does not populate an ARGB bitmap's
|
|
2569
|
+
# alpha channel. RGB keeps a successfully drawn glyph visible
|
|
2570
|
+
# in the saved PNG rather than silently transparent.
|
|
2571
|
+
$bmp = if ($req.capture_cursor -eq $true) {
|
|
2572
|
+
New-Object System.Drawing.Bitmap $w, $h, ([System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
|
|
2573
|
+
} else { New-Object System.Drawing.Bitmap $w, $h }
|
|
2574
|
+
try {
|
|
2575
|
+
foreach ($flags in @(2, 0)) { # PW_RENDERFULLCONTENT, then plain
|
|
2576
|
+
if ($method) { break }
|
|
2577
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
2578
|
+
$hdc = $g.GetHdc()
|
|
2579
|
+
$rendered = $false
|
|
2580
|
+
try { $rendered = [MWW]::PrintWindow($ideW, $hdc, [uint32]$flags) }
|
|
2581
|
+
finally { $g.ReleaseHdc($hdc); $g.Dispose() }
|
|
2582
|
+
if ($rendered -and -not (Test-Blank $bmp)) { $method = "printwindow($flags)" }
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
if (-not $method) {
|
|
2586
|
+
if ([MWW]::GetAncestor($focusBefore[0], 3) -ne $ideW) {
|
|
2587
|
+
throw 'Own-window rendering failed and the IDE is not the foreground owner; screen-region capture refused without changing focus'
|
|
2588
|
+
}
|
|
2589
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
2590
|
+
$g.CopyFromScreen($rc.Left, $rc.Top, 0, 0, (New-Object System.Drawing.Size $w, $h))
|
|
2591
|
+
$g.Dispose()
|
|
2592
|
+
$method = 'screen-region'
|
|
2593
|
+
}
|
|
2594
|
+
$cursorEvidence = $null
|
|
2595
|
+
if ($null -ne $cursorBefore) {
|
|
2596
|
+
$visible = ($cursorBefore[0] -band 1) -ne 0
|
|
2597
|
+
$overIde = $cursorBefore[4] -eq $ideW.ToInt64() -and $cursorBefore[2] -ge $rc.Left -and $cursorBefore[2] -lt $rc.Right -and $cursorBefore[3] -ge $rc.Top -and $cursorBefore[3] -lt $rc.Bottom
|
|
2598
|
+
$cursorEvidence = [ordered]@{ visible=$visible; over_ide=$overIde; rendered=$false; screen_x=[int]$cursorBefore[2]; screen_y=[int]$cursorBefore[3]; window_x=([int]$cursorBefore[2]-$rc.Left); window_y=([int]$cursorBefore[3]-$rc.Top) }
|
|
2599
|
+
if ($visible -and $overIde) {
|
|
2600
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp); $hdc = $g.GetHdc()
|
|
2601
|
+
try { $hotspot = [MWW]::DrawCapturedCursor($hdc,$cursorBefore,$rc.Left,$rc.Top) }
|
|
2602
|
+
finally { $g.ReleaseHdc($hdc); $g.Dispose() }
|
|
2603
|
+
$cursorEvidence.rendered=$true
|
|
2604
|
+
$cursorEvidence.hotspot_x=$hotspot[0]; $cursorEvidence.hotspot_y=$hotspot[1]
|
|
2605
|
+
# Also show the same native glyph against light and dark
|
|
2606
|
+
# backgrounds: XOR/white cursors can vanish on the canvas.
|
|
2607
|
+
$cursorPath = [IO.Path]::ChangeExtension($out, 'cursor.png')
|
|
2608
|
+
$tileWidth = $hotspot[2]+16; $tileHeight = $hotspot[3]+16
|
|
2609
|
+
$cursorBmp = New-Object System.Drawing.Bitmap ($tileWidth*2), $tileHeight, ([System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
|
|
2610
|
+
$cg = [System.Drawing.Graphics]::FromImage($cursorBmp)
|
|
2611
|
+
try {
|
|
2612
|
+
$cg.Clear([System.Drawing.Color]::White)
|
|
2613
|
+
$cg.FillRectangle([System.Drawing.Brushes]::Black,$tileWidth,0,$tileWidth,$tileHeight)
|
|
2614
|
+
$chdc=$cg.GetHdc()
|
|
2615
|
+
try {
|
|
2616
|
+
[MWW]::DrawCapturedCursor($chdc,$cursorBefore,([int]$cursorBefore[2]-$hotspot[0]-8),([int]$cursorBefore[3]-$hotspot[1]-8)) | Out-Null
|
|
2617
|
+
[MWW]::DrawCapturedCursor($chdc,$cursorBefore,([int]$cursorBefore[2]-$hotspot[0]-8-$tileWidth),([int]$cursorBefore[3]-$hotspot[1]-8)) | Out-Null
|
|
2618
|
+
} finally { $cg.ReleaseHdc($chdc) }
|
|
2619
|
+
$visiblePixels=$false
|
|
2620
|
+
for ($cy=0;$cy -lt $tileHeight -and -not $visiblePixels;$cy++) {
|
|
2621
|
+
for ($cx=0;$cx -lt ($tileWidth*2);$cx++) {
|
|
2622
|
+
$expectedColor=if($cx -lt $tileWidth){[System.Drawing.Color]::White.ToArgb()}else{[System.Drawing.Color]::Black.ToArgb()}
|
|
2623
|
+
if($cursorBmp.GetPixel($cx,$cy).ToArgb() -ne $expectedColor){$visiblePixels=$true;break}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
$cursorEvidence.glyph_visible=$visiblePixels
|
|
2627
|
+
$stream=New-Object IO.MemoryStream
|
|
2628
|
+
try { $cursorBmp.Save($stream,[System.Drawing.Imaging.ImageFormat]::Png); $cursorBytes=$stream.ToArray() }
|
|
2629
|
+
finally { $stream.Dispose() }
|
|
2630
|
+
} finally { $cg.Dispose(); $cursorBmp.Dispose() }
|
|
2631
|
+
$cursorEvidence.path=$cursorPath
|
|
2632
|
+
}
|
|
2633
|
+
$cursorAfter = [MWW]::CaptureCursor()
|
|
2634
|
+
if (($cursorBefore -join ':') -ne ($cursorAfter -join ':')) {
|
|
2635
|
+
throw 'Cursor position, shape, visibility or owning window changed during capture; reobserve before input'
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
$focusAfter = @([MWW]::CaptureFocus())
|
|
2639
|
+
$beforeKey = ($focusBefore | ForEach-Object { $_.ToInt64() }) -join ':'
|
|
2640
|
+
$afterKey = ($focusAfter | ForEach-Object { $_.ToInt64() }) -join ':'
|
|
2641
|
+
if ($beforeKey -ne $afterKey) { throw 'Foreground/active/focused window changed during capture; image acceptance unverified. Reobserve before input' }
|
|
2642
|
+
$bmp.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
2643
|
+
if ($null -ne $cursorBytes) { [IO.File]::WriteAllBytes($cursorPath,$cursorBytes) }
|
|
2644
|
+
} finally { $bmp.Dispose() }
|
|
2645
|
+
|
|
2646
|
+
# Which route was taken is a diagnostic, so it goes to the log rather
|
|
2647
|
+
# than the response: the registered output schema for this tool is an
|
|
2648
|
+
# exact shape (additionalProperties: false) and a running DSH keeps
|
|
2649
|
+
# the schema it loaded, so an extra field fails output validation.
|
|
2650
|
+
Log "screenshot $out via $method (${w}x${h}); foreground/active/focus preserved $beforeKey"
|
|
2651
|
+
|
|
2652
|
+
$ok = $true
|
|
2653
|
+
$data = [ordered]@{ path = $out; width = $w; height = $h }
|
|
2654
|
+
if ($null -ne $cursorEvidence) { $data.cursor = $cursorEvidence }
|
|
2655
|
+
} finally {
|
|
2656
|
+
if ($priorDpi -ne [IntPtr]::Zero) { [MWW]::SetThreadDpiAwarenessContext($priorDpi) | Out-Null }
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
default {
|
|
2661
|
+
throw "unknown verb '$verb' (allowed: ping, status, start_ide, open, close_ide, trial_state, dismiss_trial, ide_state, answer_dialog, pous, variables, compile_state, make, build, patch, worksheet, datatypes, output_windows, activate_output, command_id, command, feature_state, screenshot, read_output, save, stop)"
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
if ($verb -in @('make', 'build', 'rebuild', 'patch', 'worksheet', 'datatypes')) {
|
|
2666
|
+
$app = Connect-App
|
|
2667
|
+
[void](Assert-StagedOpen $app $verb)
|
|
2668
|
+
|
|
2669
|
+
$compiledBefore = [bool]$app.ActiveProject.IsCompiled
|
|
2670
|
+
$sw = [Diagnostics.Stopwatch]::StartNew()
|
|
2671
|
+
$accepted = $false; $lastErr = $null
|
|
2672
|
+
# A compile is refused while the compiler is busy ("Operation not
|
|
2673
|
+
# possible while compiler is running."), so retry rather than sleep a
|
|
2674
|
+
# fixed time.
|
|
2675
|
+
while ($sw.Elapsed.TotalSeconds -lt 120) {
|
|
2676
|
+
try {
|
|
2677
|
+
if ($verb -eq 'rebuild') {
|
|
2678
|
+
$cid = [int]$app.GetIdOfCommand('adeCmdBuildRebuildProject')
|
|
2679
|
+
if ($cid -ne 36570) { throw 'This IDE does not expose the verified Rebuild command' }
|
|
2680
|
+
$window = Get-IdeWindow
|
|
2681
|
+
if (-not $window) { throw 'No IDE window for Rebuild' }
|
|
2682
|
+
$accepted = [MWW]::PostMessage([IntPtr]$window, 0x0111, [IntPtr]$cid, [IntPtr]::Zero)
|
|
2683
|
+
if (-not $accepted) { throw 'Rebuild menu request was not posted' }
|
|
2684
|
+
} else { $app.ActiveProject.Compile($mode); $accepted = $true }
|
|
2685
|
+
break
|
|
2686
|
+
}
|
|
2687
|
+
catch { $lastErr = $_.Exception.Message; Start-Sleep -Milliseconds 750 }
|
|
2688
|
+
}
|
|
2689
|
+
if (-not $accepted) { throw "Compile($mode) never accepted within 120s; last error: $lastErr" }
|
|
2690
|
+
|
|
2691
|
+
# A cached IsCompiled=true is not proof that this request completed.
|
|
2692
|
+
# Only a false-to-true transition observed for this invocation settles it.
|
|
2693
|
+
$isCompiled = $false
|
|
2694
|
+
$observedPending = -not $compiledBefore
|
|
2695
|
+
$alreadyUpToDate = $false
|
|
2696
|
+
$settle = [Diagnostics.Stopwatch]::StartNew()
|
|
2697
|
+
$swPoll = [Diagnostics.Stopwatch]::StartNew()
|
|
2698
|
+
while ($swPoll.Elapsed.TotalSeconds -lt 90) {
|
|
2699
|
+
Start-Sleep -Milliseconds 400
|
|
2700
|
+
try {
|
|
2701
|
+
$currentCompiled = [bool]$app.ActiveProject.IsCompiled
|
|
2702
|
+
if (-not $currentCompiled) { $observedPending = $true }
|
|
2703
|
+
if ($currentCompiled -and $observedPending) { $isCompiled = $true; break }
|
|
2704
|
+
# A project that was ALREADY compiled and records no modification has nothing
|
|
2705
|
+
# to compile, so IsCompiled never drops and the transition test above can never
|
|
2706
|
+
# be satisfied: a successful no-op Make waited the full 90 seconds and reported
|
|
2707
|
+
# "completion unverified" while the project was in fact compiled and unmodified.
|
|
2708
|
+
# Holding the condition for two seconds separates a genuine no-op from the
|
|
2709
|
+
# instant before a real compile flips the flag.
|
|
2710
|
+
if ($verb -eq 'make' -and $currentCompiled -and $compiledBefore -and -not [bool]$app.ActiveProject.IsModified) {
|
|
2711
|
+
if ($settle.Elapsed.TotalSeconds -ge 2) {
|
|
2712
|
+
$isCompiled = $true; $alreadyUpToDate = $true; break
|
|
2713
|
+
}
|
|
2714
|
+
} else { $settle.Restart() }
|
|
2715
|
+
} catch { }
|
|
2716
|
+
}
|
|
2717
|
+
$settled = $isCompiled
|
|
2718
|
+
$stalled = -not $settled
|
|
2719
|
+
|
|
2720
|
+
# Never restore disk files underneath the running IDE. An offline
|
|
2721
|
+
# transaction journal is the recovery source after a verified close.
|
|
2722
|
+
|
|
2723
|
+
$ok = $true
|
|
2724
|
+
$data = [ordered]@{
|
|
2725
|
+
mode = $verb
|
|
2726
|
+
compile_type = $mode
|
|
2727
|
+
accepted = $accepted
|
|
2728
|
+
settled = $settled
|
|
2729
|
+
stalled = $stalled
|
|
2730
|
+
is_compiled = $isCompiled
|
|
2731
|
+
is_modified = $(try { [bool]$app.ActiveProject.IsModified } catch { $null })
|
|
2732
|
+
elapsed_s = [Math]::Round($sw.Elapsed.TotalSeconds, 1)
|
|
2733
|
+
evidence_kind = $(if ($alreadyUpToDate) { 'already_up_to_date' } elseif ($isCompiled -and $observedPending) { 'observed_compile_transition' } else { 'completion_unverified' })
|
|
2734
|
+
fresh_compile = ($isCompiled -and $observedPending -and -not $alreadyUpToDate)
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
catch {
|
|
2739
|
+
$ok = $false
|
|
2740
|
+
$err = $_.Exception.Message
|
|
2741
|
+
# Before reporting a failure, find out whether the IDE is simply waiting for
|
|
2742
|
+
# a button press. The automation API goes quiet while a modal dialog is up,
|
|
2743
|
+
# so a bare failure reads as "the IDE has closed" - and the agent then
|
|
2744
|
+
# abandons a perfectly healthy IDE, or worse, relaunches it. Naming the
|
|
2745
|
+
# dialog here means EVERY failing tool call explains itself, with no
|
|
2746
|
+
# discipline required from the agent.
|
|
2747
|
+
try {
|
|
2748
|
+
$dlgs = @(Get-IdeDialogs)
|
|
2749
|
+
if ($dlgs.Count -gt 0) {
|
|
2750
|
+
$desc = ($dlgs | ForEach-Object {
|
|
2751
|
+
$lbl = ($_.buttons | ForEach-Object { $_.label }) -join ', '
|
|
2752
|
+
"dialog $($_.handle) says: `"$($_.message)`" [buttons: $lbl]"
|
|
2753
|
+
}) -join ' || '
|
|
2754
|
+
$err = "$err`n`nNOTE: the IDE is NOT closed. It is blocked by a modal dialog. $desc`nCall mw_ide_state to see it (if available), or mw_ide_dialog to answer it; otherwise answer it by hand and retry."
|
|
2755
|
+
}
|
|
2756
|
+
} catch { }
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
Write-Result $id $verb $ok $data $err
|
|
2760
|
+
Remove-Item -Force $reqPath -ErrorAction SilentlyContinue
|
|
2761
|
+
Log "res id=$id ok=$ok err=$err"
|
|
2762
|
+
Start-Sleep -Milliseconds 50
|
|
2763
|
+
}
|