iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Software Development

Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影系列 第 2

Day 2:你的視窗不是你的視窗:Session、Window Station、Input Desktop 的三層蛋糕

  • 分享至 

  • xImage
  •  

昨天講的四份診斷產物,全都建立在一個沒有講出來的前提上:
它們看到的那張桌面,就是使用者看到的那張。
而那個前提不會自動成立——它不成立的時候,也不會有任何錯誤訊息。

今天要拆的就是那個前提失效的時候會發生什麼事:

你的自動化程式碼全部執行成功,沒有任何例外,但畫面上什麼都沒發生。

這不是 bug,也不是時序問題。這是你對著錯的那張桌面說話。


一個沒有錯誤訊息的失敗

先看症狀。你寫了一段再單純不過的程式:

hwnd = find_window("記事本")   # 有值
set_foreground(hwnd)          # 回傳 True
send_keys("hello")            # 回傳 True

三行全部成功。然後你去看畫面,記事本裡是空的。

如果你在這裡開始加 sleep、換 API、懷疑是不是 UIA 有 bug,你會浪費非常多時間。因為問題根本不在這三行,而在發動它們的那個執行緒,掛在哪裡

Windows 的視窗不是掛在「作業系統」底下的。它掛在一個三層結構裡,而 SendInput 和 UIA 都只對你目前所在的那一層生效。


第一層:Session

Session 是使用者登入的單位。你在螢幕前登入,拿到 Session 1;另一個人用遠端桌面登入,拿到 Session 2。兩邊的視窗互相看不見。

這裡有一條從 Windows Vista 就存在、但很多人不知道的規則:服務(Service)一律跑在 Session 0,而 Session 0 沒有接任何螢幕。 這叫 Session 0 Isolation,設計目的是安全——不讓一個以 SYSTEM 權限跑的服務去操作使用者桌面上的視窗。

所以「把測試包成 Windows 服務讓它開機自動跑」這個直覺,會直接撞牆。服務看得到自己開的視窗,但那些視窗永遠不會出現在任何人的螢幕上。

我在這個系列的開發過程中,用比較痛的方式重新學到這件事。我在個人電腦上開了一台 Windows 11 ARM64 虛擬機來重現 CI 環境,然後 SSH 進去跑 GUI 測試:

$ ssh win 'python -m pytest tests/'
...
WindowDiscoveryTimeoutError: Window failed to appear within 30.0s

程式跑起來了、處理程序也確實建立了,但 UIA 什麼都找不到。原因是 SSH 給你的登入 session 沒有互動式桌面。最後的解法是在 guest 裡建一個 scheduled task,讓它以「使用者已登入的那個 session」執行,SSH 只負責觸發它——等於是隔著一道牆去借一張桌面來用。

同樣的道理解釋了為什麼 CI 映像檔的 autounattend.xml 一定要設定自動登入:

<!-- A GUI test needs a real interactive session: without autologon the
     machine sits at the lock screen and every UIA call sees nothing. -->
<AutoLogon>
  <Enabled>true</Enabled>
  ...
</AutoLogon>

沒有自動登入,機器就停在鎖定畫面。而鎖定畫面後面,沒有你的桌面。


第二層:Window Station

一個 Session 裡可以有多個 Window Station。Window Station 是剪貼簿、原子表(atom table)和 Desktop 的容器,同時也是一道安全邊界。

關鍵在於:一個 Session 裡只有一個 Window Station 是互動式的,它的名字叫 WinSta0 只有 WinSta0 連著鍵盤、滑鼠和顯示器。其他 Window Station(例如服務用的 Service-0x0-3e7$)能建立視窗、能收訊息,但沒有任何實體輸入裝置連著它們。

這就是為什麼 Session 0 的服務即使開了視窗也沒人看得到——它不只在錯的 Session,通常還在錯的 Window Station。


第三層:Desktop,以及哪一張在接收輸入

