answer42 0.5.2__tar.gz → 0.5.4__tar.gz
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.
- {answer42-0.5.2 → answer42-0.5.4}/PKG-INFO +1 -1
- {answer42-0.5.2 → answer42-0.5.4}/docs/architecture.md +6 -1
- {answer42-0.5.2 → answer42-0.5.4}/docs/installation.md +12 -0
- {answer42-0.5.2 → answer42-0.5.4}/pyproject.toml +1 -1
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/assets/MCPTestClient.cf +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/assets/MCPTestManager.cf +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/runtime.py +35 -13
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/server.py +129 -3
- answer42-0.5.4/src/mcp_1c/win_desktop.py +399 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/window_control.py +66 -5
- {answer42-0.5.2 → answer42-0.5.4}/.gitignore +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/LICENSE +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/README.md +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/credentials.example.json +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/docs/agent-installation.md +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/docs/assets/answer42-logo.png +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/docs/assets/platform42-logo.svg +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/scripts/build_cf.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/scripts/build_pages.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/scripts/e2e_http_smoke.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/scripts/e2e_stable.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/scripts/openclaw_mcp_autoreload.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/scripts/rag_cli.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/scripts/read_webclient_start_info.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/cf/ConfigDumpInfo.xml +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/cf/Configuration.xml +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/cf/DataProcessors/MCPTestManager/Ext/ObjectModule.bsl +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form/Module.bsl" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form.xml" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260.xml" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/cf/DataProcessors/MCPTestManager.xml +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/cf/Ext/ManagedApplicationModule.bsl +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/Catalogs//320/237/320/241_/320/222/320/273/320/260/320/264/320/265/320/273/320/265/321/206.xml" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/Catalogs//320/237/320/241_/320/241/320/277/321/200/320/260/320/262/320/276/321/207/320/275/320/270/320/2721.xml" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/ConfigDumpInfo.xml +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/Configuration.xml +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Ext/ManagerModule.bsl" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Ext/ObjectModule.bsl" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Templates//320/236/321/201/320/275/320/276/320/262/320/275/320/260/321/217/320/241/321/205/320/265/320/274/320/260/320/232/320/276/320/274/320/277/320/276/320/275/320/276/320/262/320/272/320/270/320/224/320/260/320/275/320/275/321/213/321/205/Ext/Template.xml" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Templates//320/236/321/201/320/275/320/276/320/262/320/275/320/260/321/217/320/241/321/205/320/265/320/274/320/260/320/232/320/276/320/274/320/277/320/276/320/275/320/276/320/262/320/272/320/270/320/224/320/260/320/275/320/275/321/213/321/205.xml" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262.xml" +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/__init__.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/assets/__init__.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/auth.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/bridge.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/credentials.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/os_support.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/platform.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/protocol.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/__init__.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/detect.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/dump.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/model.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/parsers.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/service.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/store.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/summary_backend.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/summary_vector.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/recorder.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/release_helper.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/skill_installer.py +0 -0
- {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/webclient_probe.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: answer42
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.4
|
|
4
4
|
Summary: Answer42 — The Answer to Life, Universe, and 1C — UI Driver. MCP-powered 1C:Enterprise UI automation: click, fill, navigate, test, and introspect managed forms through the test-client API
|
|
5
5
|
Author: Marvin (AI Assistant), 42Clouds, and contributors
|
|
6
6
|
Author-email: "Kosolapov Stanislav (proDOOMman)" <prodoomman@gmail.com>
|
|
@@ -109,7 +109,11 @@ class SessionState:
|
|
|
109
109
|
|
|
110
110
|
### `mcp_1c.window_control`
|
|
111
111
|
|
|
112
|
-
OS-specific слой окна тест-клиента: Linux (`wmctrl`/`xdotool`/`python-xlib`, `xwininfo`), Windows (WinAPI). Допускается для подготовки геометрии окна и скриншотов (resize/maximize/geometry capture). Ввод, клики и результат действий 1С не определяются через X11/WinAPI; автоматизация 1С идёт через API клиента тестирования.
|
|
112
|
+
OS-specific слой окна тест-клиента: Linux (`wmctrl`/`xdotool`/`python-xlib`, `xwininfo`), Windows (WinAPI; для скрытых рабочих столов — через `mcp_1c.win_desktop`). Допускается для подготовки геометрии окна и скриншотов (resize/maximize/geometry capture). Ввод, клики и результат действий 1С не определяются через X11/WinAPI; автоматизация 1С идёт через API клиента тестирования.
|
|
113
|
+
|
|
114
|
+
### `mcp_1c.win_desktop`
|
|
115
|
+
|
|
116
|
+
Windows-аналог Xvfb: скрытые рабочие столы (`CreateDesktopW` в `WinSta0`), `DesktopPopen` — `subprocess.Popen` с `STARTUPINFO.lpDesktop` (CPython его не поддерживает), перечисление окон рабочего стола, `PrintWindow`-скриншоты и maximize из вспомогательного потока, привязанного к рабочему столу через `SetThreadDesktop`. Включается `ONEC_MCP_WINDOWS_DESKTOP=hidden`; тогда `session.display`/`client_display` содержат имена рабочих столов.
|
|
113
117
|
|
|
114
118
|
### `mcp_1c.runtime`
|
|
115
119
|
|
|
@@ -233,6 +237,7 @@ large remote infobases.
|
|
|
233
237
|
| `ONEC_MCP_WS_HOST` | WebSocket host | `127.0.0.1` |
|
|
234
238
|
| `ONEC_MCP_WS_PORT` | WebSocket port | `8765` |
|
|
235
239
|
| `ONEC_MCP_REQUEST_TIMEOUT` | Таймаут запроса, сек | `60` |
|
|
240
|
+
| `ONEC_MCP_WINDOWS_DESKTOP` | Windows: `hidden` — запускать менеджер и клиент 1С на скрытых рабочих столах сессии; `current` — на текущем рабочем столе | `current` |
|
|
236
241
|
| `ONEC_MCP_DATA_DIR` | Рабочая директория | OS tempdir + `answer42` (`/tmp/answer42` on Linux, `%TEMP%\answer42` on Windows) |
|
|
237
242
|
| `MCP_1C_RAG_DB` | Путь к RAG SQLite | `build/rag/onec-rag.sqlite` |
|
|
238
243
|
| `ONEC_MCP_DISABLE_RAG` | Отключить встроенный RAG (`1/true/yes/on`) | пусто |
|
|
@@ -52,6 +52,18 @@ python scripts/build_cf.py src/client_cf build/MCPTestClient.cf
|
|
|
52
52
|
|
|
53
53
|
На Linux без серверных компонентов Answer42 может запускать менеджер и клиент напрямую через файловую базу (`/F`). На Windows это также поддерживается; запускайте MCP из интерактивной пользовательской сессии, потому что UI automation и screenshots требуют desktop session.
|
|
54
54
|
|
|
55
|
+
### Фоновый режим на Windows (скрытый рабочий стол)
|
|
56
|
+
|
|
57
|
+
По умолчанию на Windows менеджер и клиент тестирования открываются на текущем рабочем столе пользователя. Чтобы они работали в фоне и не мешали работе (аналог Xvfb на Linux), задайте:
|
|
58
|
+
|
|
59
|
+
```powershell
|
|
60
|
+
$env:ONEC_MCP_WINDOWS_DESKTOP = "hidden"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
или в MCP-конфиге `"env": {"ONEC_MCP_WINDOWS_DESKTOP": "hidden"}`. Для каждой сессии Answer42 создаёт в `WinSta0` два скрытых рабочих стола (`Answer42-<scope>-<session>-manager` и `...-client`) и запускает `1cv8c` на них через `STARTUPINFO.lpDesktop`. Окна 1С не появляются на экране, не забирают фокус и не видны в панели задач. Screenshots снимаются через `PrintWindow`, auto-maximize работает на скрытом рабочем столе.
|
|
64
|
+
|
|
65
|
+
Ограничения: процесс MCP по-прежнему должен работать в интерактивной сессии пользователя (скрытый рабочий стол принадлежит его window station); Windows service в session 0 не подходит. Посмотреть окна на скрытом рабочем столе можно только через `screenshot`.
|
|
66
|
+
|
|
55
67
|
## Рекомендации для агентов
|
|
56
68
|
|
|
57
69
|
Answer42 больше не поставляет bundled skills. Рабочие рекомендации находятся в
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "answer42"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.4"
|
|
4
4
|
description = "Answer42 — The Answer to Life, Universe, and 1C — UI Driver. MCP-powered 1C:Enterprise UI automation: click, fill, navigate, test, and introspect managed forms through the test-client API"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
Binary file
|
|
Binary file
|
|
@@ -22,7 +22,7 @@ from dataclasses import dataclass, field
|
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
from typing import Any
|
|
24
24
|
|
|
25
|
-
from . import os_support
|
|
25
|
+
from . import os_support, win_desktop
|
|
26
26
|
|
|
27
27
|
LOGGER = logging.getLogger(__name__)
|
|
28
28
|
|
|
@@ -143,19 +143,30 @@ def require_xvfb() -> str:
|
|
|
143
143
|
return xvfb_bin
|
|
144
144
|
|
|
145
145
|
|
|
146
|
-
def session_displays(display_offset: int) -> tuple[str, str, bool]:
|
|
146
|
+
def session_displays(display_offset: int, session_key: str = "") -> tuple[str, str, bool]:
|
|
147
147
|
"""Choose inherited desktop display or two Answer42-owned Xvfb displays.
|
|
148
148
|
|
|
149
149
|
Returns ``(manager_display, client_display, starts_xvfb)``. A live X11
|
|
150
150
|
display is deliberately shared by manager and client: it is the caller's
|
|
151
151
|
explicit request to run visibly on that desktop. Otherwise the historical
|
|
152
152
|
isolated two-Xvfb setup is retained for headless operation.
|
|
153
|
+
|
|
154
|
+
On Windows the "display" is either empty (the user's current desktop) or,
|
|
155
|
+
with ``ONEC_MCP_WINDOWS_DESKTOP=hidden``, the names of two hidden desktops
|
|
156
|
+
for manager and client; they are created lazily when 1C is launched.
|
|
153
157
|
"""
|
|
154
158
|
desktop_display = inherited_x11_display()
|
|
155
159
|
if desktop_display:
|
|
156
160
|
return desktop_display, desktop_display, False
|
|
157
161
|
if os_support.IS_WINDOWS:
|
|
158
|
-
|
|
162
|
+
if not win_desktop.hidden_desktop_enabled():
|
|
163
|
+
return "", "", False
|
|
164
|
+
key = session_key or str(display_offset)
|
|
165
|
+
return (
|
|
166
|
+
win_desktop.session_desktop_name(key, "manager"),
|
|
167
|
+
win_desktop.session_desktop_name(key, "client"),
|
|
168
|
+
False,
|
|
169
|
+
)
|
|
159
170
|
require_xvfb()
|
|
160
171
|
manager_display = find_free_display(DEFAULT_DISPLAY_START + display_offset)
|
|
161
172
|
# The first X socket does not exist until start_xvfb() is called, so derive
|
|
@@ -186,6 +197,23 @@ def stop_xvfb(proc: subprocess.Popen | None) -> None:
|
|
|
186
197
|
kill_process(proc, "Xvfb")
|
|
187
198
|
|
|
188
199
|
|
|
200
|
+
def release_display(display: str) -> None:
|
|
201
|
+
"""Release an Answer42-owned hidden Windows desktop; no-op for X11 displays."""
|
|
202
|
+
if win_desktop.is_hidden_desktop_name(display):
|
|
203
|
+
win_desktop.release_desktop(display)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _popen_on_display(argv: list[str], display: str, **kwargs: Any) -> subprocess.Popen:
|
|
207
|
+
"""Start a 1C process on the given X11 display or hidden Windows desktop."""
|
|
208
|
+
env = kwargs.pop("env", None) or os.environ.copy()
|
|
209
|
+
if win_desktop.is_hidden_desktop_name(display):
|
|
210
|
+
win_desktop.ensure_desktop(display)
|
|
211
|
+
return win_desktop.DesktopPopen(argv, desktop=display, env=env, **kwargs)
|
|
212
|
+
if display:
|
|
213
|
+
env["DISPLAY"] = display
|
|
214
|
+
return subprocess.Popen(argv, env=env, **kwargs)
|
|
215
|
+
|
|
216
|
+
|
|
189
217
|
def is_port_listening(host: str, port: int, timeout: float = 1.0) -> bool:
|
|
190
218
|
"""Return True if something accepts TCP connections on host:port."""
|
|
191
219
|
try:
|
|
@@ -534,16 +562,13 @@ def _start_thin_test_manager_argv(
|
|
|
534
562
|
argv = add_1c_out_args_before_testmanager(argv, onec_out_path)
|
|
535
563
|
with log_path.open("ab", buffering=0) as log_handle:
|
|
536
564
|
LOGGER.info("Starting thin test manager: %s", " ".join(argv))
|
|
537
|
-
|
|
538
|
-
if display:
|
|
539
|
-
env["DISPLAY"] = display
|
|
540
|
-
proc = subprocess.Popen(
|
|
565
|
+
proc = _popen_on_display(
|
|
541
566
|
argv,
|
|
567
|
+
display,
|
|
542
568
|
stdout=log_handle,
|
|
543
569
|
stderr=log_handle,
|
|
544
570
|
stdin=subprocess.DEVNULL,
|
|
545
571
|
**os_support.subprocess_creation_kwargs(),
|
|
546
|
-
env=env,
|
|
547
572
|
)
|
|
548
573
|
proc.answer42_stdout_log_path = str(log_path) # type: ignore[attr-defined]
|
|
549
574
|
proc.answer42_out_log_path = str(onec_out_path) # type: ignore[attr-defined]
|
|
@@ -662,16 +687,13 @@ def start_test_client(
|
|
|
662
687
|
stdout_log_path = log_dir / log_name
|
|
663
688
|
with stdout_log_path.open("ab", buffering=0) as log_handle:
|
|
664
689
|
LOGGER.info("Starting test client: %s", " ".join(argv))
|
|
665
|
-
|
|
666
|
-
if display:
|
|
667
|
-
env["DISPLAY"] = display
|
|
668
|
-
proc = subprocess.Popen(
|
|
690
|
+
proc = _popen_on_display(
|
|
669
691
|
argv,
|
|
692
|
+
display,
|
|
670
693
|
stdout=log_handle,
|
|
671
694
|
stderr=log_handle,
|
|
672
695
|
stdin=subprocess.DEVNULL,
|
|
673
696
|
**os_support.subprocess_creation_kwargs(),
|
|
674
|
-
env=env,
|
|
675
697
|
)
|
|
676
698
|
proc.answer42_stdout_log_path = str(stdout_log_path) # type: ignore[attr-defined]
|
|
677
699
|
proc.answer42_argv = argv # type: ignore[attr-defined]
|
|
@@ -31,7 +31,7 @@ from mcp.server.mcpserver.resources import FileResource
|
|
|
31
31
|
from mcp.server.subscriptions import ToolsListChanged
|
|
32
32
|
from starlette.responses import FileResponse, PlainTextResponse
|
|
33
33
|
|
|
34
|
-
from . import __version__, os_support, release_helper, runtime, skill_installer
|
|
34
|
+
from . import __version__, os_support, release_helper, runtime, skill_installer, win_desktop
|
|
35
35
|
from . import platform as _platform
|
|
36
36
|
from .auth import build_auth_settings, build_token_verifier, load_static_token_config
|
|
37
37
|
from .auth import current_account_id as _current_account_id
|
|
@@ -1614,6 +1614,8 @@ async def _cleanup_failed_start_session(sess: SessionState, reason: str) -> None
|
|
|
1614
1614
|
pass
|
|
1615
1615
|
runtime.stop_xvfb(sess.client_xvfb_process)
|
|
1616
1616
|
runtime.stop_xvfb(sess.xvfb_process)
|
|
1617
|
+
runtime.release_display(sess.client_display)
|
|
1618
|
+
runtime.release_display(sess.display)
|
|
1617
1619
|
_cleanup_stale_session_processes(sess, reason)
|
|
1618
1620
|
if sess.data_dir.exists():
|
|
1619
1621
|
shutil.rmtree(sess.data_dir, ignore_errors=True)
|
|
@@ -4688,7 +4690,9 @@ async def start_session(
|
|
|
4688
4690
|
with _port_allocation_lock():
|
|
4689
4691
|
port_offset = _stable_session_port_offset(sid)
|
|
4690
4692
|
display_offset = port_offset // _PORT_BAND_SIZE
|
|
4691
|
-
display, client_display, starts_xvfb = runtime.session_displays(
|
|
4693
|
+
display, client_display, starts_xvfb = runtime.session_displays(
|
|
4694
|
+
display_offset, session_key=f"{SESSION_SCOPE_ID or 'local'}-{sid}"
|
|
4695
|
+
)
|
|
4692
4696
|
ws_port = _find_session_port(runtime.DEFAULT_WS_PORT, port_offset, sub_offset=0)
|
|
4693
4697
|
ibsrv_port = _find_session_port(runtime.DEFAULT_IBSRV_HTTP_PORT, port_offset, sub_offset=0)
|
|
4694
4698
|
ibsrv_direct_regport = _find_session_port(runtime.DEFAULT_IBSRV_DIRECT_PORT, port_offset, sub_offset=0)
|
|
@@ -5053,6 +5057,8 @@ async def _stop_session_impl(session_id: str = DEFAULT_SESSION, clean_data: bool
|
|
|
5053
5057
|
stopped.append("client-xvfb")
|
|
5054
5058
|
runtime.stop_xvfb(sess.xvfb_process)
|
|
5055
5059
|
stopped.append("xvfb")
|
|
5060
|
+
runtime.release_display(sess.client_display)
|
|
5061
|
+
runtime.release_display(sess.display)
|
|
5056
5062
|
_cleanup_stale_session_processes(sess, "stop_session")
|
|
5057
5063
|
stopped.append("stale-processes")
|
|
5058
5064
|
if clean_data and sess.data_dir.exists():
|
|
@@ -7027,6 +7033,95 @@ def universal_report_workflow() -> str:
|
|
|
7027
7033
|
"""
|
|
7028
7034
|
|
|
7029
7035
|
|
|
7036
|
+
@mcp.prompt(
|
|
7037
|
+
name="event_log_workflow",
|
|
7038
|
+
title="Журнал регистрации 1С",
|
|
7039
|
+
description="Встроенный workflow просмотра, фильтрации, проверки и безопасной выгрузки Журнала регистрации 1С.",
|
|
7040
|
+
)
|
|
7041
|
+
def event_log_workflow() -> str:
|
|
7042
|
+
"""Return reusable instructions for the standard 1C Event Log form."""
|
|
7043
|
+
return """# Журнал регистрации 1С через Answer42
|
|
7044
|
+
|
|
7045
|
+
Используй этот workflow для расследования событий в стандартной обработке
|
|
7046
|
+
«Журнал регистрации». Журнал может быть недоступен без роли просмотра или в
|
|
7047
|
+
конкретной конфигурации: сначала найди объект через `rag_lookup_object`/
|
|
7048
|
+
`rag_query`, затем открой найденную app-навигацию. В УНФ 3.0 проверена ссылка
|
|
7049
|
+
`e1cib/app/Обработка.ЖурналРегистрации`.
|
|
7050
|
+
|
|
7051
|
+
## Безопасность и доказательства
|
|
7052
|
+
|
|
7053
|
+
- Журнал содержит персональные данные, имена пользователей, IP/порты, данные
|
|
7054
|
+
событий и комментарии. Не выгружай широкий журнал и не пересылай сырые записи,
|
|
7055
|
+
пока пользователь не подтвердил временной интервал и получателя.
|
|
7056
|
+
- Сначала сузь период, затем добавляй только необходимые отборы. Не меняй
|
|
7057
|
+
параметры регистрации событий и не удаляй журнал: workflow предназначен для
|
|
7058
|
+
чтения и контролируемого экспорта.
|
|
7059
|
+
- Не угадывай имена или состав полей: после открытия вызови
|
|
7060
|
+
`ui_tree(profile="navigation")`; перед сложным отбором используй
|
|
7061
|
+
`ui_tree(profile="full", include_hidden=true)` и RAG для формы отбора.
|
|
7062
|
+
|
|
7063
|
+
## Основной экран
|
|
7064
|
+
|
|
7065
|
+
В проверенной УНФ-форме есть:
|
|
7066
|
+
|
|
7067
|
+
- `КоличествоПоказываемыхСобытий` («Показывать») — ограничение числа строк;
|
|
7068
|
+
- `Критичность` («Важность») — быстрый уровень важности;
|
|
7069
|
+
- `ПредставлениеОтбора` — текст действующего отбора;
|
|
7070
|
+
- таблица `Журнал`, команды `Интервал`, `Установить отбор`, `Отключить отбор`,
|
|
7071
|
+
`Вывести список...`, `Выгрузить в файл...`, а также поиск.
|
|
7072
|
+
|
|
7073
|
+
Сначала зафиксируй текущие значения этих полей и количество/видимые колонки
|
|
7074
|
+
таблицы. Команды просмотра события и отбора по текущей колонке требуют выбранной
|
|
7075
|
+
строки/колонки; не вызывай их, пока не проверишь доступность через `ui_tree`.
|
|
7076
|
+
|
|
7077
|
+
## Интервал и отбор
|
|
7078
|
+
|
|
7079
|
+
1. Нажми `Интервал` для малого, явно согласованного периода и подтверди его.
|
|
7080
|
+
Повторно прочитай `ПредставлениеОтбора` и строки `Журнал`.
|
|
7081
|
+
2. Для составного отбора нажми `Установить отбор`. В стандартной форме
|
|
7082
|
+
`ОтборЖурналаРегистрации` сначала прочитай live UI; ожидаемые группы и поля:
|
|
7083
|
+
- **даты:** `ДатаНачала`, `ДатаОкончания`, `ВыбратьПериод`;
|
|
7084
|
+
- **основные:** `Важность`, `События`, `Пользователи`, `Сеансы`;
|
|
7085
|
+
- **приложение/компьютер:** `Приложения`, `Компьютеры`;
|
|
7086
|
+
- **разделение:** `ОбластиДанных`, `РазделениеДанныхСеанса`;
|
|
7087
|
+
- **сведения:** `Метаданные`, `Комментарий`, `Данные`,
|
|
7088
|
+
`ПредставлениеДанных` и при необходимости `ДанныеСписок`;
|
|
7089
|
+
- **транзакция:** `СтатусТранзакции`, `Транзакция`;
|
|
7090
|
+
- **сервер:** `РабочиеСерверы`, `ОсновныеIPПорты`, `ВспомогательныеIPПорты`.
|
|
7091
|
+
3. Списочные поля и флажки нельзя надежно заполнять строкой наугад. Открой
|
|
7092
|
+
dropdown/стандартный выбор, прочитай доступные значения, выбери только явно
|
|
7093
|
+
заданные пользователем и затем снова проверь форму. Для «Важность» и
|
|
7094
|
+
«Статус транзакции» форма использует отдельные check/value-поля — проверь
|
|
7095
|
+
реальные доступные элементы перед изменением.
|
|
7096
|
+
4. Нажми `Применить`, дождись обновления и проверь итоговую строку отбора,
|
|
7097
|
+
число строк и несколько строк результата через `table_rows(name="Журнал")`.
|
|
7098
|
+
5. `Отключить отбор` снимает текущий отбор целиком. Используй его только по
|
|
7099
|
+
явной просьбе; для нового расследования лучше открывай форму отбора и меняй
|
|
7100
|
+
только нужные критерии.
|
|
7101
|
+
|
|
7102
|
+
## Анализ строк
|
|
7103
|
+
|
|
7104
|
+
- Для текстового поиска по уже загруженным строкам используй `dynamic_list_find`
|
|
7105
|
+
или кнопку «Расширенный поиск», но не подменяй им серверный отбор: поиск
|
|
7106
|
+
работает по представленным данным и не заменяет интервал/критерии журнала.
|
|
7107
|
+
- Перед «Открыть» или «Открыть данные для просмотра» выбери конкретную строку,
|
|
7108
|
+
затем прочитай открывшуюся форму через `ui_tree`. Данные события могут быть
|
|
7109
|
+
объёмными и чувствительными; извлекай только минимально нужный фрагмент.
|
|
7110
|
+
- Для воспроизводимого результата укажи: период, все критерии, лимит
|
|
7111
|
+
«Показывать», количество строк и идентификаторы/даты проверенных событий.
|
|
7112
|
+
|
|
7113
|
+
## Выгрузка
|
|
7114
|
+
|
|
7115
|
+
- `Вывести список...` применяй для табличного представления текущей выборки;
|
|
7116
|
+
проверяй результат структурными средствами `tabular_documents` и
|
|
7117
|
+
`tabular_document_text`, а большую выборку сохраняй через `tabular_document_save`.
|
|
7118
|
+
- `Выгрузить в файл...` предназначена для передачи в техподдержку. Это может
|
|
7119
|
+
включать чувствительные данные: сначала потребуй явное подтверждение объёма и
|
|
7120
|
+
получателя, затем подготовь узкий отбор. Проверь путь, формат и `saved=true`;
|
|
7121
|
+
при блокировке клиента используй `client_busy_status`/`wait_client_idle`.
|
|
7122
|
+
"""
|
|
7123
|
+
|
|
7124
|
+
|
|
7030
7125
|
def _query_from_navigation_link(value: str) -> str:
|
|
7031
7126
|
text = str(value or "").strip()
|
|
7032
7127
|
if text.startswith("e1cib/"):
|
|
@@ -10371,6 +10466,31 @@ def _register_screenshot_url(path: Path) -> dict[str, Any] | None:
|
|
|
10371
10466
|
}
|
|
10372
10467
|
|
|
10373
10468
|
|
|
10469
|
+
def _capture_hidden_desktop_screenshot(
|
|
10470
|
+
desktop: str, output_path: Path, *, window: bool
|
|
10471
|
+
) -> tuple[dict[str, Any] | None, str, str]:
|
|
10472
|
+
"""Capture a 1C window from an Answer42 hidden Windows desktop.
|
|
10473
|
+
|
|
10474
|
+
A hidden desktop is never composed on screen, so there is no "whole
|
|
10475
|
+
display" bitmap: the fallback is the largest visible window there.
|
|
10476
|
+
"""
|
|
10477
|
+
geometry = _active_external_1c_window_geometry(display=desktop) if window else None
|
|
10478
|
+
fallback = ""
|
|
10479
|
+
fallback_reason = ""
|
|
10480
|
+
if geometry is None:
|
|
10481
|
+
windows = [w for w in win_desktop.list_windows(desktop) if w["width"] >= 100 and w["height"] >= 100]
|
|
10482
|
+
if not windows:
|
|
10483
|
+
raise RuntimeError(f"Cannot capture screenshot: no visible windows on hidden desktop {desktop!r}")
|
|
10484
|
+
largest = max(windows, key=lambda w: w["width"] * w["height"])
|
|
10485
|
+
geometry = {key: largest[key] for key in ("left", "top", "width", "height", "title", "hwnd")}
|
|
10486
|
+
geometry["desktop"] = desktop
|
|
10487
|
+
if window:
|
|
10488
|
+
fallback = "largest_window"
|
|
10489
|
+
fallback_reason = "1C window was not detected on the hidden desktop; captured the largest window instead"
|
|
10490
|
+
win_desktop.capture_window_png(desktop, int(geometry["hwnd"]), int(geometry["width"]), int(geometry["height"]), output_path)
|
|
10491
|
+
return (geometry if window else None), fallback, fallback_reason
|
|
10492
|
+
|
|
10493
|
+
|
|
10374
10494
|
@mcp.tool()
|
|
10375
10495
|
def screenshot(
|
|
10376
10496
|
path: Annotated[str, "Output image path"] = "build/screenshots/screenshot.png",
|
|
@@ -10402,7 +10522,11 @@ def screenshot(
|
|
|
10402
10522
|
geometry: dict[str, Any] | None = None
|
|
10403
10523
|
fallback = ""
|
|
10404
10524
|
fallback_reason = ""
|
|
10405
|
-
if
|
|
10525
|
+
if win_desktop.is_hidden_desktop_name(resolved_display):
|
|
10526
|
+
geometry, fallback, fallback_reason = _capture_hidden_desktop_screenshot(
|
|
10527
|
+
resolved_display, output_path, window=window
|
|
10528
|
+
)
|
|
10529
|
+
elif os_support.IS_WINDOWS:
|
|
10406
10530
|
geometry = _active_external_1c_window_geometry() if window else None
|
|
10407
10531
|
if window and geometry is None:
|
|
10408
10532
|
fallback = "full_display"
|
|
@@ -10588,6 +10712,8 @@ async def _cleanup_sessions() -> None:
|
|
|
10588
10712
|
pass
|
|
10589
10713
|
runtime.stop_xvfb(sess.client_xvfb_process)
|
|
10590
10714
|
runtime.stop_xvfb(sess.xvfb_process)
|
|
10715
|
+
runtime.release_display(sess.client_display)
|
|
10716
|
+
runtime.release_display(sess.display)
|
|
10591
10717
|
if SESSION_SCOPE_ID:
|
|
10592
10718
|
_persist_sessions()
|
|
10593
10719
|
_mark_global_sessions_orphaned_for_owner()
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Hidden Windows desktops for background 1C test-manager/test-client runs.
|
|
2
|
+
|
|
3
|
+
This is the Windows counterpart of the Linux Xvfb displays. A separate
|
|
4
|
+
desktop object (``CreateDesktopW``) is created inside the interactive window
|
|
5
|
+
station ``WinSta0``; 1C processes started with ``STARTUPINFO.lpDesktop``
|
|
6
|
+
pointing at it render their windows there, so nothing appears on the user's
|
|
7
|
+
current desktop and focus is never stolen.
|
|
8
|
+
|
|
9
|
+
The desktop is still part of the user's logon session, so the MCP server must
|
|
10
|
+
run in an interactive session (not a service in session 0). Screenshots use
|
|
11
|
+
``PrintWindow(PW_RENDERFULLCONTENT)`` from a helper thread attached to the
|
|
12
|
+
desktop, because ``BitBlt`` of a desktop that is not the input desktop yields
|
|
13
|
+
black frames.
|
|
14
|
+
|
|
15
|
+
The module imports ctypes WinAPI bindings lazily so that importing Answer42 on
|
|
16
|
+
POSIX does not fail.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import struct
|
|
25
|
+
import subprocess
|
|
26
|
+
import threading
|
|
27
|
+
import zlib
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, TypeVar
|
|
31
|
+
|
|
32
|
+
LOGGER = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
IS_WINDOWS = os.name == "nt"
|
|
35
|
+
|
|
36
|
+
DESKTOP_MODE_ENV = "ONEC_MCP_WINDOWS_DESKTOP"
|
|
37
|
+
DESKTOP_MODE_CURRENT = "current"
|
|
38
|
+
DESKTOP_MODE_HIDDEN = "hidden"
|
|
39
|
+
DESKTOP_PREFIX = "Answer42-"
|
|
40
|
+
WINDOW_STATION = "WinSta0"
|
|
41
|
+
|
|
42
|
+
_GENERIC_ALL = 0x10000000
|
|
43
|
+
_STARTF_USESTDHANDLES = 0x00000100
|
|
44
|
+
_CREATE_UNICODE_ENVIRONMENT = 0x00000400
|
|
45
|
+
_PW_RENDERFULLCONTENT = 0x00000002
|
|
46
|
+
_SW_RESTORE = 9
|
|
47
|
+
_SW_MAXIMIZE = 3
|
|
48
|
+
|
|
49
|
+
_T = TypeVar("_T")
|
|
50
|
+
|
|
51
|
+
_DESKTOPS: dict[str, int] = {}
|
|
52
|
+
_DESKTOPS_LOCK = threading.Lock()
|
|
53
|
+
_api: Any = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def desktop_mode() -> str:
|
|
57
|
+
"""Return the configured Windows desktop mode: ``current`` or ``hidden``."""
|
|
58
|
+
value = (os.getenv(DESKTOP_MODE_ENV) or "").strip().lower()
|
|
59
|
+
if value in {"hidden", "background", "1", "true", "yes", "on"}:
|
|
60
|
+
return DESKTOP_MODE_HIDDEN
|
|
61
|
+
return DESKTOP_MODE_CURRENT
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def hidden_desktop_enabled() -> bool:
|
|
65
|
+
return IS_WINDOWS and desktop_mode() == DESKTOP_MODE_HIDDEN
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def session_desktop_name(session_key: str, role: str) -> str:
|
|
69
|
+
"""Build a stable desktop name; desktop names must not contain backslashes."""
|
|
70
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", f"{session_key}-{role}").strip("_") or role
|
|
71
|
+
return f"{DESKTOP_PREFIX}{safe}"[:200]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_hidden_desktop_name(value: str) -> bool:
|
|
75
|
+
return bool(IS_WINDOWS and value and value.startswith(DESKTOP_PREFIX))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def lpdesktop(name: str) -> str:
|
|
79
|
+
return f"{WINDOW_STATION}\\{name}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _winapi() -> Any:
|
|
83
|
+
"""Lazily bind the WinAPI functions used here."""
|
|
84
|
+
global _api
|
|
85
|
+
if _api is not None:
|
|
86
|
+
return _api
|
|
87
|
+
import ctypes
|
|
88
|
+
import ctypes.wintypes as wt
|
|
89
|
+
|
|
90
|
+
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
91
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
92
|
+
gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
|
|
93
|
+
|
|
94
|
+
class STARTUPINFOW(ctypes.Structure):
|
|
95
|
+
_fields_ = [
|
|
96
|
+
("cb", wt.DWORD), ("lpReserved", wt.LPWSTR), ("lpDesktop", wt.LPWSTR), ("lpTitle", wt.LPWSTR),
|
|
97
|
+
("dwX", wt.DWORD), ("dwY", wt.DWORD), ("dwXSize", wt.DWORD), ("dwYSize", wt.DWORD),
|
|
98
|
+
("dwXCountChars", wt.DWORD), ("dwYCountChars", wt.DWORD), ("dwFillAttribute", wt.DWORD),
|
|
99
|
+
("dwFlags", wt.DWORD), ("wShowWindow", wt.WORD), ("cbReserved2", wt.WORD),
|
|
100
|
+
("lpReserved2", ctypes.c_void_p), ("hStdInput", wt.HANDLE), ("hStdOutput", wt.HANDLE),
|
|
101
|
+
("hStdError", wt.HANDLE),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
class PROCESS_INFORMATION(ctypes.Structure):
|
|
105
|
+
_fields_ = [("hProcess", wt.HANDLE), ("hThread", wt.HANDLE), ("dwProcessId", wt.DWORD), ("dwThreadId", wt.DWORD)]
|
|
106
|
+
|
|
107
|
+
class BITMAPINFOHEADER(ctypes.Structure):
|
|
108
|
+
_fields_ = [
|
|
109
|
+
("biSize", wt.DWORD), ("biWidth", wt.LONG), ("biHeight", wt.LONG), ("biPlanes", wt.WORD),
|
|
110
|
+
("biBitCount", wt.WORD), ("biCompression", wt.DWORD), ("biSizeImage", wt.DWORD),
|
|
111
|
+
("biXPelsPerMeter", wt.LONG), ("biYPelsPerMeter", wt.LONG), ("biClrUsed", wt.DWORD),
|
|
112
|
+
("biClrImportant", wt.DWORD),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
user32.CreateDesktopW.restype = wt.HANDLE
|
|
116
|
+
user32.CreateDesktopW.argtypes = [wt.LPCWSTR, wt.LPCWSTR, ctypes.c_void_p, wt.DWORD, wt.DWORD, ctypes.c_void_p]
|
|
117
|
+
user32.OpenDesktopW.restype = wt.HANDLE
|
|
118
|
+
user32.OpenDesktopW.argtypes = [wt.LPCWSTR, wt.DWORD, wt.BOOL, wt.DWORD]
|
|
119
|
+
user32.CloseDesktop.argtypes = [wt.HANDLE]
|
|
120
|
+
user32.SetThreadDesktop.argtypes = [wt.HANDLE]
|
|
121
|
+
user32.EnumDesktopWindows.argtypes = [wt.HANDLE, ctypes.c_void_p, wt.LPARAM]
|
|
122
|
+
user32.PrintWindow.argtypes = [wt.HWND, wt.HDC, wt.UINT]
|
|
123
|
+
user32.GetDC.restype = wt.HDC
|
|
124
|
+
user32.GetDC.argtypes = [wt.HWND]
|
|
125
|
+
user32.ReleaseDC.argtypes = [wt.HWND, wt.HDC]
|
|
126
|
+
user32.GetWindowTextLengthW.argtypes = [wt.HWND]
|
|
127
|
+
user32.GetWindowTextW.argtypes = [wt.HWND, wt.LPWSTR, ctypes.c_int]
|
|
128
|
+
user32.GetWindowRect.argtypes = [wt.HWND, ctypes.POINTER(wt.RECT)]
|
|
129
|
+
user32.IsWindowVisible.argtypes = [wt.HWND]
|
|
130
|
+
user32.GetWindowThreadProcessId.argtypes = [wt.HWND, ctypes.POINTER(wt.DWORD)]
|
|
131
|
+
user32.ShowWindow.argtypes = [wt.HWND, ctypes.c_int]
|
|
132
|
+
user32.MoveWindow.argtypes = [wt.HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wt.BOOL]
|
|
133
|
+
kernel32.CreateProcessW.argtypes = [
|
|
134
|
+
wt.LPCWSTR, wt.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wt.BOOL, wt.DWORD,
|
|
135
|
+
ctypes.c_void_p, wt.LPCWSTR, ctypes.c_void_p, ctypes.c_void_p,
|
|
136
|
+
]
|
|
137
|
+
kernel32.CloseHandle.argtypes = [wt.HANDLE]
|
|
138
|
+
gdi32.CreateCompatibleDC.restype = wt.HDC
|
|
139
|
+
gdi32.CreateCompatibleDC.argtypes = [wt.HDC]
|
|
140
|
+
gdi32.CreateCompatibleBitmap.restype = wt.HBITMAP
|
|
141
|
+
gdi32.CreateCompatibleBitmap.argtypes = [wt.HDC, ctypes.c_int, ctypes.c_int]
|
|
142
|
+
gdi32.SelectObject.restype = wt.HGDIOBJ
|
|
143
|
+
gdi32.SelectObject.argtypes = [wt.HDC, wt.HGDIOBJ]
|
|
144
|
+
gdi32.GetDIBits.argtypes = [wt.HDC, wt.HBITMAP, wt.UINT, wt.UINT, ctypes.c_void_p, ctypes.c_void_p, wt.UINT]
|
|
145
|
+
gdi32.DeleteObject.argtypes = [wt.HGDIOBJ]
|
|
146
|
+
gdi32.DeleteDC.argtypes = [wt.HDC]
|
|
147
|
+
|
|
148
|
+
class _Api:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
api = _Api()
|
|
152
|
+
api.ctypes = ctypes
|
|
153
|
+
api.wt = wt
|
|
154
|
+
api.user32 = user32
|
|
155
|
+
api.kernel32 = kernel32
|
|
156
|
+
api.gdi32 = gdi32
|
|
157
|
+
api.STARTUPINFOW = STARTUPINFOW
|
|
158
|
+
api.PROCESS_INFORMATION = PROCESS_INFORMATION
|
|
159
|
+
api.BITMAPINFOHEADER = BITMAPINFOHEADER
|
|
160
|
+
api.EnumWindowsProc = ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM)
|
|
161
|
+
_api = api
|
|
162
|
+
return api
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _last_error(api: Any, what: str) -> OSError:
|
|
166
|
+
err = api.ctypes.get_last_error()
|
|
167
|
+
return OSError(err, f"{what} failed: {api.ctypes.FormatError(err).strip()}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def ensure_desktop(name: str) -> None:
|
|
171
|
+
"""Create (or open) the named desktop and keep a handle until release_desktop()."""
|
|
172
|
+
if not IS_WINDOWS:
|
|
173
|
+
raise RuntimeError("Hidden desktops are only available on Windows")
|
|
174
|
+
api = _winapi()
|
|
175
|
+
with _DESKTOPS_LOCK:
|
|
176
|
+
if name in _DESKTOPS:
|
|
177
|
+
return
|
|
178
|
+
handle = api.user32.CreateDesktopW(name, None, None, 0, _GENERIC_ALL, None)
|
|
179
|
+
if not handle:
|
|
180
|
+
raise _last_error(api, f"CreateDesktopW({name!r})")
|
|
181
|
+
_DESKTOPS[name] = int(handle)
|
|
182
|
+
LOGGER.info("Hidden desktop ready: %s", lpdesktop(name))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def release_desktop(name: str) -> None:
|
|
186
|
+
"""Close our handle; Windows destroys the desktop after its last process exits."""
|
|
187
|
+
if not name or not IS_WINDOWS:
|
|
188
|
+
return
|
|
189
|
+
with _DESKTOPS_LOCK:
|
|
190
|
+
handle = _DESKTOPS.pop(name, None)
|
|
191
|
+
if handle:
|
|
192
|
+
_winapi().user32.CloseDesktop(handle)
|
|
193
|
+
LOGGER.info("Released hidden desktop %s", name)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _open_desktop(name: str) -> int:
|
|
197
|
+
api = _winapi()
|
|
198
|
+
handle = api.user32.OpenDesktopW(name, 0, False, _GENERIC_ALL)
|
|
199
|
+
if not handle:
|
|
200
|
+
raise _last_error(api, f"OpenDesktopW({name!r})")
|
|
201
|
+
return int(handle)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def run_on_desktop(name: str, fn: Callable[[], _T], timeout: float = 30.0) -> _T:
|
|
205
|
+
"""Run ``fn`` in a helper thread attached to the named desktop.
|
|
206
|
+
|
|
207
|
+
SetThreadDesktop only succeeds for a thread without windows or hooks, so
|
|
208
|
+
a fresh short-lived thread is used for each call.
|
|
209
|
+
"""
|
|
210
|
+
api = _winapi()
|
|
211
|
+
box: dict[str, Any] = {}
|
|
212
|
+
|
|
213
|
+
def worker() -> None:
|
|
214
|
+
handle = 0
|
|
215
|
+
try:
|
|
216
|
+
handle = _open_desktop(name)
|
|
217
|
+
if not api.user32.SetThreadDesktop(handle):
|
|
218
|
+
raise _last_error(api, f"SetThreadDesktop({name!r})")
|
|
219
|
+
box["value"] = fn()
|
|
220
|
+
except BaseException as exc:
|
|
221
|
+
box["error"] = exc
|
|
222
|
+
finally:
|
|
223
|
+
if handle:
|
|
224
|
+
api.user32.CloseDesktop(handle)
|
|
225
|
+
|
|
226
|
+
thread = threading.Thread(target=worker, name=f"answer42-desktop-{name}", daemon=True)
|
|
227
|
+
thread.start()
|
|
228
|
+
thread.join(timeout)
|
|
229
|
+
if thread.is_alive():
|
|
230
|
+
raise TimeoutError(f"Operation on desktop {name!r} did not finish within {timeout}s")
|
|
231
|
+
if "error" in box:
|
|
232
|
+
raise box["error"]
|
|
233
|
+
return box["value"]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def list_windows(name: str) -> list[dict[str, Any]]:
|
|
237
|
+
"""Return visible top-level windows of the named desktop."""
|
|
238
|
+
api = _winapi()
|
|
239
|
+
user32 = api.user32
|
|
240
|
+
wt = api.wt
|
|
241
|
+
ctypes = api.ctypes
|
|
242
|
+
windows: list[dict[str, Any]] = []
|
|
243
|
+
|
|
244
|
+
def callback(hwnd: int, _lparam: int) -> bool:
|
|
245
|
+
try:
|
|
246
|
+
if not user32.IsWindowVisible(hwnd):
|
|
247
|
+
return True
|
|
248
|
+
length = user32.GetWindowTextLengthW(hwnd)
|
|
249
|
+
buf = ctypes.create_unicode_buffer(length + 1)
|
|
250
|
+
user32.GetWindowTextW(hwnd, buf, length + 1)
|
|
251
|
+
rect = wt.RECT()
|
|
252
|
+
if not user32.GetWindowRect(hwnd, ctypes.byref(rect)):
|
|
253
|
+
return True
|
|
254
|
+
pid = wt.DWORD()
|
|
255
|
+
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
256
|
+
windows.append({
|
|
257
|
+
"hwnd": int(hwnd),
|
|
258
|
+
"title": buf.value,
|
|
259
|
+
"pid": int(pid.value),
|
|
260
|
+
"left": int(rect.left),
|
|
261
|
+
"top": int(rect.top),
|
|
262
|
+
"width": int(rect.right - rect.left),
|
|
263
|
+
"height": int(rect.bottom - rect.top),
|
|
264
|
+
})
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
handle = _open_desktop(name)
|
|
270
|
+
try:
|
|
271
|
+
user32.EnumDesktopWindows(handle, api.EnumWindowsProc(callback), 0)
|
|
272
|
+
finally:
|
|
273
|
+
user32.CloseDesktop(handle)
|
|
274
|
+
return windows
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _png_bytes(width: int, height: int, bgra: bytes) -> bytes:
|
|
278
|
+
rows = bytearray()
|
|
279
|
+
stride = width * 4
|
|
280
|
+
for y in range(height):
|
|
281
|
+
row = bgra[y * stride:(y + 1) * stride]
|
|
282
|
+
rgb = bytearray(width * 3)
|
|
283
|
+
rgb[0::3] = row[2::4]
|
|
284
|
+
rgb[1::3] = row[1::4]
|
|
285
|
+
rgb[2::3] = row[0::4]
|
|
286
|
+
rows.append(0)
|
|
287
|
+
rows.extend(rgb)
|
|
288
|
+
|
|
289
|
+
def chunk(tag: bytes, data: bytes) -> bytes:
|
|
290
|
+
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
|
291
|
+
|
|
292
|
+
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
|
293
|
+
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(bytes(rows), 6)) + chunk(b"IEND", b"")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def capture_window_png(name: str, hwnd: int, width: int, height: int, path: Path | str) -> Path:
|
|
297
|
+
"""Render a window of the named desktop into a PNG via PrintWindow."""
|
|
298
|
+
if width <= 0 or height <= 0:
|
|
299
|
+
raise ValueError(f"Invalid window size {width}x{height}")
|
|
300
|
+
api = _winapi()
|
|
301
|
+
output_path = Path(path)
|
|
302
|
+
|
|
303
|
+
def capture() -> bytes:
|
|
304
|
+
user32, gdi32, ctypes = api.user32, api.gdi32, api.ctypes
|
|
305
|
+
screen_dc = user32.GetDC(None)
|
|
306
|
+
mem_dc = gdi32.CreateCompatibleDC(screen_dc)
|
|
307
|
+
bitmap = gdi32.CreateCompatibleBitmap(screen_dc, width, height)
|
|
308
|
+
previous = gdi32.SelectObject(mem_dc, bitmap)
|
|
309
|
+
try:
|
|
310
|
+
if not user32.PrintWindow(hwnd, mem_dc, _PW_RENDERFULLCONTENT):
|
|
311
|
+
raise _last_error(api, "PrintWindow")
|
|
312
|
+
header = api.BITMAPINFOHEADER(ctypes.sizeof(api.BITMAPINFOHEADER), width, -height, 1, 32, 0, 0, 0, 0, 0, 0)
|
|
313
|
+
buffer = ctypes.create_string_buffer(width * height * 4)
|
|
314
|
+
if not gdi32.GetDIBits(mem_dc, bitmap, 0, height, buffer, ctypes.byref(header), 0):
|
|
315
|
+
raise _last_error(api, "GetDIBits")
|
|
316
|
+
return buffer.raw
|
|
317
|
+
finally:
|
|
318
|
+
gdi32.SelectObject(mem_dc, previous)
|
|
319
|
+
gdi32.DeleteObject(bitmap)
|
|
320
|
+
gdi32.DeleteDC(mem_dc)
|
|
321
|
+
user32.ReleaseDC(None, screen_dc)
|
|
322
|
+
|
|
323
|
+
bgra = run_on_desktop(name, capture)
|
|
324
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
output_path.write_bytes(_png_bytes(width, height, bgra))
|
|
326
|
+
return output_path
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def maximize_window(name: str, hwnd: int, width: int, height: int) -> None:
|
|
330
|
+
"""Resize and maximize a window on the named desktop (no foreground change)."""
|
|
331
|
+
api = _winapi()
|
|
332
|
+
|
|
333
|
+
def apply() -> None:
|
|
334
|
+
api.user32.ShowWindow(hwnd, _SW_RESTORE)
|
|
335
|
+
api.user32.MoveWindow(hwnd, 0, 0, int(width), int(height), True)
|
|
336
|
+
api.user32.ShowWindow(hwnd, _SW_MAXIMIZE)
|
|
337
|
+
|
|
338
|
+
run_on_desktop(name, apply)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _environment_block(env: dict[str, str] | None) -> Any:
|
|
342
|
+
if env is None:
|
|
343
|
+
return None
|
|
344
|
+
items = sorted(((str(k), str(v)) for k, v in env.items()), key=lambda kv: kv[0].upper())
|
|
345
|
+
block = "".join(f"{k}={v}\0" for k, v in items) + "\0"
|
|
346
|
+
return _winapi().ctypes.create_unicode_buffer(block, len(block))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class DesktopPopen(subprocess.Popen):
|
|
350
|
+
"""``subprocess.Popen`` that starts the child on a specific Windows desktop.
|
|
351
|
+
|
|
352
|
+
CPython's ``STARTUPINFO`` does not expose ``lpDesktop``, so only process
|
|
353
|
+
creation is replaced; pipes, ``poll()``, ``wait()`` and ``kill()`` keep the
|
|
354
|
+
standard implementation.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
def __init__(self, *args: Any, desktop: str, **kwargs: Any) -> None:
|
|
358
|
+
if not IS_WINDOWS:
|
|
359
|
+
raise RuntimeError("DesktopPopen is only available on Windows")
|
|
360
|
+
self._answer42_desktop = desktop
|
|
361
|
+
super().__init__(*args, **kwargs)
|
|
362
|
+
|
|
363
|
+
def _execute_child( # type: ignore[override]
|
|
364
|
+
self, args, executable, preexec_fn, close_fds, pass_fds, cwd, env, startupinfo,
|
|
365
|
+
creationflags, shell, p2cread, p2cwrite, c2pread, c2pwrite, errread, errwrite, *unused,
|
|
366
|
+
) -> None:
|
|
367
|
+
api = _winapi()
|
|
368
|
+
ctypes = api.ctypes
|
|
369
|
+
if shell:
|
|
370
|
+
raise ValueError("DesktopPopen does not support shell=True")
|
|
371
|
+
command_line = args if isinstance(args, str) else subprocess.list2cmdline(args)
|
|
372
|
+
si = api.STARTUPINFOW()
|
|
373
|
+
si.cb = ctypes.sizeof(si)
|
|
374
|
+
si.lpDesktop = lpdesktop(self._answer42_desktop)
|
|
375
|
+
use_std_handles = -1 not in (p2cread, c2pwrite, errwrite)
|
|
376
|
+
if use_std_handles:
|
|
377
|
+
si.dwFlags |= _STARTF_USESTDHANDLES
|
|
378
|
+
si.hStdInput = int(p2cread)
|
|
379
|
+
si.hStdOutput = int(c2pwrite)
|
|
380
|
+
si.hStdError = int(errwrite)
|
|
381
|
+
pi = api.PROCESS_INFORMATION()
|
|
382
|
+
env_block = _environment_block(env)
|
|
383
|
+
flags = int(creationflags) | (_CREATE_UNICODE_ENVIRONMENT if env_block is not None else 0)
|
|
384
|
+
cmd_buffer = ctypes.create_unicode_buffer(command_line)
|
|
385
|
+
try:
|
|
386
|
+
ok = api.kernel32.CreateProcessW(
|
|
387
|
+
executable, cmd_buffer, None, None, bool(use_std_handles), flags,
|
|
388
|
+
env_block, os.fspath(cwd) if cwd is not None else None,
|
|
389
|
+
ctypes.byref(si), ctypes.byref(pi),
|
|
390
|
+
)
|
|
391
|
+
if not ok:
|
|
392
|
+
raise _last_error(api, "CreateProcessW")
|
|
393
|
+
finally:
|
|
394
|
+
# Same as CPython: drop the parent's copies of child-only handles.
|
|
395
|
+
self._close_pipe_fds(p2cread, p2cwrite, c2pread, c2pwrite, errread, errwrite)
|
|
396
|
+
api.kernel32.CloseHandle(pi.hThread)
|
|
397
|
+
self._child_created = True
|
|
398
|
+
self._handle = subprocess.Handle(pi.hProcess)
|
|
399
|
+
self.pid = int(pi.dwProcessId)
|
|
@@ -11,6 +11,8 @@ import time
|
|
|
11
11
|
from dataclasses import dataclass
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
+
from . import win_desktop
|
|
15
|
+
|
|
14
16
|
LOGGER = logging.getLogger(__name__)
|
|
15
17
|
|
|
16
18
|
TEST_CLIENT_WINDOW_TITLE = "Клиент тестирования (1С:Предприятие)"
|
|
@@ -48,7 +50,7 @@ def maximize_test_client_window(
|
|
|
48
50
|
This function controls only window geometry; it doesn't click or type in the application.
|
|
49
51
|
"""
|
|
50
52
|
if os.name == "nt":
|
|
51
|
-
return _maximize_with_winapi(title_contains, width, height).as_dict()
|
|
53
|
+
return _maximize_with_winapi(title_contains, width, height, desktop=display or "").as_dict()
|
|
52
54
|
|
|
53
55
|
env = os.environ.copy()
|
|
54
56
|
if display:
|
|
@@ -70,7 +72,7 @@ def active_1c_window_geometry(title_contains: str = "", display: str | None = No
|
|
|
70
72
|
type, or decide whether a 1C UI command succeeded; UI automation goes
|
|
71
73
|
through the 1C test-client API.
|
|
72
74
|
"""
|
|
73
|
-
chosen = _find_windows_1c_window(title_contains) if os.name == "nt" else _find_x11_1c_window(title_contains, display)
|
|
75
|
+
chosen = _find_windows_1c_window(title_contains, desktop=display or "") if os.name == "nt" else _find_x11_1c_window(title_contains, display)
|
|
74
76
|
if not chosen:
|
|
75
77
|
return None
|
|
76
78
|
return chosen.get("geometry")
|
|
@@ -398,7 +400,10 @@ def _maximize_with_xlib(title_contains: str, display: str, width: int | None, he
|
|
|
398
400
|
return WindowControlResult(False, "xlib", error=str(e))
|
|
399
401
|
|
|
400
402
|
|
|
401
|
-
def _find_windows_1c_window(title_contains: str = "") -> dict[str, Any] | None:
|
|
403
|
+
def _find_windows_1c_window(title_contains: str = "", desktop: str = "") -> dict[str, Any] | None:
|
|
404
|
+
"""Find the best 1C window on the current desktop or an Answer42 hidden desktop."""
|
|
405
|
+
if win_desktop.is_hidden_desktop_name(desktop):
|
|
406
|
+
return _find_hidden_desktop_1c_window(title_contains, desktop)
|
|
402
407
|
user32 = ctypes.windll.user32
|
|
403
408
|
candidates: list[dict[str, Any]] = []
|
|
404
409
|
|
|
@@ -448,17 +453,73 @@ def _find_windows_1c_window(title_contains: str = "") -> dict[str, Any] | None:
|
|
|
448
453
|
return candidates[0]
|
|
449
454
|
|
|
450
455
|
|
|
451
|
-
def
|
|
456
|
+
def _window_priority(title: str, width: int, height: int) -> int:
|
|
457
|
+
priority = min(width * height // 10000, 50)
|
|
458
|
+
if "1С:Предприятие" in title:
|
|
459
|
+
priority += 50
|
|
460
|
+
if title and title not in {"1cv8c", "1cv8", "Конфигурация"}:
|
|
461
|
+
priority += 100
|
|
462
|
+
return priority
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _find_hidden_desktop_1c_window(title_contains: str, desktop: str) -> dict[str, Any] | None:
|
|
466
|
+
try:
|
|
467
|
+
windows = win_desktop.list_windows(desktop)
|
|
468
|
+
except OSError as exc:
|
|
469
|
+
LOGGER.debug("Cannot enumerate hidden desktop %s: %s", desktop, exc)
|
|
470
|
+
return None
|
|
471
|
+
# A hidden desktop holds only one Answer42 session role, so without an
|
|
472
|
+
# explicit filter any large window there is ours: the Windows caption of
|
|
473
|
+
# the test client is often just "Конфигурация".
|
|
474
|
+
title_filter_matches = any(_title_matches(str(w.get("title") or ""), title_contains) for w in windows)
|
|
475
|
+
candidates: list[dict[str, Any]] = []
|
|
476
|
+
for item in windows:
|
|
477
|
+
title = str(item.get("title") or "")
|
|
478
|
+
width, height = int(item["width"]), int(item["height"])
|
|
479
|
+
if width < 300 or height < 150:
|
|
480
|
+
continue
|
|
481
|
+
if (title_contains or title_filter_matches) and not _title_matches(title, title_contains):
|
|
482
|
+
continue
|
|
483
|
+
candidates.append({
|
|
484
|
+
"hwnd": int(item["hwnd"]),
|
|
485
|
+
"title": title,
|
|
486
|
+
"priority": _window_priority(title, width, height),
|
|
487
|
+
"geometry": {
|
|
488
|
+
"left": int(item["left"]),
|
|
489
|
+
"top": int(item["top"]),
|
|
490
|
+
"width": width,
|
|
491
|
+
"height": height,
|
|
492
|
+
"title": title,
|
|
493
|
+
"hwnd": int(item["hwnd"]),
|
|
494
|
+
"desktop": desktop,
|
|
495
|
+
},
|
|
496
|
+
})
|
|
497
|
+
if not candidates:
|
|
498
|
+
return None
|
|
499
|
+
candidates.sort(key=lambda item: int(item.get("priority") or 0), reverse=True)
|
|
500
|
+
return candidates[0]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _maximize_with_winapi(title_contains: str, width: int | None, height: int | None, desktop: str = "") -> WindowControlResult:
|
|
452
504
|
try:
|
|
453
505
|
import ctypes.wintypes # type: ignore
|
|
454
506
|
|
|
455
507
|
user32 = ctypes.windll.user32
|
|
456
|
-
chosen = _find_windows_1c_window(title_contains)
|
|
508
|
+
chosen = _find_windows_1c_window(title_contains, desktop=desktop)
|
|
457
509
|
if chosen is None:
|
|
458
510
|
return WindowControlResult(False, "winapi", error="window not found")
|
|
459
511
|
hwnd = int(chosen["hwnd"])
|
|
460
512
|
screen_w = width or user32.GetSystemMetrics(0) or 1920
|
|
461
513
|
screen_h = height or user32.GetSystemMetrics(1) or 1080
|
|
514
|
+
if win_desktop.is_hidden_desktop_name(desktop):
|
|
515
|
+
win_desktop.maximize_window(desktop, hwnd, int(screen_w), int(screen_h))
|
|
516
|
+
return WindowControlResult(
|
|
517
|
+
True,
|
|
518
|
+
"winapi-hidden-desktop",
|
|
519
|
+
title=str(chosen.get("title") or ""),
|
|
520
|
+
geometry={"x": 0, "y": 0, "width": int(screen_w), "height": int(screen_h)},
|
|
521
|
+
candidates=[{"title": str(chosen.get("title") or ""), "hwnd": hwnd, "desktop": desktop}],
|
|
522
|
+
)
|
|
462
523
|
user32.ShowWindow(hwnd, 9) # SW_RESTORE
|
|
463
524
|
user32.MoveWindow(hwnd, 0, 0, int(screen_w), int(screen_h), True)
|
|
464
525
|
user32.ShowWindow(hwnd, 3) # SW_MAXIMIZE
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|