PyClickerRPA 0.9.0__tar.gz → 0.11.0__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 (102) hide show
  1. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/PKG-INFO +34 -1
  2. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/PyClickerRPA.egg-info/PKG-INFO +34 -1
  3. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/PyClickerRPA.egg-info/SOURCES.txt +4 -0
  4. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/README.md +33 -0
  5. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/actions.py +5 -0
  6. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/adb.py +36 -4
  7. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/clicker.py +82 -0
  8. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/controllers.py +196 -10
  9. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/migrations.py +11 -2
  10. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/models.py +46 -0
  11. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/resources.py +40 -0
  12. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/runner.py +154 -12
  13. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/pyproject.toml +1 -1
  14. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_actions.py +1 -0
  15. pyclickerrpa-0.11.0/tests/test_controller_strategies.py +203 -0
  16. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_migrations.py +13 -2
  17. pyclickerrpa-0.11.0/tests/test_p1_blackbox.py +226 -0
  18. pyclickerrpa-0.11.0/tests/test_recognition_dispatch.py +173 -0
  19. pyclickerrpa-0.11.0/tests/test_resource_bundle.py +150 -0
  20. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/LICENSE +0 -0
  21. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/PyClickerRPA.egg-info/dependency_links.txt +0 -0
  22. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/PyClickerRPA.egg-info/entry_points.txt +0 -0
  23. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/PyClickerRPA.egg-info/requires.txt +0 -0
  24. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/PyClickerRPA.egg-info/top_level.txt +0 -0
  25. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/__init__.py +0 -0
  26. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/app.py +0 -0
  27. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/capture.py +0 -0
  28. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/diagnose.py +0 -0
  29. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/dpi.py +0 -0
  30. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/expr.py +0 -0
  31. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/__init__.py +0 -0
  32. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/flow_connection.py +0 -0
  33. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/flow_editor.py +0 -0
  34. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/flow_node.py +0 -0
  35. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/hotkeys.py +0 -0
  36. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/lint_dialog.py +0 -0
  37. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/log_panel.py +0 -0
  38. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/main_window.py +0 -0
  39. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/monitor.py +0 -0
  40. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/preview_label.py +0 -0
  41. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/property_panel.py +0 -0
  42. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/qt_runner.py +0 -0
  43. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/queue_panel.py +0 -0
  44. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/recorder.py +0 -0
  45. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/scheduler_widget.py +0 -0
  46. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/snippet_library.py +0 -0
  47. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/step_list_widget.py +0 -0
  48. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/task_widget.py +0 -0
  49. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/testkit_widget.py +0 -0
  50. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/gui/var_panel.py +0 -0
  51. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/lint.py +0 -0
  52. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/matcher.py +0 -0
  53. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/ocr.py +0 -0
  54. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/recognizers.py +0 -0
  55. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/reports.py +0 -0
  56. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/run.py +0 -0
  57. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/sandbox.py +0 -0
  58. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/task_queue.py +0 -0
  59. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/templatize.py +0 -0
  60. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/testkit/__init__.py +0 -0
  61. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/testkit/__main__.py +0 -0
  62. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/testkit/assertions.py +0 -0
  63. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/testkit/case.py +0 -0
  64. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/testkit/loader.py +0 -0
  65. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/testkit/pytest_plugin.py +0 -0
  66. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/testkit/report.py +0 -0
  67. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/testkit/runner.py +0 -0
  68. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/testkit/visual.py +0 -0
  69. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/web/__init__.py +0 -0
  70. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/web/__main__.py +0 -0
  71. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/web/run_manager.py +0 -0
  72. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/web/scheduler.py +0 -0
  73. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/web/serialize.py +0 -0
  74. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/web/server.py +0 -0
  75. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/web/static/app.js +0 -0
  76. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/web/static/index.html +0 -0
  77. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/web/static/style.css +0 -0
  78. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/autoclicker/windows.py +0 -0
  79. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/setup.cfg +0 -0
  80. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_adb.py +0 -0
  81. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_diagnose.py +0 -0
  82. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_diagnose_gui.py +0 -0
  83. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_engine_integration.py +0 -0
  84. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_engine_services.py +0 -0
  85. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_flow_branch.py +0 -0
  86. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_lint.py +0 -0
  87. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_listener_lifecycle.py +0 -0
  88. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_matcher.py +0 -0
  89. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_models.py +0 -0
  90. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_phase4_integration.py +0 -0
  91. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_platform_recognition.py +0 -0
  92. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_queue_entrypoints.py +0 -0
  93. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_recorder_gui.py +0 -0
  94. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_runner.py +0 -0
  95. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_sandbox.py +0 -0
  96. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_structured_flow.py +0 -0
  97. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_structured_gui.py +0 -0
  98. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_templatize.py +0 -0
  99. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_testkit.py +0 -0
  100. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_testkit_contracts.py +0 -0
  101. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_testkit_gui.py +0 -0
  102. {pyclickerrpa-0.9.0 → pyclickerrpa-0.11.0}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyClickerRPA
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, structured conditions and loops with array iteration, node flow editor, smart recorder, task health checks and failure attribution, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
5
5
  Author: Equinox
