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.
Files changed (64) hide show
  1. {answer42-0.5.2 → answer42-0.5.4}/PKG-INFO +1 -1
  2. {answer42-0.5.2 → answer42-0.5.4}/docs/architecture.md +6 -1
  3. {answer42-0.5.2 → answer42-0.5.4}/docs/installation.md +12 -0
  4. {answer42-0.5.2 → answer42-0.5.4}/pyproject.toml +1 -1
  5. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/assets/MCPTestClient.cf +0 -0
  6. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/assets/MCPTestManager.cf +0 -0
  7. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/runtime.py +35 -13
  8. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/server.py +129 -3
  9. answer42-0.5.4/src/mcp_1c/win_desktop.py +399 -0
  10. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/window_control.py +66 -5
  11. {answer42-0.5.2 → answer42-0.5.4}/.gitignore +0 -0
  12. {answer42-0.5.2 → answer42-0.5.4}/LICENSE +0 -0
  13. {answer42-0.5.2 → answer42-0.5.4}/README.md +0 -0
  14. {answer42-0.5.2 → answer42-0.5.4}/credentials.example.json +0 -0
  15. {answer42-0.5.2 → answer42-0.5.4}/docs/agent-installation.md +0 -0
  16. {answer42-0.5.2 → answer42-0.5.4}/docs/assets/answer42-logo.png +0 -0
  17. {answer42-0.5.2 → answer42-0.5.4}/docs/assets/platform42-logo.svg +0 -0
  18. {answer42-0.5.2 → answer42-0.5.4}/scripts/build_cf.py +0 -0
  19. {answer42-0.5.2 → answer42-0.5.4}/scripts/build_pages.py +0 -0
  20. {answer42-0.5.2 → answer42-0.5.4}/scripts/e2e_http_smoke.py +0 -0
  21. {answer42-0.5.2 → answer42-0.5.4}/scripts/e2e_stable.py +0 -0
  22. {answer42-0.5.2 → answer42-0.5.4}/scripts/openclaw_mcp_autoreload.py +0 -0
  23. {answer42-0.5.2 → answer42-0.5.4}/scripts/rag_cli.py +0 -0
  24. {answer42-0.5.2 → answer42-0.5.4}/scripts/read_webclient_start_info.py +0 -0
  25. {answer42-0.5.2 → answer42-0.5.4}/src/cf/ConfigDumpInfo.xml +0 -0
  26. {answer42-0.5.2 → answer42-0.5.4}/src/cf/Configuration.xml +0 -0
  27. {answer42-0.5.2 → answer42-0.5.4}/src/cf/DataProcessors/MCPTestManager/Ext/ObjectModule.bsl +0 -0
  28. {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
  29. {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
  30. {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
  31. {answer42-0.5.2 → answer42-0.5.4}/src/cf/DataProcessors/MCPTestManager.xml +0 -0
  32. {answer42-0.5.2 → answer42-0.5.4}/src/cf/Ext/ManagedApplicationModule.bsl +0 -0
  33. {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
  34. {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
  35. {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
  36. {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/ConfigDumpInfo.xml +0 -0
  37. {answer42-0.5.2 → answer42-0.5.4}/src/client_cf/Configuration.xml +0 -0
  38. {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
  39. {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
  40. {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
  41. {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
  42. {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
  43. {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
  44. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/__init__.py +0 -0
  45. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/assets/__init__.py +0 -0
  46. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/auth.py +0 -0
  47. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/bridge.py +0 -0
  48. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/credentials.py +0 -0
  49. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/os_support.py +0 -0
  50. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/platform.py +0 -0
  51. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/protocol.py +0 -0
  52. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/__init__.py +0 -0
  53. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/detect.py +0 -0
  54. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/dump.py +0 -0
  55. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/model.py +0 -0
  56. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/parsers.py +0 -0
  57. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/service.py +0 -0
  58. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/store.py +0 -0
  59. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/summary_backend.py +0 -0
  60. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/rag/summary_vector.py +0 -0
  61. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/recorder.py +0 -0
  62. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/release_helper.py +0 -0
  63. {answer42-0.5.2 → answer42-0.5.4}/src/mcp_1c/skill_installer.py +0 -0
  64. {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.2
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.2"
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"
@@ -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
- return "", "", False
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
- env = os.environ.copy()
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
- env = os.environ.copy()
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(display_offset)
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 os_support.IS_WINDOWS:
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 _maximize_with_winapi(title_contains: str, width: int | None, height: int | None) -> WindowControlResult:
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