只記未完成的事。 已出貨的內容寫進 WHATS_NEW.md,相容性變更寫進 CHANGELOG.md;完成的項目從本檔移除,不累積歷史。
狀態標記:
| 標記 | 意思 |
|---|---|
TODO |
已決定要做,尚未開始 |
WIP |
進行中,工作樹已有部分成果 |
BLOCKED |
卡在外部條件(硬體、第三方、上游套件) |
DECIDE |
需要維護者拍板才能往下走 |
CLAUDE.md §Size and complexity limits 規定:超標檔案只能列在這裡,列不進來的就是缺陷。
清單上的檔案可以改、可以變短,但不得再變長——要再長就得先拆。
行數為 2026-08-19 實測(len(text.splitlines()));webrtc_panel.py 於
2026-08-22 拆出 advanced_group.py 後降到 2,545,上限跟著往下走。
| 檔案 | 行數 | 為何還沒拆 |
|---|---|---|
utils/mcp_server/tools/_handlers.py |
4,789 | 676 個 MCP 工具的處理函式本體。與 _factories.py(表)不同,這裡是邏輯,應該依主題拆成 _handlers/ 套件(input/screen/window/file/agent…)。拆點清楚,純粹是量大。 |
gui/remote_desktop/webrtc_panel.py |
2,545 | 單一 Qt 面板,但已含連線、監視器選擇、頻寬自適應、麥克風、錄影五組互動狀態。應拆成 panel + 各控制器。 |
utils/accessibility/backends/windows_backend.py |
915 | 已拆出 windows_query.py(170)與 windows_state.py(98)。剩下的是同一套 UIA COM 生命週期管理,再拆會把 CoInitialize/介面釋放的配對邏輯切散。 |
本質豁免(依 CLAUDE.md 的「flat data tables」條款,不算既有豁免):
utils/mcp_server/tools/_factories.py(8,972,MCP 工具註冊表)、
utils/executor/action_executor.py(8,125,AC_* 分派表)、
gui/script_builder/command_schema.py(5,051,每個 AC_* 的參數 schema)、
je_auto_control/__init__.py(1,970,門面 re-export)、
gui/language_wrapper/{english,japanese,traditional_chinese,simplified_chinese}.py
(1,316/1,203/1,189/1,188,語系字串表)。
2026-08-18 重新實測時,表上原有的七列全部變長,而 CLAUDE.md 明寫
「列上的檔案不得再變長,要再長就得先拆」,所以這裡曾標成 [DECIDE]。
維護者已於 2026-08-19 拍板:接受實測數字當新基準——不為了回到舊數字而去拆
_handlers.py(4,789)與 webrtc_panel.py。上表的行數即是各自的新上限,
規則不變:只准變短,再變長就得先拆。
同一批裡有六個檔案在 2026-08-19 已經拆回線內、從表上移除,做法寫在 WHATS_NEW.md。
行數沒有任何 CI 在把關(quality.yml 只跑 ruff 與 bandit,而 ruff 只管行寬),
所以這張表只會在有人手動實測時才會被發現對不上——上次就是。
TODO — 上游仍未發 wheel(opencv-python、cryptography),但安裝本身不再是卡點
這一項曾經是 BLOCKED,而那個判斷只對一半。上游確實沒有發 wheel,
這件事到今天(2026-08-20)重新實測依舊成立;但「裝不起來」卡的不是程式,
是 pyproject.toml 無條件要求那兩個套件。實測:把 cryptography、cv2、
je_open_cv、numpy、PIL 五個全擋掉之後,import je_auto_control、executor、
MCP 工具表、cli、api.generate_code、api.create_failure_bundle 全部照常跑。
所以修法是一個 PEP 508 環境標記,三個相依共用同一個:
sys_platform != 'win32' or platform_machine != 'ARM64'
windows-11-arm 已經回到 platform-smoke.yml 的矩陣(只跑 3.14,CPython 的
官方 win-arm64 build 從 3.11 才有)。其他平台拿到的東西一個位元都沒變。
裝得起來不等於功能齊。該平台上以下四組會拋帶提示的錯誤,而不是默默失效:
| 功能 | 缺的是 | 錯誤形式 |
|---|---|---|
| 影像比對、截圖轉 BGR、螢幕錄影 | opencv-python/je_open_cv |
utils/cv2_utils/optional.py 的 require_cv2()/require_je_open_cv() 拋 RuntimeError |
動作檔加密(action_signing) |
cryptography |
_fernet_types() 拋 RuntimeError(簽章本身是 HMAC,不受影響) |
秘密金庫(${secrets.NAME}) |
cryptography |
同上 |
| ACME/TLS 發證、加密錄影 | cryptography |
模組層 ImportError 轉述(照 webrtc_transport 慣例) |
這四組在 arm64 上能不能回來,完全取決於上游:
| 依賴 | win_arm64 | 實測(2026-08-20) |
|---|---|---|
opencv-python>=4.8,<6 |
沒有 | 任何版本都沒有,pip 回的是 from versions: none。je_open_cv 自己是純 Python,但相依 opencv-python,所以一起卡——標記也必須一起下。 |
cryptography>=48.0.1 |
沒有 | wheel 只出到 46.0.3,46.0.4 起上游就不再發 win_arm64。而 >=48.0.1 是 347ec1e 為了 GHSA-537c-gmf6-5ccf(high)訂的安全下限,不能為了 arm64 降回去。 |
pillow==12.3.0 |
有 | pillow-12.3.0-cp3xx-win_arm64.whl 一直都在。曾經被寫成卡點,那是猜的,它從來不是。 |
mss/defusedxml |
有 | 純 Python。這三個加上 Pillow 就是 arm64 實際裝到的全部。 |
PySide6==6.11.1/qt-material==2.17 |
有 | [gui] extra 在 arm64 上裝得起來。 |
aiortc |
沒有 | 卡在傳遞相依 google-crc32c,與本專案的選擇無關;av 自己有 wheel。 |
重驗指令(不需要 arm64 機器,也不需要 runner):
pip install --dry-run --only-binary=:all: --platform win_arm64 --python-version 3.12 --target /tmp/probe 'opencv-python>=4.8,<6' 'cryptography>=48.0.1'兩行 ERROR: No matching distribution 就是現況。哪天其中一行不見了,就把
pyproject.toml 上那個標記拿掉(三行一起),
test/unit_test/headless/test_arm64_dependency_markers.py 會帶著你改完。
注意一個驗證上的陷阱:pip --platform 不會換掉 marker 的評估環境,
它只影響 wheel 相容性標籤,所以拿本機做 --dry-run 驗不到標記的效果(兩個
套件依舊會被要求)。能驗的是兩件事:直接評估 marker(上面那支測試在做的),
以及 windows-11-arm 那一格自己綠。
cv2 在 33 個檔、共 76 句 import,全部是函式內 lazy。這次只在兩個大家一定會
經過的門換成 require_cv2()/require_je_open_cv():wrapper/auto_control_screen.py(截圖)
與 utils/cv2_utils/template_detection.py(樣板比對)。其餘七十幾句維持原樣,在 arm64 上
會得到 ModuleNotFoundError: No module named 'cv2'。全包一輪是大面積 diff,且對呼叫端
並沒有多提供可以行動的資訊——哪天語意不足再說。
這一項曾經三度寫成「要一台 VM」——先是 portal 交握,再是 ydotool 的絕對移動落點,
中間還有負原點的擷取。三次都不是,三次都是同一個誤判:把「合成器/桌面做不到的事」
當成了「容器做不到的事」。portal 是 D-Bus 介面,誰佔住那個名字誰就是 portal;
「會吃 libinput 裝置的 seat」是 wlroots 的 WLR_BACKENDS=headless,libinput 加
LIBSEAT_BACKEND=builtin 加 SEATD_VTBOUND=0(第四個條件是 udev 要比 ydotoold 早起
來)。都已經是 CI job 了,見下面「已經有答案的」與 WHATS_NEW.md。
下次要往這裡加「需要一台 VM/真桌面」之前,先問這件事到底是誰做不到。
eis_device_pause()在 libeis 1.3.901 對 sender client 沒有送出任何東西。 對兩個 live device 呼叫 pause 再 dispatch,client 端的 ei fd 4 秒內完全沒有可讀資料。 所以LibeiBackend._on_event的DEVICE_PAUSED/DEVICE_REMOVED分支仍然沒有 peer 可以驅動。eis_verify.py把這件事寫成「要嘛有反應,要嘛根本沒被通知」, 若哪天 libeis 開始送了,client 忽略它就會當場失敗。真的合成器上會不會不一樣,未知。ei_device_start_emulating()的 sequence number 沒有被送到對面。 刻意送 4242 過去,server 讀回來是 0。我們這邊的計數本身符合標頭檔的約定 (每次呼叫至少 +1),所以不影響正確性,只是從對面驗不到。- 同意對話框「長什麼樣子、真人要按多久」。 portal 這一層現在驗到的是對話框 產生的東西:准(Response 0)、拒(Response 1)、以及一直不回答。三種我們都在真的 bus 上跑過,三種都得在自己的時限內收斂。至於真的 mutter 對話框長什麼樣、真人猶豫 三十秒會不會撞到別的東西,那是 mutter 的事,CI 裡沒有人可以去按它。
五個 job 都在 GitHub runner 上跑過了(2026-08-19,PR #481)。modprobe uinput evdev
在 runner 上載得起來,systemd-udevd 在容器裡也收得到 kernel uevent——這兩件事原本
只在本機(Docker Desktop 的 WSL2 kernel)驗過,曾經記在上面當待辦,現在有答案了。
job 一律寫成模組載不起來就明講失敗,不會靜默跳過,所以哪天 runner 的 kernel 變了會
當場紅掉。
| 面向 | 怎麼驗的 | job |
|---|---|---|
| 擷取路徑 | 真的 wlroots 合成器(sway headless,兩個上不同純色的 output),27 項 × 2 種版面 | wayland-verification |
| libei 協定層 | 真的 libeis.so.1 server 在 Unix socket 上,20 項 |
eis-verification |
| RemoteDesktop portal 交握 | 真的 dbus-daemon + 真的 liboeffis,對面是自己實作的 portal,ConnectToEIS 交出通往真 libeis 的活 fd,20 項 |
portal-verification |
| ydotool CLI | 真的 uinput 裝置,直接讀回 /dev/input/eventN,12 項 |
ydotool-verification |
| ydotool 的絕對移動落在哪 | 真的 wlroots session 吃真的 ydotool 裝置(headless,libinput + builtin seat),游標位置從 grim -c 的像素讀回,14 項 × 2 種版面 |
seat-verification |
擷取那一列的第二種版面是負原點:output HEADLESS-1 position -1280 0,
也就是「第二台螢幕在主螢幕左邊」的桌面。sway headless 收這個座標,grim 也收負的
-g,所以這件事根本不必等 GNOME VM——原本記在這裡說測不到,是把「合成器做得到的事」
當成了「容器做不到的事」。跑起來當場抓到三個真的錯:size() 回的是版面右緣不是寬度、
非 grim 層級的裁切用版面座標去裁一張以版面原點為 (0,0) 的圖、grab_logical() 一律回
原點 (0,0) 所以比對到的座標整個偏掉。修法見 WHATS_NEW.md。
portal 那一列是同一個錯誤犯第二次的結果,而它抓到的東西比前一次更嚴重:
portal.py 那條「先開 gdbus monitor、再用 gdbus call 發請求」的路
在任何真的 bus 上都不可能成功——portal 的 Response 是指名送給發出呼叫的那條
連線,兩個 gdbus 行程是兩條連線,監聽的那條永遠不是收件人。在真的 dbus-daemon 上
量到的就是這樣:呼叫看得到,回答永遠等不到,每次都走到 30 秒逾時。修法見
WHATS_NEW.md。
五者都不需要合成器以外的東西,更不需要 GNOME VM。libei 這一層驗掉的包含
capability enum 值與 variadic ei_seat_bind_capabilities、event-type enum 值、
start_emulating → 事件 → frame 的實際上線內容、live context 的 teardown
安全性(原本每個行程漏一個 context + 一個 fd,已修)、以及絕對指標的座標空間
(region offset 讀得回來且含在座標裡、region 外的移動被靜靜丟掉、負原點的版面要
正規化)。portal 這一層驗掉的是四個呼叫的順序與 client 自己預測的 request path、
SelectDevices 收到的裝置遮罩(也就是使用者被要求同意的範圍)、交回來的 fd 真的
承載得起一個 EI session,以及六種拒絕路徑各自都要 fail closed。ydotool 這一層驗掉的是
click 位元遮罩、拆邊的 press/release、mousemove --absolute 的實際上線內容、
捲動正負號與軸向,以及 mouse/keyboard 自己組出來的 argv。seat 這一層驗掉的是
--absolute 到底相對於哪裡(版面左上角,不是版面座標的 (0, 0))、關掉加速度後
一像素對一像素、沒轉換的 (0, 0) 會打到隔壁螢幕、set_position 減掉的正好是原點、
以及預設 profile 下的 2 倍加速。
liboeffis是獨立的二進位套件,libei1不會把它帶進來。 Debian trixie 有liboeffis1(1.3.901-1,liboeffis.so.1,連 libsystemd 的 sd-bus)—— 這裡原本寫「Debian trixie 沒有」,是錯的,已實測更正。Arch(1.6.0)與 Fedora 也有。 但因為它不是libei1的相依,只裝 libei 的機器上 portal 快速路徑仍然是關閉的,connect()會退到$XDG_RUNTIME_DIR/eis-0socket,GNOME/KDE 不開那個 socket → 退回 ydotool。所以要用 libei 快速路徑,liboeffis得自己裝。- 而那條退路本身,在同一批發行版上原本是壞的——0.1.x 對本專案送的 argv 回傳 0 卻不送任何事件。已於 2026-08-19 擋掉,見 CHANGELOG 與 WHATS_NEW;此處無待辦。
緩解:驗不到的擷取部分有逃生門——JE_AUTOCONTROL_WAYLAND_CAPTURE_COMMAND 讓操作者
直接指定自己的擷取指令({output} 會被換成暫存 PNG 路徑),優先於所有偵測。
BLOCKED — 上游(libei 1.3.901)
linux_wayland/libei.py 的 _teardown 刻意每個行程漏一個 context 與一個 fd,
因為對一個還沒完成交握的 handle 呼叫 ei_unref 會直接 SIGSEGV。
這是在驅動使用者桌面的函式庫裡的 crash,所以寧可漏也不能當。
這條本來就該在這裡。 兩支 verify 腳本都會印 *** REVISIT *** 並叫讀者
來翻 Progress.md,而這裡一直什麼都沒寫:
docker/libei_verify.py:「The workaround inLibeiBackend._teardowncan probably go」docker/eis_verify.py:「ei_unrefnow SEGFAULTS on a live context too」
重驗方式就是跑那兩支腳本(eis-verification job 已經在跑);哪天 banner 不再
出現,就把 _teardown 的迴避拿掉。形狀與 arm64 那條一樣:卡上游、有一行重驗。
DECIDE — 兩半都做完了,只剩「下一個覆蓋率目標是多少」要維護者拍板
原本這一條記的是兩個只存在於 pyproject.toml 註解裡、沒有任何機制的承諾。
2026-08-21 把機制補上了(做法見 WHATS_NEW.md),兩半也都走完了:
型別契約的豁免清單 2026-08-22 清空,平台縫最後兩個名稱(keyboard/mouse)
2026-08-23 拿到合約;覆蓋率那半發現不是爬得不夠,是量測起點錯了,修正後地板
從 50 提到 69。
這一條還留著,是因為只剩一個問題要維護者回答:下一個覆蓋率目標值是多少。 另外兩節留著是因為它們記的那幾個坑之後還會踩到。
DECIDE — 地板已經設成修正後矩陣的最低那一格(69);下一個目標值要維護者定
fail_under 一度從 35 提到 50,理由寫在 pyproject.toml。那兩個數字都低了大約
24 點,而原因不在測試,在量測的起點:
quality.yml 用的是 pytest --cov,而本套件註冊了 pytest11 entry point。
pytest 在載入外掛時就會 import je_auto_control.utils.pytest_plugin.plugin——
要 import 那個子模組,Python 必須先執行 je_auto_control/__init__.py,也就是門面,
連帶把好幾百個模組拉進來。pytest-cov 是在那之後才開始量的,所以那幾百個模組的
import 期程式碼(def 行、類別本體、常數、兩張大分派表)全部被記成「從沒執行過」。
2026-08-23 實測,同一套測試、同一份 [tool.coverage.run] 設定,只差開始的時機:
| 量法 | 總覆蓋率 |
|---|---|
pytest --cov=je_auto_control |
52.22% |
coverage run -m pytest |
72.05% |
差 11,962 個 statement。受害最深的正好是最大的幾個檔:action_executor.py +786、
_handlers.py +684、門面自己 +369、_factories.py +209。
一個註冊了 pytest 外掛的套件,沒辦法用 pytest --cov 量自己。
quality.yml 已經改成 coverage run -m pytest(先於 pytest 載入任何東西),
test/unit_test/headless/test_coverage_measurement.py 把這件事釘住——因為兩種寫法的
差別在綠色的建置裡看不出來:改回去會白送 24 點,而每一格照樣是綠的。
修正後的九宮格已經量出來了(2026-08-23,本 PR 的 run):
| 最低 | 最高 | |
|---|---|---|
修正前(pytest --cov) |
50.26%(ubuntu-22.04/3.10) | 51.69%(windows-2022/3.14) |
修正後(coverage run) |
69.67%(ubuntu-22.04/3.14) | 70.97%(windows-2022/3.12) |
地板因此設成 69——取最低那一格往下取整,與當初 50 取自 50.26% 是同一個慣例。
[tool.coverage.report] 的 precision 也從預設的 0 提到 2:預設精度下九格全部印
「70%」,而它們其實是 69.67 到 70.97,害得這次的地板得去 XML artifact 裡撈。
順帶把 fail_under 的容差從一整個百分點縮到 0.01。
地板只有一個家(pyproject.toml 的 fail_under),quality.yml 不再另外抄一份。
Windows 是高的那一角,因為門面 import 進來的是它自己那個平台的後端; 換句話說剩下的那 30 點裡,有一部分是任何單一平台都拿不到的。
還要決定的:70 是舊的目的地,現在等於已經到了,下一個目標值該由維護者定。
真正還低的是哪幾塊,現在有實測(本機 Windows/3.14,修正後):
utils/remote_desktop 35%、utils/mcp_server 34%(_handlers.py 自己 10%)、
utils/executor 41%、utils/accessibility 29%、wrapper/window_backends 10%。
這四塊的共同形狀是「一大堆薄轉接函式包著已經測過的無頭函式」,所以往上爬的方式
是走註冊表逐一驅動,而不是一支一支手寫測試。
TODO → 完成(2026-08-22)
範圍不再是兩條路徑,而是整包減去一張只准變少的清單
(test/verify/typing_contract_exempt.txt)。差別在於預設值:路徑清單只有人想到才會長,
新模組預設在圈外;現在新模組預設就在契約裡。
2026-08-22 那張清單降到零:je_auto_control/ 的 1,018 個檔案在
win32/linux/darwin 三個目標上全部乾淨。清掉 136 個模組的過程與每一群的做法寫在
WHATS_NEW.md;這裡只留下之後還用得到的五件事:
- 反覆出現的五種形狀:mixin 讀取宿主的成員(用類別本體裡的
if TYPE_CHECKING:宣告,執行期會被剝掉)、self._x = None沒有標注 (mypy 會把屬性的型別判成None)、callable被當成型別用、x: SomeType = None的隱含 Optional、以及掉了長度的 tuple。 - 攔截用的 tuple 必須標成
Tuple[Type[BaseException], ...],而且要收成一個 模組常數——except (A, B, *TUPLE)的星號解包 mypy 跟不進except。 # type: ignore只有當它是那一行的第一個註解時才生效(已實測),所以有# nosec的行要把它放前面。cv2的 stub 會隨版本變:pyproject.toml把它列在「ship no stubs 的基礎相依」 底下,但 opencv-python 有附.pyi,閘門會去讀。實測 4.13.0:MSER_create、ORB_create、VideoWriter_fourcc執行期都在、stub 裡都沒有。>=4.8,<6範圍內 版本一換,判定就可能跟著動——與 numpy 那條註解同一類的坑。- 要讓 mypy 剪掉一個分支,整條條件都得是它讀得懂的:
sys.platform == "..."與.startswith("...")算,in [...]不算,而只要裡面混進一個函式呼叫 (is_windows()),or/and整條就變成未知、兩邊都會被檢查。所以platform_wrapper那種「問platform_id才知道綁哪個後端」的分支沒辦法 讓自己被剪掉——它綁的三種形狀互不相容,一個型別蓋不住。做法是那兩個名稱進來時 先落在私有的Any上、出去時才標合約:後端那一側在_platform_*.py被檢查, 呼叫端那一側在auto_control_*.py被檢查,中間那一接頭本來就沒有東西可查。 細節見wrapper/backend_contract.py的 docstring。
清單現在只有標頭、沒有任何條目。它變長就是退步,typing_contract_verify.py
會在有人讓它變長時紅掉。
原本這裡是一條 DECIDE,寫的是「windows/ 底下 8 個模組」。重新實測後是
16 個模組,而且一半不在 windows/ 底下(utils/trash/、utils/app_idle/、
utils/file_assoc/、utils/idle_keepawake/、utils/lock_session/、
utils/session_guard/、utils/usb/passthrough/key_provider.py、
gui/main_window.py)——這一點直接否掉了原本推薦的那一條(照目錄決定用哪個平台量,
分不到這八個)。
維護者選了逐行 # type: ignore 附理由,實際只用了 28 行(原本估的 58
是把同一行在 linux 與 darwin 各算了一次)。做法見 WHATS_NEW.md,
兩件必須實測的事記在這裡免得再踩:
- mypy 只認每一行的第一個註解——接在既有
# nosec後面的# type: ignore完全不生效(已實測)。所以有# nosec的那兩行,marker 放前面、兩個理由併成一句。 - 有九行放不進 120 字元,是改寫而不是把理由砍到看不懂:括號換行時 marker 跟著
左括號走,兩處先把值取出來成區域變數(DPAPI 的
last_error、input hook 的kernel32),讀起來比原本的一行式更清楚。
十六個模組事後都在真的 Windows 機器上重新 import 並實際呼叫過
(dpapi_available()、_windows_locked()、check_key_is_press)——
只有型別檢查器驗過的改寫等於沒人驗過。
有一件事別再踩:這個閘門的判定不能隨環境浮動。裝了 [gui]/[webrtc] 的開發機
與乾淨的 pip install -e . 曾經對 38 個模組看法不同(36 個 Qt 模組只在 PySide6
不在時才過關,2 個只在 babel/pytest 不在時才失敗)。修法是把所有非基礎相依的
第三方模組壓成 Any;其中 follow_imports = "skip" 對 .pyi 無效、必須同時開
follow_imports_for_stubs,正是 numpy 那條註解早就寫過的坑。