6
6
  License-Expression: MIT
@@ -89,6 +89,39 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
89
89
  activate-window, HTTP request, run-Python-script, call sub-task, groups, and a
90
90
  **stop** step to end a run — with retry / repeat / until policies.
91
91
 
92
+ ### Black-box automation runtime
93
+
94
+ - **Recognition-driven dispatch** (MAA-style): a step's `next` list is scanned
95
+ in order and the first candidate whose recognition gate hits is executed.
96
+ Set `next_timeout_ms` (>0, or -1 for unbounded) with `rate_limit_ms` to wait
97
+ and rescan instead of failing immediately — `next_timeout_ms: 0` (default)
98
+ keeps the classic single-pass behavior.
99
+ - **Resource bundles**: `Task.resource_paths` lists bundle root directories;
100
+ template names that aren't real paths resolve as `<root>/image/<name>` with
101
+ later roots overriding earlier ones (overlay). `Task.load_bundle(dir)`
102
+ assembles a task from `interface.json` (`resource` roots + `pipeline` entry)
103
+ or a plain `task.json`. Hot reload rides the signature-checked template cache.
104
+ - **Configurable capture/input channels**: controllers accept
105
+ `screencap_method` / `input_method`. Win32: `gdi_bitblt` (default),
106
+ `printwindow`, input `sendinput` (default) or `postmessage` (background,
107
+ needs a window handle; continuous gestures unsupported). ADB: `screencap`
108
+ (default) or `raw_gzip` (device-side gzip, steadier on slow links), input
109
+ `adb_input`. `minicap` / `minitouch` / `maatouch` / `desktop_dup` are
110
+ registered placeholders that fall back to the default with a
111
+ `strategy_fallback` warning — they require extra device components that are
112
+ not bundled (real-device behavior unverified).
113
+ - **Target resolution** (MAA-style): an action's `target` param can be a fixed
114
+ region `[x, y, w, h]` (clicks its center) or a **node name** — it then clicks
115
+ the center of that node's latest recognition box, enabling "recognize A, then
116
+ click next to A". `target: true` / absent keeps the step's own targeting.
117
+ - **Post-action freeze wait**: `post_wait_freezes: {time, target}` on any action
118
+ waits until the watched region stops changing before moving on — the standard
119
+ remedy for loading screens and animations. Unlike a standalone `wait_stable`
120
+ step it never fails the run; it waits up to `time` and proceeds.
121
+ - **Human-like jitter**: click coordinates are sampled from a truncated Gaussian
122
+ (sigma = `randomOffset`/2, clamped to the bound) instead of a uniform spread,
123
+ which looks more like a real hand and is friendlier to anti-automation checks.
124
+
92
125
  ## Authoring (desktop GUI)
93
126
 
94
127
  Drag-and-drop **node flow graph** and an interoperable **step list**, with
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyClickerRPA
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, structured conditions and loops with array iteration, node flow editor, smart recorder, task health checks and failure attribution, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
5
5
  Author: Equinox
6
6
  License-Expression: MIT
@@ -89,6 +89,39 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
89
89
  activate-window, HTTP request, run-Python-script, call sub-task, groups, and a
90
90
  **stop** step to end a run — with retry / repeat / until policies.
91
91
 
