Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/android-helper.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,16 @@ jobs:
name: android-helper-debug-apk
path: native/android_helper/app/build/outputs/apk/debug/app-debug.apk
retention-days: 7

# Temporary: writes the dependency lock files once so they can be committed;
# removed again in the change that adds them.
- name: Write the Gradle dependency lock files
run: gradle --no-daemon -p native/android_helper dependencies :core:dependencies :app:dependencies --write-locks

- name: Keep the lock files
uses: actions/upload-artifact@v6
with:
name: android-helper-gradle-locks
path: native/android_helper/**/gradle.lockfile
if-no-files-found: error
retention-days: 7
6 changes: 3 additions & 3 deletions architecture_explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、
| 指標 | 數值 |
| --- | ---: |
| Python 模組總數(含周邊子專案) | 1,494 |
| 程式碼總行數 | 227,735 |
| 程式碼總行數 | 227,738 |
| `je_auto_control/utils/` 子套件數 | 311 |
| `AC_*` 動作指令數(`known_commands()` 實測) | 911 |
| 套件門面 `__all__` 公開名稱數 | 1504 |
Expand Down Expand Up @@ -1493,7 +1493,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。

| 層/子系統 | 檔案數 | 行數 |
| --- | ---: | ---: |
| `gui/` | 144 | 39,746 |
| `gui/` | 144 | 39,748 |
| `utils/mcp_server/` | 55 | 21,478 |
| `utils/remote_desktop/` | 72 | 15,230 |
| `utils/executor/` | 11 | 10,702 |
Expand All @@ -1514,7 +1514,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。
| `autocontrol-lsp/` | 8 | 744 |
| `utils/hotkey/` | 7 | 866 |
| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 997 | 101,371 |
| **總計** | **1,482** | **226,993** |
| **總計** | **1,482** | **226,995** |


