2025年7月31日 星期四

Python使用ctypes調用Windows API的64位元處理

老實說,使用win32api來寫視窗程式真的蠻討厭的
步驟較現今的一堆GUI套件來說,繁瑣不少
加上又是用Python呼叫來用的話,隔了一層又更加的複雜與麻煩
當年走了歪路這樣寫是有點逼不得已
現在用tk多方便啊,就很久不這麼幹了

不過玩AI玩了好一陣子,看著漸漸日益強大的性能
突然想起一件陳年舊帳,2017年的這篇
Python環境下最單純的GUI產生法~使用ctypes調用Windows API~
當年卡在無法於Python的64位元版本環境中運作
不知現今的AI能否為我解開這問題
試著花一點時間試試後,真的都解開了
感謝現在AI發展之神速
很多舊的,解不了的程設問題拿去問AI,現在都可以得到解法
甚至在開發新功能時
讓我省去了很多很多跟一堆文件奮戰的時間
直接直指目標把事情完成

總之這次不但解決文章中無法在64位元Python執行問題
還有一個目錄選擇視窗無法使用的問題,也一併處理掉了
這篇就來把這些都記錄下來


我們再回到當年那一篇起點
https://gist.github.com/mouseroot/6128651
其實在2023年就有人提出執行Python 64位元解法了
解法很單純,只把DefWindowProc的引數給指定成可以用的就好了
windll.user32.DefWindowProcW.argtypes = [HWND, UINT, WPARAM, LPARAM]
這樣就可以解決了
當我問完AI得到可以用的解答,再去爬當年這篇時,發現已經有解時,實在是很糗啊
更糗的是,回顧自己在2020年的某個程式碼,其實自己也是這樣解決的了...
那麼為什麼還存在還沒解決的印象呢?
除了寫出來沒有更新在blog上之外
大概是後面「選擇目錄視窗」無法使用這個問題
視窗叫得出來,可是選了目錄,值傳不回來
從註解中看得出來試了一些辦法,還是失敗
那2020年那次的修正64位元的使用確實未竟全功

所以接著就是要解決這個問題
將自己的程式碼丟給Grok幫我修改為可執行的範例程式要來照抄
但Grok修正後的程式碼正確率還是沒有百分之百
有幾行錯誤,一行是建BROWSEINFOW底下時,lpfn類別,指定為c_void_p
這樣寫跑不起來,最後改回舊的程式碼('lpfn', BFFCALLBACK)
其中BFFCALBACK要在前面另外這樣指定
BFFCALLBACK = WINFUNCTYPE(c_int, HWND, c_uint, LPARAM, LPARAM)
第二個是字串相關的處理,有二處
先是browseInfo結構下pszDisplayName,原先指定為空字串
Grok雞婆,改成create_unicode_buffer(MAX_PATH)後,反而型別錯誤
再來是path的空字串指定,之前已經修正好要用ctypes中的cast進行型別轉換
將create_unicode_buffer建立的陣列轉為c_wchar_p
但Grok跟上面一樣雞婆,用create_unicode_buffer(MAX_PATH)建空字串
反而會在windll.shell32.SHGetPathFromIDListW(pidl, path)這行造成型別錯誤,無法執行
那就只能再修回去
剩下的就是補上
windll.user32.SendMessageW.argtypes = [c_void_p, c_uint, c_size_t, c_size_t]
與windll.ole32.CoTaskMemFree.argtypes = [c_void_p]

這樣Grok弄出來的
雖然錯了幾個部分,但是我卡住的部分,Grok幫我改對了
就是加入以下的部分後就成功
windll.shell32.SHBrowseForFolderW.restype = c_void_p
windll.shell32.SHBrowseForFolderW.argtypes = [c_void_p]
windll.shell32.SHGetPathFromIDListW.restype = c_int
windll.shell32.SHGetPathFromIDListW.argtypes = [c_void_p, c_wchar_p]
windll.ole32.CoTaskMemFree.argtypes = [c_void_p]
整個範例程式就沒有問題了,點哪個目錄就回傳那個目錄的資料回來
修正完可執行範例程式如下,執行環境是Python 3.12 64位元版本

from ctypes import windll, byref, create_unicode_buffer, c_void_p, c_uint
from ctypes.wintypes import HWND, MAX_PATH, LPWSTR, LPCWSTR
from ctypes import Structure, WINFUNCTYPE, c_wchar_p, c_int, addressof, c_wchar, c_size_t, cast
import os

# 設置 SHBrowseForFolderW 和 SHGetPathFromIDListW 的類型
windll.shell32.SHBrowseForFolderW.restype = c_void_p
windll.shell32.SHBrowseForFolderW.argtypes = [c_void_p]
windll.shell32.SHGetPathFromIDListW.restype = c_int
windll.shell32.SHGetPathFromIDListW.argtypes = [c_void_p, c_wchar_p]

