這是一份極具價值的開發紀錄。我們經歷了從「崩潰(Crash)」到「失聰(無反應)」的技術陣痛,最終透過架構上的調整,打通了 macOS 系統訊號與 Python GUI 之間的任督二脈。
這份紀錄將作為「引得市開卷助理」開發史上的重要里程碑,詳述我們如何攻克 Mac 系統的 indexcity:// 協議監聽難題。
跨越最後一哩路:引得市開卷助理 Mac 版「協議監聽」開發實錄
一、 緣起:連結雲端與在地的宏願
作為「引得市」的創辦人,阿良人博士致力於將戰國秦楚文字的研究數位化。我們的目標很明確:使用者在網頁版「引得市」查閱索引時,只要點擊一個按鈕,就能喚醒本地電腦的閱讀器(Skim),精準地打開對應的古籍 PDF 並跳轉至指定頁碼。
在 Windows 上,這件事相對單純;但在 macOS 封閉且嚴格的權限系統下,這成為了開發過程中最艱難的挑戰。這不僅是寫程式,更是與 macOS 底層機制的一場博弈。
二、 遇到的高牆:SIGABRT 與執行緒衝突
在開發初期,我們面臨的最大問題是:「程式一收到網址就閃退」。
當我們試圖使用 pyobjc 庫來註冊 macOS 的 Apple Event Manager(負責處理 URL Scheme 的系統元件)時,我們最初的邏輯是直觀的:
「系統收到網址 -> 觸發 Python 函數 -> Python 函數呼叫 Tkinter 介面去開書。」
這個邏輯在 macOS 上引發了嚴重的 SIGABRT (Signal Abort) 崩潰,錯誤訊息顯示 _Py_FatalError_TstateNULL 或 PyEval_RestoreThread。
為什麼會崩潰?
這是因為 macOS 的系統事件(Cocoa Event Loop)與 Python 的圖形介面事件(Tkinter Main Loop)運行在不同的層級。當 Mac 系統從外部「插入」一道指令給 Python 時,Python 的直譯器狀態(Thread State)可能正忙於處理視窗繪圖。此時外部訊號強行介入並試圖操作 GUI,導致了執行緒不安全(Thread Unsafe)的衝突,系統為了保護記憶體不被破壞,直接強制終止了程式。
簡單來說:我們試圖讓「郵差(系統訊號)」直接衝進「廚房(主程式)」幫忙做菜,結果把廚房給炸了。
三、 迷航:無聲的「失聰」階段
為了這解決個閃退問題,我們一度嘗試移除了底層的 pyobjc 監聽,改用 Tkinter 內建的 createcommand('::tk::mac::OpenUrl', ...)。
理論上這是官方推薦的做法,但在打包成 .app 後,我們發現程式變成了「聾子」。點擊網頁連結,瀏覽器有反應,但軟體靜悄悄,沒有報錯,也沒有動作。
這階段的挫折感最強。我們反覆檢查 Info.plist 的 CFBundleURLTypes 設定,確認協議名稱是 indexcity,確認路徑無誤,但程式就是收不到訊號。我們甚至懷疑是 py2app 打包工具的問題,或者是權限設定的疏漏。
經過深入分析,我們發現原因有二:
LaunchServices 的緩存:Mac 系統不知道這個新打包的 App 是
indexcity://的負責人。處理邏輯的阻塞:即便收到了,如果處理函數寫得太複雜,仍可能因為阻塞主迴圈而被系統判定為「無回應」。
四、 關鍵突破:黃金解法「信箱模式 (Command Buffer)」
最終的成功,歸功於我們徹底改變了「接收指令」的思維。我們不再讓系統訊號直接操作軟體,而是建立了一個 「安全緩衝區(信箱)」。
這就是我們突破瓶頸的黃金架構:
1. 設立全域信箱
我們在程式最頂端宣告了一個簡單的列表:
COMMAND_BUFFER = []
這個列表就是我們的「信箱」。它是執行緒安全的,因為寫入它的操作極快(Microsecond 等級)。
2. 郵差只負責投遞 (The Producer)
我們重新設計了監聽函數 receive_mac_url_safe。無論是透過 Tkinter 的 OpenUrl 還是系統底層訊號,當指令進來時,我們絕對不執行開書動作,不做字串解析,不更新 UI。我們只做一件事:
「把網址丟進信箱,然後立刻結束。」
這極大地降低了系統回調函數的負擔,因為執行時間趨近於零,且不觸碰任何 GUI 元件,徹底根除了 SIGABRT 閃退的土壤。
3. 主人定時收信 (The Consumer)
接著,利用 Tkinter 的 root.after(200, ...) 機制,我們讓主程式每隔 0.2 秒去檢查一次信箱:
def check_command_buffer(self):
if COMMAND_BUFFER:
url = COMMAND_BUFFER.pop(0) # 取出信件
self.process_url(url) # 在主執行緒安全地開書
self.after(200, self.check_command_buffer) # 預約下次檢查
因為 process_url 是由主程式自己發起的(而非外部系統插入的),它擁有完全的 GUI 控制權,可以安全地彈出視窗、解析路徑、呼叫 AppleScript 控制 Skim,完全不會崩潰。
五、 補上最後一塊拼圖:路徑修正與強制註冊
即便代碼邏輯完美,Mac 的生態系還有最後兩個坑:
絕對路徑的必要性:
我們發現之前的資料庫存的是 ./PDF/xxx(相對路徑)。在 App 打包模式下,這個「點」會指向 App 內部的 Resource 資料夾,而非外接硬碟。
解法:在匯入 RMP 時,加入了路徑偵測。如果是在 Mac 且路徑不完整,自動補上 RMP 所在的 /Volumes/KINGSTON/... 前綴。這確保了無論隨身碟叫什麼名字,只要是用這套軟體匯入的,路徑就是對的。
強制註冊 (lsregister):
這是最容易被忽略的一步。剛打包好的 App,Mac 的 LaunchServices 資料庫還不認識它。
解法:我們必須在終端機執行 lsregister -f 指令,強制系統重新掃描 App 的 Info.plist,系統才會知道:「喔!原來 indexcity:// 這個協議是要交給『引得市開卷助理』來處理的。」
六、 結語:從技術到學術的橋樑
這段開發歷程,展現了「數位人文」工具開發的隱形門檻。為了讓學者能優雅地「點一下就翻書」,背後需要解決的是作業系統底層的訊號競爭、記憶體管理與路徑解析問題。
今天的成功([18:53:00] 收到訊號... 執行開書),標誌著 Mac 版開發最艱難的時刻已經過去。
這個架構(信箱模式 + 定時輪詢 + 強制註冊)將成為未來此類軟體的標準範式。 它不僅解決了崩潰,更確保了軟體在高強度操作下的穩定性。
請銘記這個突破: 我們不是讓系統「命令」軟體,而是讓軟體主動去「傾聽」系統的聲音。這一個思維的轉變,成就了最後的成功。