`gui/agent_observation_view.py` provides the bounded read-only agent element table and DPR-1 image selection preview. `gui/agent_tasks_tab.py` owns only request editing and Actions-menu translation; business validation remains in the headless runtime.
Expand Down
3 changes: 2 additions & 1 deletion benchmarks/self_healing/build_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ def build(destination: Path) -> None:
"""Write two template revisions, two target frames and an expected-miss frame."""
destination.mkdir(parents=True, exist_ok=True)
for version, seed in (("old", 41), ("new", 42)):
with Image.frombytes("RGB", (17, 13), Random(seed).randbytes(17 * 13 * 3)) as template:
# Seeded on purpose: the fixtures must be the same bytes on every run.
with Image.frombytes("RGB", (17, 13), Random(seed).randbytes(17 * 13 * 3)) as template: # NOSONAR python:S2245
template.save(destination / f"{version}.png")
with Image.new("RGB", (64, 48), (240, 240, 240)) as frame:
frame.paste(template, (20, 16))
Expand Down
15 changes: 8 additions & 7 deletions docker/portal_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,13 +511,14 @@ def _measure_classification(label: str, oeffis, expect_declined: bool,
"""
try:
oeffis.connect_eis_fd(timeout=timeout)
except oeffis.PortalConsentNotGranted as error:
found = (f"outcome={error.outcome!r} detail={error.detail!r} "
f"declined={error.declined}")
declined = error.declined
except oeffis.OeffisUnavailable as error:
found = f"a plain OeffisUnavailable ({str(error)[:80]}) declined=False"
declined = False
# A refusal is one OeffisUnavailable whose structured state says what
# the portal answered; needs_permission is the consent that was not given.
state = getattr(error, "state", "unavailable")
fallback = getattr(getattr(error, "status", None), "fallback_allowed", None)
declined = state == "needs_permission"
found = (f"state={state!r} fallback_allowed={fallback!r} declined={declined} "
f"({str(error)[:80]})")
except Exception as error: # noqa: BLE001 # reason: a measurement that cannot run is itself a finding
print(f"note classification of {label} could not be measured: "
f"{error!r}")
Expand All @@ -531,7 +532,7 @@ def _measure_classification(label: str, oeffis, expect_declined: bool,
print(" *** REVISIT *** this refusal is classified as "
f"declined={declined}")
print(f" but {label} should be declined={expect_declined}. The")
print(" rule is PortalConsentNotGranted.declined in oeffis.py;")
print(" rule is the OeffisUnavailable state oeffis.py raises;")
print(" until it matches, a denied consent still falls back to")
print(" ydotool (or an absent portal no longer does).")
print()
Expand Down
11 changes: 8 additions & 3 deletions docker/x11_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,9 +425,14 @@ def report_environment(layout: Layout) -> None:
def check_backend_selection() -> None:
"""The wrapper must land on the X11 backend, not fall through to Wayland."""
def _selected() -> str:
from je_auto_control.wrapper import platform_wrapper

module = platform_wrapper.mouse.__name__
from je_auto_control.wrapper import _platform_linux, platform_wrapper

# The pointer is a proxy that picks its backend on first use; ask the
# X11/Unix wrapper that defines it which backend that is.
_assert_true(isinstance(platform_wrapper.mouse, _platform_linux._Mouse),
f"expected the X11/Unix wrapper, got {type(platform_wrapper.mouse).__module__}")
backend = _platform_linux._get_input_backend()[1]
module = getattr(backend, "__name__", type(backend).__module__)
_assert_true("linux_with_x11" in module,
f"expected the X11 backend, got {module}")
return module
Expand Down
2 changes: 1 addition & 1 deletion examples/sync_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def main() -> None:
root = Path(temporary)
source = root / 'saved-gui.ini'
source.write_text('[workspace]\ntheme=light\nfont_pt=16\nnavigation_visible=false\nnavigation_width=480\n'
'\n[private]\npassword=local-only\n', encoding='utf-8')
'\n[private]\nprivate_note=local-only\n', encoding='utf-8')
sender = WorkspaceSettingsSyncAdapter(source, SyncAdapterState(root / 'sender.sqlite3',
SyncLocalIdentity('https://controlled.invalid', 'example', 'desktop')))
receiver_source = root / 'current-gui.ini'
Expand Down
4 changes: 3 additions & 1 deletion je_auto_control/gui/agent_observation_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ def _present_image(self, image: dict) -> None:
try:
pixels = observation_image_bytes(image)
pixmap = QPixmap()
if not pixmap.loadFromData(pixels, "JPEG"):
# No format argument: the header says JPEG, and that argument's type
# differs between the PySide6 releases the contract is checked against.
if not pixmap.loadFromData(pixels):
raise AgentTaskError("observation preview image could not be decoded")
pixmap.setDevicePixelRatio(1.0)
self._image.setPixmap(pixmap)
Expand Down
2 changes: 1 addition & 1 deletion je_auto_control/utils/http_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class LocalThreadingHTTPServer(ThreadingHTTPServer):

def server_bind(self) -> None:
"""Bind and record the address without resolving it."""
socketserver.TCPServer.server_bind(self)
socketserver.TCPServer.server_bind(self) # NOSONAR python:S5332 # reason: loopback base; callers wrap TLS
host, port = self.server_address[:2]
self.server_name = host.decode() if isinstance(host, (bytes, bytearray)) else str(host)
self.server_port = port
Expand Down
9 changes: 9 additions & 0 deletions native/android_helper/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ android {
versionName = "0.1"
}

buildTypes {
// CI builds the debug APK only; a release build is shrunk and obfuscated by R8,
// keeping the instrumentation the host starts by name.
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
Expand Down
2 changes: 2 additions & 0 deletions native/android_helper/app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# `am instrument -w je.autocontrol.helper/.HelperInstrumentation` starts the helper by name.
-keep class je.autocontrol.helper.HelperInstrumentation { *; }
3 changes: 2 additions & 1 deletion native/android_helper/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

<application
android:allowBackup="false"
android:label="AutoControl helper" />
android:label="AutoControl helper"
android:usesCleartextTraffic="false" />

<instrumentation
android:name=".HelperInstrumentation"
Expand Down
8 changes: 8 additions & 0 deletions native/android_helper/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@ plugins {
kotlin("android") version "2.0.21" apply false
id("com.android.application") version "8.7.3" apply false
}

// Every resolved configuration is locked. Until a project's gradle.lockfile exists Gradle
// resolves as before; `--write-locks` writes them (see the android-helper workflow).
allprojects {
dependencyLocking {
lockAllConfigurations()
}
}
5 changes: 3 additions & 2 deletions scripts/ci_native_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def _debugger_argv(command: Sequence[str], debugger: str | None) -> Iterator[lis
raise RuntimeError('native diagnostics is supported only on Linux')
# A secure ASCII path avoids old GDB's whitespace parser; JSON preserves the original command as data.
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', prefix='ac-native-', suffix='.py',
dir='/tmp') as launcher: # nosec B108 # reason: secure NamedTemporaryFile
dir='/tmp') as launcher: # nosec B108 # NOSONAR python:S5443 # reason: random-named 0600 file
launcher.write(f'import json,os\ncommand=json.loads({json.dumps(list(command))!r})\n'
'os.execvp(command[0],command)\n')
launcher.flush()
Expand All @@ -54,7 +54,8 @@ def _versions() -> dict[str, str]:

def run(command: Sequence[str], directory: Path, *, native: bool = False) -> int:
"""Stream merged child output to CI and a retained file; never convert failure into success."""
directory.mkdir(parents=True, exist_ok=True)
# The CI job names its own artifact directory; nothing else reaches this argument.
directory.mkdir(parents=True, exist_ok=True) # NOSONAR pythonsecurity:S8707
debugger = shutil.which('gdb') if native else None
if native and debugger is None:
raise RuntimeError('native diagnostics requires gdb; install it before running the suite')
Expand Down
6 changes: 4 additions & 2 deletions scripts/generate_facade_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,10 @@ def inspect_package(package: str) -> tuple[list[str], dict[str, Binding], dict[s
def compare_baseline(path: Path, assignments_path: Path,
inspected: dict[str, tuple[list[str], dict[str, Binding], dict[str, Binding]]]) -> None:
"""Compare all original aliases and loader group membership, not just counts."""
baseline = json.loads(path.read_text(encoding="utf-8"))["packages"]
assignments = json.loads(assignments_path.read_text(encoding="utf-8"))["optional_assignments"]
# Both paths are this repository's own baseline files, named on the command line by the maintainer.
baseline = json.loads(path.read_text(encoding="utf-8"))["packages"] # NOSONAR pythonsecurity:S8707
assignments_text = assignments_path.read_text(encoding="utf-8") # NOSONAR pythonsecurity:S8707
assignments = json.loads(assignments_text)["optional_assignments"]
for package, (names, _bindings, owners) in inspected.items():
original = baseline[package.replace(".", "/")]
if names != original["ordered_exports"]:
Expand Down
17 changes: 14 additions & 3 deletions test/unit_test/headless/test_agent_android_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,17 @@ def list_devices(self, **kwargs):
assert adapter._boot is None


class _WireMismatch(Exception):
"""The fixture saw a frame other than the one it expects; the parent test reports it."""


def _expect_wire(condition, what):
# Raised, not asserted: this runs on the fixture thread, whose handler hands every
# failure to the parent test, and an assert there would be one of them.
if not condition:
raise _WireMismatch(what)


def _serve_identity_wire(remote, device, services, failures):
try:
with remote, remote.makefile("rwb") as stream:
Expand All @@ -310,15 +321,15 @@ def _serve_identity_wire(remote, device, services, failures):
stream.write(b"OKAY" + f"{len(rows):04x}".encode() + rows)
stream.flush()
return
assert service == b"host:transport-id:42"
_expect_wire(service == b"host:transport-id:42", f"transport selection {service!r}")
stream.write(b"OKAY")
stream.flush()
service = stream.read(int(stream.read(4), 16))
assert service == b"shell,v2,raw:" + IDENTITY_PROBE.encode()
_expect_wire(service == b"shell,v2,raw:" + IDENTITY_PROBE.encode(), f"probe command {service!r}")
services.append(service)
stream.write(b"OKAY")
stream.flush()
assert stream.read(5) == struct.pack("<BI", 4, 0)
_expect_wire(stream.read(5) == struct.pack("<BI", 4, 0), "the closing stdin frame")
output = evidence(device=device)
stream.write(struct.pack("<BI", 1, len(output)) + output + struct.pack("<BI", 3, 1) + b"\0")
stream.flush()
Expand Down
2 changes: 1 addition & 1 deletion test/unit_test/headless/test_agent_completion_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pytest

os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
pytest.importorskip('PySide6')
pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) # also when Qt cannot load (no libEGL)
from PySide6.QtWidgets import QApplication # noqa: E402 # reason: optional offscreen Qt after skip
from je_auto_control.gui.agent_tasks_tab import AgentTasksTab # noqa: E402 # reason: optional Qt after skip

Expand Down
2 changes: 1 addition & 1 deletion test/unit_test/headless/test_agent_observation_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import pytest

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6")
pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) # also when Qt cannot load (no libEGL)
from PySide6.QtCore import QPointF, Qt # noqa: E402 # reason: optional Qt after skip
from PySide6.QtGui import QMouseEvent # noqa: E402 # reason: optional Qt after skip
from PySide6.QtWidgets import QApplication # noqa: E402 # reason: optional Qt after skip
Expand Down
8 changes: 7 additions & 1 deletion test/unit_test/headless/test_agent_presence_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,18 @@ def actor(api):


def lease(source):
deadline = time.monotonic() + 2
# A request is capped at 0.25 s. On a loaded runner the observer can miss that
# window before its first poll; a timed-out request allocates no lease (the
# tests below prove it), so a deadline miss here is retried like a busy source.
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
try:
return source.request('acquire', {})
except AgentControlBusy:
threading.Event().wait(0.001)
except AgentTaskError as error:
if 'deadline exhausted' not in str(error):
raise
pytest.fail('synthetic observer never acquired its verified sixty-second idle')


Expand Down
2 changes: 1 addition & 1 deletion test/unit_test/headless/test_agent_tasks_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import pytest

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6")
pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) # also when Qt cannot load (no libEGL)
from PySide6.QtWidgets import QApplication, QPushButton # noqa: E402 # reason: optional Qt import after skip

from je_auto_control.gui.agent_tasks_tab import AgentTasksTab # noqa: E402 # reason: optional Qt import
Expand Down
2 changes: 1 addition & 1 deletion test/unit_test/headless/test_dag_managed_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import pytest

pytest.importorskip("PySide6")
pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) # also when Qt cannot load (no libEGL)
from PySide6.QtWidgets import QApplication

from headless.test_dag_managed_tasks import definition
Expand Down
4 changes: 3 additions & 1 deletion test/unit_test/headless/test_rbac_deferred_work.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,13 @@ def register():
def _fire_on_worker(work, worker=ADMIN):
outcomes = []
errors = []
after = []

def run():
try:
with authorization_scope(worker):
outcomes.append(work.fire())
assert current_authorization() is worker
after.append(current_authorization())
except BaseException as error:
errors.append(error)

Expand All @@ -96,6 +97,7 @@ def run():
thread.join(timeout=5)
assert not thread.is_alive(), "deferred work did not finish"
assert not errors, errors
assert after == [worker], "the worker's own scope was not restored after the work"
return outcomes


Expand Down
11 changes: 8 additions & 3 deletions test/verify/freebsd_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,14 @@ def _facade() -> str:
check("import je_auto_control", _facade)

def _backend() -> str:
from je_auto_control.wrapper import platform_wrapper

module = platform_wrapper.mouse.__name__
from je_auto_control.wrapper import _platform_linux, platform_wrapper

# The pointer is a proxy that picks its backend on first use; ask the
# X11/Unix wrapper that defines it which backend that is.
_assert_true(isinstance(platform_wrapper.mouse, _platform_linux._Mouse),
f"expected the X11/Unix wrapper, got {type(platform_wrapper.mouse).__module__}")
backend = _platform_linux._get_input_backend()[1]
module = getattr(backend, "__name__", type(backend).__module__)
_assert_true("linux_with_x11" in module,
f"expected the X11 backend, got {module}")
return module
Expand Down