# 定義 BFFCALLBACK 回調函數原型
BFFCALLBACK = WINFUNCTYPE(c_int, HWND, c_uint, c_void_p, c_void_p)

# 定義 BROWSEINFOW 結構
class BROWSEINFOW(Structure):
    _fields_ = [
        ("hwndOwner", HWND),
        ("pidlRoot", c_void_p),
        ("pszDisplayName", c_wchar * MAX_PATH),  
        ("lpszTitle", c_wchar_p),
        ("ulFlags", c_uint),
        ("lpfn", BFFCALLBACK),
        ("lParam", c_void_p),
        ("iImage", c_int)
    ]

#修正Grok缺漏的部分,補上SendMessage與CoTaskMemFree的引數
windll.user32.SendMessageW.argtypes = [c_void_p, c_uint, c_size_t, c_size_t]
windll.ole32.CoTaskMemFree.argtypes = [c_void_p]

class MyWindow:
    def __init__(self):
        # 初始化 COM
        windll.ole32.CoInitialize(None)

    def BffCallbackProc(self, hwnd, msg, lp, data):
        if msg == 1:  # BFFM_INITIALIZED
            windll.user32.SendMessageW(hwnd, 0x0465, 0, data)  # BFFM_SETSELECTIONW
        return 0

    def getDirectoryname(self, setpath=None):
        if setpath is None:
            setpath = os.path.abspath(os.curdir)
        else:
            setpath = os.path.abspath(setpath)

        titlename = "請選擇檔案所在目錄"
        browseInfo = BROWSEINFOW()
        browseInfo.hwndOwner = None
        browseInfo.pidlRoot = None
        browseInfo.pszDisplayName = ""
        browseInfo.lpszTitle = titlename
        browseInfo.ulFlags = 0x00000050  # BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE

        if setpath:
            path_buffer = create_unicode_buffer(setpath)
            browseInfo.lParam = c_void_p(addressof(path_buffer))
            browseInfo.lpfn = BFFCALLBACK(self.BffCallbackProc)
            print("setpath")
        else:
            browseInfo.lParam = None
            browseInfo.lpfn = None
            print("Nopath")

        pidl = windll.shell32.SHBrowseForFolderW(byref(browseInfo))
        if not pidl:
            return None

        path = cast(create_unicode_buffer(MAX_PATH), LPCWSTR)
        result = windll.shell32.SHGetPathFromIDListW(pidl, path)
        windll.ole32.CoTaskMemFree(pidl)

        if result:
            return path.value
        else:
            raise OSError("Failed to get path from PIDL")

    def __del__(self):
        # 清理 COM
        windll.ole32.CoUninitialize()

# 範例使用
if __name__ == "__main__":
    window = MyWindow()
    folder = window.getDirectoryname()
    print(f"Selected folder: {folder}")

到這裡先總結一下重點,有二個
重點一,就是運行64位元版本的Python去call Windows API時,最好要指定好引數與回傳值
不指定,就看運氣給不給跑了
其實重新檢查程式碼
像使用CreateWindowExW、RegisterClassExW還有一堆函數都沒進行指定引數與回傳
結果還是跑得起來
可是像DefWindowProcW不指定正確引數就是不行
SHBrowseForFolderW則是相反,可以不給引數,但不能不指定回傳
而SHGetPathFromIDListW就又跟前面DefWindowProcW一樣,一定要給正確引數
所以正規作法,還是統一指定會比較沒有問題
但接下來問題就是引數與回傳值要給什麼

重點二,引數與回傳值指定方法
理論上最穩的就是參照windows api上的類別撰寫
像DefWindowProcW的部分

LRESULT DefWindowProcW(
  HWND   hWnd,
  UINT   Msg,
  WPARAM wParam,
  LPARAM lParam
);

那麼照著寫出來的就會變開頭那樣
windll.user32.DefWindowProcW.argtypes = [HWND, UINT, WPARAM, LPARAM]
是可以完全跟原本API對起來
非常的合理

不過重點二說是這樣說,但其實不一定要這樣做
首先,前面提到解決64位元範例有先問Grok
Grok給的解答其實與前面的解答不同,它給的答案是
windll.user32.DefWindowProcW.argtypes = [c_void_p, c_uint, c_size_t, c_size_t]
至於為什麼這樣也能動
來實際查看ctypes中wintypes下WPARAM跟LPARAM的類別就會懂了
其實在命令列中導入WPARAM與LPARAM後直接輸入
就會顯示為ctypes的c_ulonglong跟c_longlong

