Notepad++ 的編輯區是 Scintilla——一個被大量第三方編輯器內嵌的控制項。它在 UIA 樹裡長這樣:
<Pane class='Scintilla' name='' patterns=[]>
patterns=[]。沒有 Value、沒有 Text、什麼都沒有。所以 find_text_input() 的 UIA 控制項型別階梯完全碰不到它——它會跑完 20 秒的階梯然後失敗,而編輯器就在那裡。
Scintilla 有自己的訊息介面:SCI_GETLENGTH、SCI_GETCURRENTPOS、SCI_GETTEXT……幾百個。所以我以為這一題的答案顯然是「送 SCI_* 訊息」。
我連錯三次。
SCI_GETTEXTSCI_GETTEXT 要一個緩衝區:
SCI_GETTEXT(position length, char *text) → position
char *text——一個指標。我送過去,回來的是 0,緩衝區沒有被填。
原因很直接:我的指標指向我自己的位址空間。接收方——Notepad++ 的處理程序——拿到那個數值之後,把它當成它自己位址空間裡的位址去解參照。那是兩個不同的地方。
VirtualAllocEx 可以救標準解法應該是這樣:在目標處理程序裡配置記憶體,把那個位址送過去,再用 ReadProcessMemory 讀回來。
remote = kernel32.VirtualAllocEx(handle, None, size, MEM_COMMIT, PAGE_READWRITE)
user32.SendMessageW(hwnd, SCI_GETTEXT, size, remote)
kernel32.ReadProcessMemory(handle, remote, buffer, size, None)
也不行。 緩衝區還是空的。
這一步花了我最久,因為技術本身是對的——那確實是跨處理程序傳緩衝區的正規做法。錯的是我對「誰在做 marshalling」的理解。
WM_GETTEXT 的用法跟 SCI_GETTEXT 幾乎一樣,也要一個指標。而 WM_GETTEXT 跨處理程序完全正常。差別在這裡:
USER32 認識系統訊息。 它知道
WM_GETTEXT的lParam是一個指向緩衝區的指標,所以當SendMessage跨越處理程序邊界時,它會幫你把緩衝區複製過去、再把結果複製回來。USER32 不認識
SCI_*。 那些是WM_USER + n,對 USER32 來說只是兩個整數。它把數值原封不動送過去,不做任何 marshalling。
所以 VirtualAllocEx 為什麼沒用也清楚了:問題從來不是「目標處理程序裡沒有可用的記憶體」。問題是接收端的程式碼(Scintilla 自己)拿到指標之後直接解參照——它沒有任何理由去 ReadProcessMemory,因為在正常使用情境下,呼叫它的人跟它在同一個處理程序裡。
要真正做到,得把程式碼注入到目標處理程序裡執行。那超出一個測試函式庫該做的事的邊界很遠。
SCI_GETCHARAT 是答案SCI_GETCHARAT(position) 回傳單一個字元的位元組值——不用指標。所以它跨處理程序可以用。逐字元讀完整份文件,理論上可行。
我甚至開始寫了。然後才回頭做一件應該最早做的事:試試 get_value()。
edit = win.find_text_input(timeout=30.0)
print(edit.get_value()) # 'alpha\r\nbeta\r\ngamma\r\n'
它一直都可以。 get_value() 在沒有 Value pattern 的時候會退回 WM_GETTEXT,而 WM_GETTEXT 是系統訊息,USER32 幫我 marshal 了。
我花了三輪去解一個不存在的問題。真正的問題只有一個:階梯找不到 Scintilla。
DEFAULT_TEXT_INPUT_LADDER: tuple[dict, ...] = (
{"class_name": "RichEditD2DPT"}, # Win11 tabbed Notepad
{"class_name": "Edit"}, # classic Win32 edit control
{"class_name": "Scintilla"}, # ← 這一行
{"control_type_id": 50030}, # UIA Document
{"control_type_id": 50004}, # UIA Edit
)
find_text_input() 在 Notepad++ 上從「跑完 20 秒階梯後失敗」變成 718ms 成功。
一個 class name。 而我在那之前寫了兩份被丟掉的實驗程式碼。
SCI_* 到底有沒有用有,但只有回傳純量的那些。它們不需要指標,所以跨處理程序沒問題,而且它們回答的是 WM_GETTEXT 答不出來的問題:
view = ScintillaView.from_element(edit)
view.length # 位元組數,不是字元數
view.line_count
view.current_position # 游標在哪
view.selection_range # (7, 11)
view.eol_mode # EolMode.CRLF
view.tab_width
view.code_page
view.is_modified # 有沒有未儲存的變更
is_modified 特別有用:「打了字但還沒存」是一個沒有其他方法問得到的狀態。 讀檔案問不到(檔案還是舊的),讀緩衝區也問不到(內容看起來就是新的)。
十四個常數,每一個都是實際送過去、確認回傳值合理才寫進去的。wintegrate 刻意不提供逐字元讀取器——get_value() 已經能讀,而 SCI_GETCHARAT 迴圈只會是一條比較慢、比較容易錯的第二條路。
分界線是「這個訊息 USER32 認不認識」,而它比 Scintilla 廣得多:
| 訊息類型 | 跨處理程序帶指標時 | 例子 |
|---|---|---|
系統訊息(< WM_USER) |
USER32 幫你 marshal | WM_GETTEXT、WM_SETTEXT |
自訂訊息(>= WM_USER) |
原封不動送數值 | SCI_*、任何 app 自訂的 |
所以下次看到一個控制項有豐富的訊息介面,先問一句:這些訊息裡有沒有指標?如果有,它們是 WM_USER 之後的嗎? 是的話,跨處理程序那條路就不通,不要在 VirtualAllocEx 上花時間。
主視窗有 35 個 Button,全部沒有 automation_id——工具列是 MFC 的,只剩在地化的名稱可用(新增(N) / New)。位置也不是備案:依 (top, left) 排序之後最前面的是兩個 0x0 的幽靈按鈕,點下去什麼都不會發生。
但它的 Win32 對話框控制項帶數字 control id。按 Ctrl+F 開啟尋找對話框:
Button aid='1' name='尋找下一個' ← IDOK
Button aid='1614' name='計數'
Button aid='2' name='關閉' ← IDCANCEL
數字不會被翻譯。 所以在這個應用程式裡,唯一與語言無關的按鈕測試路徑,是走它的 Win32 對話框而不是它的工具列。
patterns=[] 的 Pane,控制項型別階梯碰不到它SCI_* 是 WM_USER + n,USER32 不會 marshal 它們的指標
VirtualAllocEx 不能救,因為接收端的程式碼直接解參照那個指標WM_GETTEXT 可以,因為它是系統訊息——而 get_value() 本來就會退回它find_text_input() 變成 718msSCI_* 仍有價值,尤其 is_modified
明天換 WinMerge,而它的編輯窗格連 WM_GETTEXT 都不回應。那時候唯一剩下的證據是像素。