一個 Window Station 裡又可以有多個 Desktop。WinSta0 底下標準有三張:

Desktop 用途
Default 你平常看到的桌面,所有應用程式都在這裡
Winlogon 登入畫面、Ctrl+Alt+Del 安全桌面、UAC 提權對話框
Screen-saver 螢幕保護程式

這三張桌面同一時間只有一張在接收輸入,那一張叫做 Input Desktop

按下 Ctrl+Alt+Del 時,系統做的事就是 SwitchDesktopWinlogon。這也是為什麼你的自動化腳本永遠點不到 UAC 對話框——它不在 Default 上,你的執行緒也沒有權限切過去。這是設計,不是限制。

而執行緒有自己的「我在哪張桌面」狀態。如果你的執行緒附加在 A 桌面,SendInput 就送到 A,UIA 也只列舉 A 上面的視窗——即使此刻真正在螢幕上的是 B。

呼叫全部成功,只是對象是一張沒人在看的桌面。

修正方式是明確地把執行緒接到當下的 Input Desktop 上:

def attach_to_input_desktop() -> bool:
    """
    Attaches current thread to the active Input Desktop (e.g. 'Default').
    Crucial in CI / agent environments where the runner thread may spawn
    attached to an isolated desktop.
    """
    try:
        input_desk = user32.OpenInputDesktop(0, False, DESKTOP_ALL)
        if input_desk:
            return bool(user32.SetThreadDesktop(input_desk))
    except Exception as exc:
        logger.debug(f"SetThreadDesktop failed ({type(exc).__name__}): {exc}")
    return False

OpenInputDesktop 拿到「現在正在接收輸入的那一張」,SetThreadDesktop 把當前執行緒搬過去。兩行程式碼;而少了這兩行,CI 上的一整套環境清理都是對著空氣做的。

wintegrate 裡,這是 set_foreground() 做的第一件事——在任何嘗試把視窗叫到前景之前,先確認自己站在對的地方。


陷阱:另一個也叫「桌面」的東西

講到這裡有個必須澄清的混淆,因為它讓我在設計 API 時繞了一段路。

Windows 10 引入了 Virtual Desktop,就是工作列上「工作檢視」(Task View)那個功能,Win+Ctrl+D 開一張新的。中文同樣叫「虛擬桌面」。

它和上面講的 Desktop 完全不是同一個東西。

Window Station Desktop Virtual Desktop(Task View)
層級 NT 核心物件,安全邊界 Shell 功能,只是視窗分組
API OpenInputDesktopSetThreadDesktopSwitchDesktop IVirtualDesktopManager COM 介面
隔離性 真的隔離:跨不過去 沒有隔離:EnumWindows 全部看得到
出現時間 Windows NT 就有 Windows 10 才有

一個是安全邊界,一個是視窗收納。名字撞在一起純屬歷史意外。

wintegrate 兩層都碰,但目的完全不同:

def set_foreground(self, verify: bool = True, timeout: float = 2.0) -> bool:
    attach_to_input_desktop()      # 第三層:確保我在對的 Desktop 上
    self.move_to_current_desktop() # Task View:把視窗抓到眼前這一張

前者是「我有沒有站在對的地方」,後者是「這個視窗有沒有被收到另一張虛擬桌面去」。兩個都會讓視窗「看不到」,但成因和解法毫無關係。


回到 CI:這三層在 runner 上長什麼樣

wintegrate 對虛擬桌面隔離的預設值是 "auto",判斷邏輯是這樣:

# Isolate interactive desktops so tests never inject input into the
# user's live session; CI runners are disposable and skip the desktop
# switch (it is slow, notably on ARM64 runners).
return env.supports_virtual_desktops and not is_ci()

翻成白話:在你自己的機器上要隔離,在 CI 上不要。

同一個設定在兩種環境下的正確答案是相反的,理由也不同。本機跑測試時,你不會希望它把 hello 打進你正在編輯的文件裡,所以開一張乾淨的 Virtual Desktop 把測試關進去。CI runner 是拋棄式的,沒有東西需要保護,而切換桌面要花時間——在 ARM64 runner 上尤其明顯。

還有一個更根本的理由:supports_virtual_desktops 的判定是

supports_vd = is_desktop and win_ver.build >= 10240

而 GitHub Actions 的 windows-latest 跑的是 Windows Server 2025 Datacenteris_desktopFalse。Server 版根本沒有 Task View。所以在 x64 runner 上這條路徑被關掉兩次——一次因為它是 CI,一次因為那台機器上根本沒這個功能。

(順帶一提,windows-11-arm runner 是 Windows 11 Enterpriseis_desktopTrue。同一份程式碼在兩種 runner 上會走不同分支——一個 client SKU、一個 server SKU,而它們對 UI 自動化不是可互換的。這種差異後面會出現很多次,最後甚至會逼我把整套測試在兩種 SKU 上各跑一遍。)


這個模型不只影響自動化,也影響安裝

一個晚一點才會踩到的例子,因為它跟「執行測試」看起來完全無關。

我要在虛擬機上安裝一個 MSIX 打包的應用程式,從 SSH 執行:

Add-AppxPackage -Path .\Files.msixbundle
# Deployment failed with HRESULT: 0x80070005, 存取被拒。

0x80070005ACCESS_DENIED,而訊息還特別提到「目標磁碟區 C:」。
所以我花了一輪去查 ACL:把套件搬到一個開放讀取的資料夾、確認執行身分是管理員、
三條不同的安裝路徑(bundle、單一 msix、官方的 -AppInstallerFile)——全部一樣。

真正的原因在部署事件記錄 Microsoft-Windows-AppXDeploymentServer/Operational 裡:

id=718  無法初始化 PLM,錯誤為 0x80070005
id=605  錯誤 0x80070005: PackagesInUseClosed 狀態處理常式失敗

PLM 是 Process Lifetime Manager,而我的 SSH session 拿不到它。

解法就是本篇前面那個借桌面的招數:同一個指令透過排程工作、以已登入使用者的
session 執行,第一次就成功。同一個工具,這次借的不是桌面而是一個子系統。

兩個教訓:

一、這一章講的三層模型不只決定「輸入送到哪裡」,也決定「什麼 API 能用」。
MSIX 部署需要一個有 PLM 的 session,而那不是每一個 session 都有。

二、0x80070005 的字面訊息把人往完全錯誤的方向帶。「存取被拒」聽起來像權限,
而答案只在事件記錄裡。當一個錯誤碼的字面意思跟你查到的東西對不上,
去找那個子系統自己的 log。

(順帶一提,我後來以為這代表「CI runner 上也裝不了 MSIX」——那也是錯的。
hosted runner 上完全正常,因為它的 session 有 PLM。
一個環境的限制不會自動是另一個環境的限制。)


小結

下次你遇到「呼叫成功但畫面沒反應」,先別懷疑 API。按這三層由上而下問一次:

  1. Session——我這個處理程序有互動式 session 嗎?(服務?SSH?排程工作?鎖定畫面?)
  2. Window Station——我在 WinSta0 上嗎?
  3. Desktop——我的執行緒附加在當下的 Input Desktop 上嗎?

三個都對了,你的 SendInput 才有意義。任何一個錯了,你會得到一個安靜的、成功的、什麼也沒做的呼叫——而這是所有失敗模式裡最難查的一種。

明天我們處理下一個問題:確定自己站對地方之後,怎麼把那張桌面完整錄下來——一場兩分鐘的測試,為什麼不能先把畫面存在記憶體裡。


上一篇
Day 1:被遺忘在現代 CI 世界之外的 Windows 桌面應用
下一篇
Day 3:測試崩潰永遠在最後一秒:PyAV 串流編碼與 CI 錄影的三道防線
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言