而ctypes的c_size_t則是c_ulonglong
兩者是差不多的東西都是64位元的超長整數
只差一個是有正負的,一個是沒有的
資料傳過去大小相符,只要資料正確,跑得起來很正常
再來,讀取目錄卡關的部分,舉SHGetPathFromIDListW為例好了
標頭檔是寫成(定義成)這樣

BOOL SHGetPathFromIDListW(
  [in]  PCIDLIST_ABSOLUTE pidl,
  [out] LPWSTR            pszPath
);


可是前面Grok給的卻是c_void_p替代PCIDLIST_ABSOLUTE,c_wchar_p替代LPWSTR
回傳值以c_int替代BOOL,然後執行依然正常...
好好地仔細比對與想想,這樣還能正常運作的原因慢慢就浮現了
因為PIDLIST_ABSOLUTE的定義以前有寫過,長下面這樣
PCIDLIST_ABSOLUTE = POINTER(ITEMIDLIST_ABSOLUTE)
也就是ITEMIDLIST_ABSOLUTE的指標
所以先不管這個ITEMIDLIST_ABSOLUTE是什麼
它就是指標,用c_void_p來傳自然不會有什麼大問題
LPWSTR根本就是c_wchar_p,所以也是OK
至於用c_int來取代BOOL,反正C++的bool就是0跟1而已
用c_uint應該也沒差多少
換句話說,也就是其實以前的那些寫法應該都對,畢竟直接抄官方示範的C++程式
卡是卡在Python自己的型別檢查轉不過去
在強制指定可傳遞資料的型別下,資料傳給windows api只要是對的,就動得起來了
那麼其實直接用基礎的類別那些c_void_p等等來當引數也無不可
或許還更基於原始的本質,甚至繞過Python自己層層的轉譯,效率更高
畢竟Windows API那些東西也是微軟自己另外定義出來,寫在標頭檔的東西啊

所以再回顧之前2020年的失敗的程式碼
其實那時離成功只差一步
ITEMIDLIST_ABSOLUTE與PIDLIST_ABSOLUTE這些都建好了
舊程式註解起來的這兩行都是寫對的
#SHGetPathFromIDList = windll.shell32.SHGetPathFromIDListW
#SHGetPathFromIDList.argtypes=[PCIDLIST_ABSOLUTE, LPWSTR]
就差了沒有指定SHBrowseForFolderW的回傳值為PCIDLIST_ABSOLUTE
真可惜

好啦,在兩個重點的說明後,上面的程式碼,如果要大修為更接近Windows API的原始樣貌
應該變這樣寫

from ctypes import windll, byref, create_unicode_buffer, POINTER
from ctypes.wintypes import HWND, MAX_PATH, LPWSTR, LPCWSTR, UINT, WPARAM, LPARAM
from ctypes.wintypes import INT, BOOL, UINT, USHORT, BYTE, LPVOID
from ctypes import Structure, WINFUNCTYPE, cast
import os

# 定義 SHITEMID 結構
class SHITEMID(Structure):
    _fields_ = [("cb", USHORT),
                ("abID", BYTE * 1)] 
                
# 定義 ITEMIDLIST 結構
class ITEMIDLIST(Structure):
    _fields_ = [("mkid", SHITEMID)]

# 定義 ITEMIDLIST_ABSOLUTE
ITEMIDLIST_ABSOLUTE = ITEMIDLIST
# 定義 PCIDLIST_ABSOLUTE
PCIDLIST_ABSOLUTE = POINTER(ITEMIDLIST_ABSOLUTE)

# 定義 BFFCALLBACK 回調函數原型
BFFCALLBACK = WINFUNCTYPE(INT, HWND, UINT, WPARAM, LPARAM)

# 定義 BROWSEINFOW 結構
class BROWSEINFOW(Structure):
    _fields_ = [
        ('hwndOwner',HWND),
        ('pidlRoot', ITEMIDLIST),
        ('pszDisplayName', LPWSTR), 
        ('lpszTitle', LPWSTR),      
        ('ulFlags', UINT),          
        ('lpfn', BFFCALLBACK),
        ('lParam', LPARAM),         
        ('iImage', UINT)]          

#定義 LPBROWSEINFOW 
LPBROWSEINFOW = POINTER(BROWSEINFOW)
    
# 設置 SHBrowseForFolderW 和 SHGetPathFromIDListW 的類型
"""修正為windows api上的正確引數"""
windll.shell32.SHBrowseForFolderW.argtypes = [LPBROWSEINFOW]
windll.shell32.SHBrowseForFolderW.restype = PCIDLIST_ABSOLUTE
windll.shell32.SHGetPathFromIDListW.restype = BOOL
windll.shell32.SHGetPathFromIDListW.argtypes=[PCIDLIST_ABSOLUTE,LPWSTR]