92
+ ### Black-box automation runtime
93
+
94
+ - **Recognition-driven dispatch** (MAA-style): a step's `next` list is scanned
95
+ in order and the first candidate whose recognition gate hits is executed.
96
+ Set `next_timeout_ms` (>0, or -1 for unbounded) with `rate_limit_ms` to wait
97
+ and rescan instead of failing immediately — `next_timeout_ms: 0` (default)
98
+ keeps the classic single-pass behavior.
99
+ - **Resource bundles**: `Task.resource_paths` lists bundle root directories;
100
+ template names that aren't real paths resolve as `<root>/image/<name>` with
101
+ later roots overriding earlier ones (overlay). `Task.load_bundle(dir)`
102
+ assembles a task from `interface.json` (`resource` roots + `pipeline` entry)
103
+ or a plain `task.json`. Hot reload rides the signature-checked template cache.
104
+ - **Configurable capture/input channels**: controllers accept
105
+ `screencap_method` / `input_method`. Win32: `gdi_bitblt` (default),
106
+ `printwindow`, input `sendinput` (default) or `postmessage` (background,
107
+ needs a window handle; continuous gestures unsupported). ADB: `screencap`
108
+ (default) or `raw_gzip` (device-side gzip, steadier on slow links), input
109
+ `adb_input`. `minicap` / `minitouch` / `maatouch` / `desktop_dup` are
110
+ registered placeholders that fall back to the default with a
111
+ `strategy_fallback` warning — they require extra device components that are
112
+ not bundled (real-device behavior unverified).
113
+ - **Target resolution** (MAA-style): an action's `target` param can be a fixed
114
+ region `[x, y, w, h]` (clicks its center) or a **node name** — it then clicks
115
+ the center of that node's latest recognition box, enabling "recognize A, then
116
+ click next to A". `target: true` / absent keeps the step's own targeting.
117
+ - **Post-action freeze wait**: `post_wait_freezes: {time, target}` on any action
118
+ waits until the watched region stops changing before moving on — the standard
119
+ remedy for loading screens and animations. Unlike a standalone `wait_stable`
120
+ step it never fails the run; it waits up to `time` and proceeds.
121
+ - **Human-like jitter**: click coordinates are sampled from a truncated Gaussian
122
+ (sigma = `randomOffset`/2, clamped to the bound) instead of a uniform spread,
123
+ which looks more like a real hand and is friendlier to anti-automation checks.
124
+
92
125
  ## Authoring (desktop GUI)
93
126
 
94
127
  Drag-and-drop **node flow graph** and an interoperable **step list**, with
@@ -71,6 +71,7 @@ autoclicker/web/static/index.html
71
71
  autoclicker/web/static/style.css
72
72
  tests/test_actions.py
73
73
  tests/test_adb.py
74
+ tests/test_controller_strategies.py
74
75
  tests/test_diagnose.py
75
76
  tests/test_diagnose_gui.py
76
77
  tests/test_engine_integration.py
@@ -81,10 +82,13 @@ tests/test_listener_lifecycle.py
81
82
  tests/test_matcher.py
82
83
  tests/test_migrations.py
83
84
  tests/test_models.py
85
+ tests/test_p1_blackbox.py
84
86
  tests/test_phase4_integration.py
85
87
  tests/test_platform_recognition.py
86
88
  tests/test_queue_entrypoints.py
89
+ tests/test_recognition_dispatch.py
87
90
  tests/test_recorder_gui.py
91
+ tests/test_resource_bundle.py
88
92
  tests/test_runner.py
89
93
  tests/test_sandbox.py
90
94
  tests/test_structured_flow.py
@@ -51,6 +51,39 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
51
51
  activate-window, HTTP request, run-Python-script, call sub-task, groups, and a
52
52
  **stop** step to end a run — with retry / repeat / until policies.
53
53
 
54
+ ### Black-box automation runtime
55
+
56
+ - **Recognition-driven dispatch** (MAA-style): a step's `next` list is scanned
57
+ in order and the first candidate whose recognition gate hits is executed.
58
+ Set `next_timeout_ms` (>0, or -1 for unbounded) with `rate_limit_ms` to wait
59
+ and rescan instead of failing immediately — `next_timeout_ms: 0` (default)
60
+ keeps the classic single-pass behavior.
61
+ - **Resource bundles**: `Task.resource_paths` lists bundle root directories;
62
+ template names that aren't real paths resolve as `<root>/image/<name>` with
63
+ later roots overriding earlier ones (overlay). `Task.load_bundle(dir)`
64
+ assembles a task from `interface.json` (`resource` roots + `pipeline` entry)
65
+ or a plain `task.json`. Hot reload rides the signature-checked template cache.
66
+ - **Configurable capture/input channels**: controllers accept
67
+ `screencap_method` / `input_method`. Win32: `gdi_bitblt` (default),
68
+ `printwindow`, input `sendinput` (default) or `postmessage` (background,
69
+ needs a window handle; continuous gestures unsupported). ADB: `screencap`
70
+ (default) or `raw_gzip` (device-side gzip, steadier on slow links), input
71
+ `adb_input`. `minicap` / `minitouch` / `maatouch` / `desktop_dup` are
72
+ registered placeholders that fall back to the default with a
73
+ `strategy_fallback` warning — they require extra device components that are
74
+ not bundled (real-device behavior unverified).
75
+ - **Target resolution** (MAA-style): an action's `target` param can be a fixed
76
+ region `[x, y, w, h]` (clicks its center) or a **node name** — it then clicks
77
+ the center of that node's latest recognition box, enabling "recognize A, then
78
+ click next to A". `target: true` / absent keeps the step's own targeting.
79
+ - **Post-action freeze wait**: `post_wait_freezes: {time, target}` on any action
80
+ waits until the watched region stops changing before moving on — the standard
81
+ remedy for loading screens and animations. Unlike a standalone `wait_stable`
82
+ step it never fails the run; it waits up to `time` and proceeds.
83
+ - **Human-like jitter**: click coordinates are sampled from a truncated Gaussian
84
+ (sigma = `randomOffset`/2, clamped to the bound) instead of a uniform spread,
85
+ which looks more like a real hand and is friendlier to anti-automation checks.
86
+
54
87
  ## Authoring (desktop GUI)
55
88
 
56
89
  Drag-and-drop **node flow graph** and an interoperable **step list**, with
@@ -100,6 +100,11 @@ def exec_click_position(ctx: ExecutionContext, step: Step) -> StepResult:
100
100
  if step.varName == "use_var":
101
101
  cx = int(ctx.vars.get("last_x", "0"))
102
102
  cy = int(ctx.vars.get("last_y", "0"))
103
+ # P1-4: an explicit target (fixed region or another node's recognition box)
104
+ # overrides the literal coordinates.
105
+ override = ctx.runner._resolve_target(step)
106
+ if override is not None:
107
+ cx, cy = override
103
108
  cx = ctx.runner._rand_offset(cx, step.randomOffset)
104
109
  cy = ctx.runner._rand_offset(cy, step.randomOffset)
105
110
  ctx.log(f" 点击 ({cx}, {cy})")
@@ -83,6 +83,23 @@ class AdbDevice:
83
83
  raise RuntimeError("screencap 解码失败(设备可能不支持 exec-out)")
84
84
  return img
85
85
 
86
+ def screencap_gzip(self) -> np.ndarray:
87
+ """RPA-25: 设备端 gzip 压缩后再传回的截图(慢链路设备更稳)。
88
+
89
+ 需要设备 shell 提供 gzip(Android toybox 自带)。
90
+ """
91
+ import cv2
92
+ import gzip
93
+ raw = self._run(["exec-out", "sh", "-c", "screencap -p | gzip -1"])
94
+ try:
95
+ png = gzip.decompress(raw)
96
+ except OSError as exc:
97
+ raise RuntimeError(f"screencap(gzip) 解压失败: {exc}") from exc
98
+ img = cv2.imdecode(np.frombuffer(png, dtype=np.uint8), cv2.IMREAD_COLOR)
99
+ if img is None or img.size == 0:
100
+ raise RuntimeError("screencap(gzip) 解码失败")
101
+ return img
102
+
86
103
  # ---- input ----
87
104
  def tap(self, x: int, y: int) -> None:
88
105
  self._run(["shell", "input", "tap", str(int(x)), str(int(y))])
@@ -121,15 +138,30 @@ class AdbDevice:
121
138
 
122
139
 
123
140
  class AdbCapture:
124
- """capture 后端:忽略 hwnd,一律截设备屏。"""
125
- def __init__(self, device: AdbDevice):
141
+ """capture 后端:忽略 hwnd,一律截设备屏。
142
+
143
+ RPA-25: ``screencap_method`` 选择截图通道 —— ``"screencap"``(默认,
144
+ exec-out 直传 PNG)或 ``"raw_gzip"``(设备端 gzip 压缩后传回,慢链路更稳)。
145
+ """
146
+
147
+ METHODS = ("screencap", "raw_gzip")
148
+
149
+ def __init__(self, device: AdbDevice, screencap_method: str = "screencap"):
150
+ if screencap_method not in self.METHODS:
151
+ raise ValueError(f"未知截图方法: {screencap_method},可选 {self.METHODS}")
126
152
  self.device = device
153
+ self.screencap_method = screencap_method
127
154
 
128
- def capture_screen(self) -> np.ndarray:
155
+ def _screencap(self) -> np.ndarray:
156
+ if self.screencap_method == "raw_gzip":
157
+ return self.device.screencap_gzip()
129
158
  return self.device.screencap()
130
159
 
160
+ def capture_screen(self) -> np.ndarray:
161
+ return self._screencap()
162
+
131
163
  def capture_window(self, hwnd) -> np.ndarray:
132
- return self.device.screencap()
164
+ return self._screencap()
133
165
 
134
166
 
135
167
  class AdbClicker:
@@ -52,6 +52,9 @@ def _api():
52
52
  _user32 = loader("user32", use_last_error=True)
53
53
  _user32.SendInput.argtypes = (wintypes.UINT, ctypes.POINTER(_INPUT), ctypes.c_int)
54
54
  _user32.SendInput.restype = wintypes.UINT
55
+ _user32.PostMessageW.argtypes = (wintypes.HWND, wintypes.UINT,
56
+ wintypes.WPARAM, wintypes.LPARAM)
57
+ _user32.PostMessageW.restype = wintypes.BOOL
55
58
  return _user32
56
59
 
57
60
 
@@ -250,6 +253,85 @@ class Clicker:
250
253
  self.key_press(vk)
251
254
 
252
255
 
256
+ # ---- RPA-25: PostMessage background input (window-message based) ----
257
+ WM_LBUTTONDOWN, WM_LBUTTONUP = 0x0201, 0x0202
258
+ WM_RBUTTONDOWN, WM_RBUTTONUP = 0x0204, 0x0205
259
+ WM_MOUSEWHEEL = 0x020A
260
+ WM_KEYDOWN, WM_KEYUP, WM_CHAR = 0x0100, 0x0101, 0x0102
261
+ MK_LBUTTON = 0x0001
262
+
263
+
264
+ def _lparam(x: int, y: int) -> int:
265
+ return (int(y) & 0xFFFF) << 16 | (int(x) & 0xFFFF)
266
+
267
+
268
+ class PostMessageClicker:
269
+ """RPA-25: background input via PostMessage to a target HWND.
270
+
271
+ Unlike SendInput, this does not steal focus and works on unfocused windows,
272
+ but it requires a valid window handle and only supports discrete messages —
273
+ continuous gestures (swipe/drag/pinch) are not expressible and raise.
274
+ """
275
+
276
+ def _post(self, hwnd, msg, wparam, lparam) -> None:
277
+ if not hwnd:
278
+ raise NotImplementedError("postmessage 输入需要目标窗口句柄 (hwnd)")
279
+ api = _api()
280
+ if not api.PostMessageW(hwnd, msg, wparam, lparam):
281
+ raise OSError(f"PostMessage 失败: msg=0x{msg:04X} hwnd={hwnd}")
282
+
283
+ # ---- mouse ----
284
+ def click(self, hwnd, x: int, y: int) -> None:
285
+ lp = _lparam(x, y)
286
+ self._post(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, lp)
287
+ self._post(hwnd, WM_LBUTTONUP, 0, lp)
288
+
289
+ def double_click(self, hwnd, x: int, y: int) -> None:
290
+ self.click(hwnd, x, y)
291
+ time.sleep(0.05)
292
+ self.click(hwnd, x, y)
293
+
294
+ def right_click(self, hwnd, x: int, y: int) -> None:
295
+ lp = _lparam(x, y)
296
+ self._post(hwnd, WM_RBUTTONDOWN, 0, lp)
297
+ self._post(hwnd, WM_RBUTTONUP, 0, lp)
298
+
299
+ def long_press(self, hwnd, x: int, y: int, hold_ms: int) -> None:
300
+ lp = _lparam(x, y)
301
+ self._post(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, lp)
302
+ time.sleep(hold_ms / 1000.0)
303
+ self._post(hwnd, WM_LBUTTONUP, 0, lp)
304
+
305
+ def scroll(self, hwnd, x, y, amount: int) -> None:
306
+ lp = _lparam(x, y)
307
+ self._post(hwnd, WM_MOUSEWHEEL, (amount * WHEEL_DELTA & 0xFFFF) << 16, lp)
308
+
309
+ def swipe(self, hwnd, x1, y1, x2, y2, duration_ms: int = 500) -> None:
310
+ raise NotImplementedError("postmessage 不支持连续滑动,请改用 sendinput")
311
+
312
+ def drag(self, hwnd, x1, y1, x2, y2, duration_ms: int = 300) -> None:
313
+ raise NotImplementedError("postmessage 不支持拖拽,请改用 sendinput")
314
+
315
+ def pinch(self, hwnd, cx, cy, start_dist, end_dist, duration_ms: int = 400) -> None:
316
+ raise NotImplementedError("postmessage 不支持双指缩放,请改用 sendinput")
317
+
318
+ # ---- keyboard ----
319
+ def key_down(self, vk: int) -> None:
320
+ raise NotImplementedError("postmessage 键盘输入需要目标窗口,请用 key_press/type_text")
321
+
322
+ def key_up(self, vk: int) -> None:
323
+ raise NotImplementedError("postmessage 键盘输入需要目标窗口,请用 key_press/type_text")
324
+
325
+ def key_press(self, vk: int) -> None:
326
+ raise NotImplementedError("postmessage 按键请通过 type_text 或指定 hwnd 的窗口消息")
327
+
328
+ def type_text(self, text: str) -> None:
329
+ raise NotImplementedError("postmessage 文本输入需要目标窗口句柄,请改用 sendinput")
330
+
331
+ def hotkey(self, modifier: int, vk: int) -> None:
332
+ raise NotImplementedError("postmessage 不支持组合键,请改用 sendinput")
333
+
334
+
253
335
  # ---- vk parsing (port of Clicker::parseVkCode) --------------------------------
254
336
  _NAMED_VK = {
255
337
  "ENTER": 0x0D, "RETURN": 0x0D, "ESC": 0x1B, "ESCAPE": 0x1B, "TAB": 0x09,
@@ -85,6 +85,74 @@ class ControllerStopped(RuntimeError):
85
85
  """A controller lease was cancelled before execution."""
86
86
 
87
87
 
88
+ class StrategyUnavailable(RuntimeError):
89
+ """RPA-25: a capture/input strategy needs components that are not present.
90
+
91
+ Placeholders (minicap/minitouch/maatouch/desktop_dup) raise this so an
92
+ explicit opt-in never masquerades as working; controllers fall back to
93
+ their default strategy and record a warning.
94
+ """
95
+
96
+
97
+ # RPA-25: configurable capture/input channels, registered like recognizers.
98
+ # Names are disjoint per platform by convention (see ADB_*/WIN32_* tuples).
99
+ ADB_SCREENCAP = ("screencap", "raw_gzip", "minicap")
100
+ ADB_INPUT = ("adb_input", "minitouch", "maatouch")
101
+ WIN32_SCREENCAP = ("gdi_bitblt", "printwindow", "desktop_dup")
102
+ WIN32_INPUT = ("sendinput", "postmessage")
103
+
104
+ _CAPTURE_STRATEGIES: dict[str, Callable] = {}
105
+ _INPUT_STRATEGIES: dict[str, Callable] = {}
106
+
107
+
108
+ def _register(registry: dict, name: str, *, replace: bool):
109
+ if not isinstance(name, str) or not name.strip():
110
+ raise ValueError("strategy name must be non-empty")
111
+
112
+ def decorator(factory):
113
+ if not callable(factory):
114
+ raise TypeError("strategy factory must be callable")
115
+ if name in registry and not replace:
116
+ raise ValueError(f"Strategy already registered: {name}")
117
+ registry[name] = factory
118
+ return factory
119
+ return decorator
120
+
121
+
122
+ def register_capture_strategy(name: str, *, replace: bool = False):
123
+ """Register ``factory(controller) -> capture backend``."""
124
+ return _register(_CAPTURE_STRATEGIES, name, replace=replace)
125
+
126
+
127
+ def register_input_strategy(name: str, *, replace: bool = False):
128
+ """Register ``factory(controller) -> click/keyboard backend``."""
129
+ return _register(_INPUT_STRATEGIES, name, replace=replace)
130
+
131
+
132
+ def get_capture_strategy(name: str) -> Callable:
133
+ try:
134
+ return _CAPTURE_STRATEGIES[name]
135
+ except KeyError:
136
+ raise KeyError(f"Unknown capture strategy: {name!r}; "
137
+ f"available: {sorted(_CAPTURE_STRATEGIES)}") from None
138
+
139
+
140
+ def get_input_strategy(name: str) -> Callable:
141
+ try:
142
+ return _INPUT_STRATEGIES[name]
143
+ except KeyError:
144
+ raise KeyError(f"Unknown input strategy: {name!r}; "
145
+ f"available: {sorted(_INPUT_STRATEGIES)}") from None
146
+
147
+
148
+ def registered_capture_strategies() -> list[str]:
149
+ return sorted(_CAPTURE_STRATEGIES)
150
+
151
+
152
+ def registered_input_strategies() -> list[str]:
153
+ return sorted(_INPUT_STRATEGIES)
154
+
155
+
88
156
  _lease_condition = Condition(RLock())
89
157
  _lease_locks = WeakValueDictionary()
90
158
  _lease_owners = {}
@@ -177,6 +245,30 @@ class _LegacyController:
177
245
  self._watch_thread = None
178
246
  self._watch_stop = None
179
247
  self._active_backends = []
248
+ # RPA-25: fallback records when a requested strategy is unavailable.
249
+ self.strategy_warnings: list[dict] = []
250
+
251
+ def _resolve_strategy(self, kind: str, requested: str, default: str):
252
+ """Build a backend through the strategy registry (RPA-25).
253
+
254
+ Unknown names are errors (never silent misses). A placeholder raising
255
+ StrategyUnavailable falls back to the platform default and records a
256
+ warning, so an explicit opt-in never masquerades as working.
257
+ """
258
+ registry = _CAPTURE_STRATEGIES if kind == "screencap" else _INPUT_STRATEGIES
259
+ try:
260
+ factory = registry[requested]
261
+ except KeyError:
262
+ raise KeyError(f"Unknown {kind} strategy: {requested!r}; "
263
+ f"available: {sorted(registry)}") from None
264
+ try:
265
+ return factory(self)
266
+ except StrategyUnavailable as exc:
267
+ entry = {"kind": kind, "requested": requested,
268
+ "fallback": default, "reason": str(exc)}
269
+ self.strategy_warnings.append(entry)
270
+ self.notify_change(source="strategy_fallback", **entry)
271
+ return registry[default](self)
180
272
 
181
273
  @property
182
274
  def connected(self):
@@ -484,24 +576,28 @@ class Win32Controller(_LegacyController):
484
576
 
485
577
  def __init__(self, *, capture=None, clicker=None,
486
578
  origin: tuple[int, int] | Callable[[], tuple[int, int]] | None = None,
487
- target_hwnd=None, pixel_watch=None, resource="desktop"):
579
+ target_hwnd=None, pixel_watch=None, resource="desktop",
580
+ screencap_method: str = "gdi_bitblt", input_method: str = "sendinput"):
488
581
  self._capture = capture
489
582
  self._clicker = clicker
490
583
  self._origin = origin
491
584
  self._captured_origin = None
585
+ # RPA-25: injected backends win; methods apply when backends are built.
586
+ self._screencap_method = screencap_method
587
+ self._input_method = input_method
492
588
  # Window targets on one desktop share the same execution resource.
493
589
  self._init_lifecycle(("win32", resource), target_hwnd, pixel_watch)
494
590
 
495
591
  def _capture_backend(self):
496
592
  if self._capture is None:
497
- from autoclicker import capture
498
- self._capture = capture
593
+ self._capture = self._resolve_strategy(
594
+ "screencap", self._screencap_method, "gdi_bitblt")
499
595
  return self._capture
500
596
 
501
597
  def _click_backend(self):
502
598
  if self._clicker is None:
503
- from autoclicker.clicker import Clicker
504
- self._clicker = Clicker()
599
+ self._clicker = self._resolve_strategy(
600
+ "input", self._input_method, "sendinput")
505
601
  return self._clicker
506
602
 
507
603
  def _desktop_origin(self):
@@ -553,7 +649,8 @@ class ADBController(_LegacyController):
553
649
  key_hold=False, hotkey=False)
554
650
 
555
651
  def __init__(self, device=None, *, serial=None, adb="adb", capture=None, clicker=None,
556
- target_hwnd=None, pixel_watch=None):
652
+ target_hwnd=None, pixel_watch=None,
653
+ screencap_method: str = "screencap", input_method: str = "adb_input"):
557
654
  self.device = device
558
655
  self.serial = serial if serial is not None else getattr(device, "serial", None)
559
656
  if not isinstance(self.serial, (str, type(None))):
@@ -561,6 +658,9 @@ class ADBController(_LegacyController):
561
658
  self.adb = adb
562
659
  self._capture = capture
563
660
  self._clicker = clicker
661
+ # RPA-25: injected backends win; methods apply when backends are built.
662
+ self._screencap_method = screencap_method
663
+ self._input_method = input_method
564
664
  self._init_lifecycle(("adb", self.serial or "*"), target_hwnd, pixel_watch)
565
665
 
566
666
  def _connection_backends(self):
@@ -576,14 +676,14 @@ class ADBController(_LegacyController):
576
676
 
577
677
  def _capture_backend(self):
578
678
  if self._capture is None:
579
- from autoclicker.adb import AdbCapture
580
- self._capture = AdbCapture(self._device())
679
+ self._capture = self._resolve_strategy(
680
+ "screencap", self._screencap_method, "screencap")
581
681
  return self._capture
582
682
 
583
683
  def _click_backend(self):
584
684
  if self._clicker is None:
585
- from autoclicker.adb import AdbClicker
586
- self._clicker = AdbClicker(self._device())
685
+ self._clicker = self._resolve_strategy(
686
+ "input", self._input_method, "adb_input")
587
687
  return self._clicker
588
688
 
589
689
  def capture_window(self, hwnd):
@@ -592,3 +692,89 @@ class ADBController(_LegacyController):
592
692
 
593
693
  def pinch(self, hwnd, cx, cy, start_dist, end_dist, duration_ms=400):
594
694
  raise NotImplementedError("ADB input does not support multi-touch pinch")
695
+
696
+
697
+ # ---- RPA-25: built-in capture/input strategy factories ----
698
+
699
+ def _require_kind(controller, cls, kind):
700
+ if not isinstance(controller, cls):
701
+ raise ValueError(f"{kind} 策略只能用于 {cls.__name__}")
702
+
703
+
704
+ @register_capture_strategy("gdi_bitblt")
705
+ def _win32_gdi_capture(controller):
706
+ _require_kind(controller, Win32Controller, "gdi_bitblt")
707
+ from autoclicker import capture
708
+ return capture
709
+
710
+
711
+ @register_capture_strategy("printwindow")
712
+ def _win32_printwindow_capture(controller):
713
+ """Prefer PrintWindow for a bound window; full-screen keeps GDI BitBlt."""
714
+ _require_kind(controller, Win32Controller, "printwindow")
715
+ from autoclicker import capture
716
+
717
+ class _PrintWindowBackend:
718
+ def capture_screen(self):
719
+ hwnd = controller.target_hwnd
720
+ return capture.capture_window(hwnd) if hwnd else capture.capture_screen()
721
+
722
+ def capture_window(self, hwnd):
723
+ return capture.capture_window(hwnd)
724
+
725
+ return _PrintWindowBackend()
726
+
727
+
728
+ @register_capture_strategy("desktop_dup")
729
+ def _win32_desktop_dup_capture(controller):
730
+ raise StrategyUnavailable("desktop_dup 需要 DXGI Desktop Duplication 实现(未内置)")
731
+
732
+
733
+ @register_input_strategy("sendinput")
734
+ def _win32_sendinput(controller):
735
+ _require_kind(controller, Win32Controller, "sendinput")
736
+ from autoclicker.clicker import Clicker
737
+ return Clicker()
738
+
739
+
740
+ @register_input_strategy("postmessage")
741
+ def _win32_postmessage(controller):
742
+ _require_kind(controller, Win32Controller, "postmessage")
743
+ from autoclicker.clicker import PostMessageClicker
744
+ return PostMessageClicker()
745
+
746
+
747
+ @register_capture_strategy("screencap")
748
+ def _adb_screencap(controller):
749
+ _require_kind(controller, ADBController, "screencap")
750
+ from autoclicker.adb import AdbCapture
751
+ return AdbCapture(controller._device())
752
+
753
+
754
+ @register_capture_strategy("raw_gzip")
755
+ def _adb_raw_gzip_capture(controller):
756
+ _require_kind(controller, ADBController, "raw_gzip")
757
+ from autoclicker.adb import AdbCapture
758
+ return AdbCapture(controller._device(), screencap_method="raw_gzip")
759
+
760
+
761
+ @register_capture_strategy("minicap")
762
+ def _adb_minicap_capture(controller):
763
+ raise StrategyUnavailable("minicap 需要向设备推送 minicap 组件(未内置)")
764
+
765
+
766
+ @register_input_strategy("adb_input")
767
+ def _adb_input(controller):
768
+ _require_kind(controller, ADBController, "adb_input")
769
+ from autoclicker.adb import AdbClicker
770
+ return AdbClicker(controller._device())
771
+
772
+
773
+ @register_input_strategy("minitouch")
774
+ def _adb_minitouch(controller):
775
+ raise StrategyUnavailable("minitouch 需要向设备推送 minitouch 组件(未内置)")
776
+
777
+
778
+ @register_input_strategy("maatouch")
779
+ def _adb_maatouch(controller):
780
+ raise StrategyUnavailable("maatouch 需要设备端 MaaTouch agent(未内置)")
@@ -1,7 +1,8 @@
1
1
  """Versioned, non-mutating migrations for legacy and canonical task JSON.
2
2
 
3
3
  0: implicit legacy fields; 1: script opt-in; 2: symbolic edges;
4
- 3: explicit variable_mode/subtask_scope (legacy/shared for existing tasks).
4
+ 3: explicit variable_mode/subtask_scope (legacy/shared for existing tasks);
5
+ 4: resource_paths bundle roots (empty for existing tasks).
5
6
  """
6
7
  from __future__ import annotations
7
8
 
@@ -9,7 +10,7 @@ import copy
9
10
  import re
10
11
  from typing import Callable
11
12
 
12
- LATEST_SCHEMA_VERSION = 3
13
+ LATEST_SCHEMA_VERSION = 4
13
14
  _MIGRATIONS: dict[int, Callable[[dict], dict]] = {}
14
15
 
15
16
 
@@ -166,6 +167,14 @@ def migrate_2_to_3(data: dict) -> dict:
166
167
  return data
167
168
 
168
169
 
170
+ @migration(3)
171
+ def migrate_3_to_4(data: dict) -> dict:
172
+ # RPA-24: bundle roots are opt-in; existing tasks resolve paths as before.
173
+ data.setdefault("resource_paths", [])
174
+ data["schema_version"] = 4
175
+ return data
176
+
177
+
169
178
  def migrate_to_latest(data: dict) -> dict:
170
179
  """Deep-copy and migrate, rejecting malformed or unsupported versions."""
171
180
  if not isinstance(data, dict):