#修正Grok缺漏的部分,補上SendMessage與CoTaskMemFree的引數
"""修正為windows api上的正確引數"""
windll.user32.SendMessageW.argtypes = [HWND, UINT, WPARAM, LPARAM]
windll.ole32.CoTaskMemFree.argtypes = [LPVOID]

"""加上windows標頭檔的flag常數"""
BFFM_INITIALIZED = 1
BFFM_SETSELECTIONW = 0x0465
BIF_RETURNONLYFSDIRS = 0x00000001  
BIF_NEWDIALOGSTYLE = 0x00000040

class MyWindow:
    def __init__(self):
        # 初始化 COM
        windll.ole32.CoInitialize(None)

    def BffCallbackProc(self, hwnd, msg, lp, data):
        if msg == BFFM_INITIALIZED:  # BFFM初始化
            windll.user32.SendMessageW(hwnd, BFFM_SETSELECTIONW, 0, data)
        return 0

    def getDirectoryname(self, setpath=None):
        if setpath is None:
            setpath = os.path.abspath(os.curdir)
        else:
            setpath = os.path.abspath(setpath)

        titlename = "請選擇檔案所在目錄"
        browseInfo = BROWSEINFOW()
        browseInfo.hwndOwner = None
        #browseInfo.pidlRoot = None #pidRoot的屬性變化,改不指定
        browseInfo.pszDisplayName = ""
        browseInfo.lpszTitle = titlename
        browseInfo.ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE

        if setpath:
            #path_buffer = create_unicode_buffer(setpath) 取消path_buffer
            browseInfo.lParam = LPARAM.from_buffer(LPCWSTR(setpath))
            browseInfo.lpfn = BFFCALLBACK(self.BffCallbackProc)
            print("setpath")
        else:
            browseInfo.lParam = None
            browseInfo.lpfn = None
            print("Nopath")

        pidl = windll.shell32.SHBrowseForFolderW(byref(browseInfo))
        if not pidl:
            return None

        path = cast(create_unicode_buffer(MAX_PATH), LPCWSTR)
        result = windll.shell32.SHGetPathFromIDListW(pidl, path)
        windll.ole32.CoTaskMemFree(pidl)

        if result:
            return path.value
        else:
            raise OSError("Failed to get path from PIDL")

    def __del__(self):
        # 清理 COM
        windll.ole32.CoUninitialize()

# 範例使用
if __name__ == "__main__":
    window = MyWindow()
    folder = window.getDirectoryname()
    print(f"Selected folder: {folder}")

這範例捨棄了所有的ctypes原型的c_void_p等等,全部替換成windows用的變數與指定
算是滿足了重點一與重點二的要求的,「真」完整範例
然後有個地方要特別再補充,那就是LPARAM部分
前面就說了LPARAM其實是c_ulonglong類別,也就是個整數
然而在實際應用中它卻有傳遞字串指標的狀況
像在這個目錄選擇的範例中
Grok提供的BROWSEINFO結構中lParam是指定為c_void_p
這樣指派browseInfo.lParam時,後面也是用 c_void_p將buffer字串轉為指標
但是引數如只直接用LPARAM類別
使用c_void_p提供的數值就會造成類別錯誤不給用

所以要維持正規的寫法,又要能給指標,就要使用LPARAM裡from_buffer這個函數
browseInfo.lParam = LPARAM.from_buffer(LPCWSTR(setpath))
這是很早之前就抄到的寫法,但其實一直很不明白這裡為什麼要這樣寫
現在終於懂了,也可以活用了
包含像是要建立combo_box使用SendMessageW時,也是會遇到一樣的情況
之前不指定引數時可以像下面這樣寫,直接把文字丟到LPARAM中
SendMessageW(combox, CB_ADDSTRING, 0, "預設選項")
就會很正常地產生下拉式選單
但不指定引數時,在BFFCallbackProc裡的SendMessageW就會有錯誤碼產生
如果在這情況下又保有combo_box正常,SendMessageW的引數要改設為
SendMessageW.argtypes = [c_void_p, c_uint, c_size_t, c_void_p]
最後要用c_void_p,但這明顯違反了重點二的準則
而要遵守重點二的話,那combo box的寫法就要變這樣了
SendMessageW(combox, CB_ADDSTRING, 0, LPARAM.from_buffer(LPCWSTR("預設選項")))
是麻煩了點,就看怎麼選了

總之靠著AI,終於解開了之前的難關,這感覺很不錯
所以繼續挑戰下去,來挑戰用Windows API來顯示圖片
這邊靠著AI也成功了
本來是要連著寫成一篇,但這篇越寫越多
最後補上兩串長長的範例程式碼後
再寫下去看來會太長
順延到下一篇去好了

沒有留言: