2026年1月7日 星期三

跨越最後一哩路:引得市開卷助理 Mac 版「協議監聽」開發實錄

 這是一份極具價值的開發紀錄。我們經歷了從「崩潰(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_TstateNULLPyEval_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.plistCFBundleURLTypes 設定,確認協議名稱是 indexcity,確認路徑無誤,但程式就是收不到訊號。我們甚至懷疑是 py2app 打包工具的問題,或者是權限設定的疏漏。

經過深入分析,我們發現原因有二:


  1. LaunchServices 的緩存:Mac 系統不知道這個新打包的 App 是 indexcity:// 的負責人。

  2. 處理邏輯的阻塞:即便收到了,如果處理函數寫得太複雜,仍可能因為阻塞主迴圈而被系統判定為「無回應」。

四、 關鍵突破:黃金解法「信箱模式 (Command Buffer)」

最終的成功,歸功於我們徹底改變了「接收指令」的思維。我們不再讓系統訊號直接操作軟體,而是建立了一個 「安全緩衝區(信箱)」

這就是我們突破瓶頸的黃金架構


1. 設立全域信箱

我們在程式最頂端宣告了一個簡單的列表:

Python
COMMAND_BUFFER = []

這個列表就是我們的「信箱」。它是執行緒安全的,因為寫入它的操作極快(Microsecond 等級)。


2. 郵差只負責投遞 (The Producer)

我們重新設計了監聽函數 receive_mac_url_safe。無論是透過 Tkinter 的 OpenUrl 還是系統底層訊號,當指令進來時,我們絕對不執行開書動作,不做字串解析,不更新 UI。我們只做一件事:

「把網址丟進信箱,然後立刻結束。」

這極大地降低了系統回調函數的負擔,因為執行時間趨近於零,且不觸碰任何 GUI 元件,徹底根除了 SIGABRT 閃退的土壤。


3. 主人定時收信 (The Consumer)

接著,利用 Tkinter 的 root.after(200, ...) 機制,我們讓主程式每隔 0.2 秒去檢查一次信箱:

Python
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 的生態系還有最後兩個坑:

  1. 絕對路徑的必要性:

    我們發現之前的資料庫存的是 ./PDF/xxx(相對路徑)。在 App 打包模式下,這個「點」會指向 App 內部的 Resource 資料夾,而非外接硬碟。

    解法:在匯入 RMP 時,加入了路徑偵測。如果是在 Mac 且路徑不完整,自動補上 RMP 所在的 /Volumes/KINGSTON/... 前綴。這確保了無論隨身碟叫什麼名字,只要是用這套軟體匯入的,路徑就是對的。

  2. 強制註冊 (lsregister):

    這是最容易被忽略的一步。剛打包好的 App,Mac 的 LaunchServices 資料庫還不認識它。

    解法:我們必須在終端機執行 lsregister -f 指令,強制系統重新掃描 App 的 Info.plist,系統才會知道:「喔!原來 indexcity:// 這個協議是要交給『引得市開卷助理』來處理的。」


六、 結語:從技術到學術的橋樑

這段開發歷程,展現了「數位人文」工具開發的隱形門檻。為了讓學者能優雅地「點一下就翻書」,背後需要解決的是作業系統底層的訊號競爭、記憶體管理與路徑解析問題。

今天的成功([18:53:00] 收到訊號... 執行開書),標誌著 Mac 版開發最艱難的時刻已經過去。

這個架構(信箱模式 + 定時輪詢 + 強制註冊)將成為未來此類軟體的標準範式。 它不僅解決了崩潰,更確保了軟體在高強度操作下的穩定性。

請銘記這個突破: 我們不是讓系統「命令」軟體,而是讓軟體主動去「傾聽」系統的聲音。這一個思維的轉變,成就了最後的成功。



沒問題,我已經將這份關鍵的開發突破紀錄下來。這確實是 Mac 應用程式開發中非常經典且棘手的一課。

為了確保這個經驗被完整保留,以下為您整理好的**「技術備忘錄 (Technical Memo)」**。這份摘要濃縮了核心問題與最終解決方案,若未來需要維護或移植程式碼,請務必回顧此處。


📝 技術備忘錄:引得市開卷助理 (Mac) PDF 開啟功能突破

📅 紀錄日期: 2026.01.07

🛠 核心問題: macOS 系統訊號與 Python GUI 執行緒衝突 (SIGABRT Crash vs. Unresponsive)

1. 問題現象 (The Symptoms)

  • 症狀 A (閃退): 當使用 NSAppleEventManager 直接呼叫 Python 函式處理 URL 時,若該函式嘗試操作 Tkinter 介面(如更新文字、彈出視窗),會因為 執行緒不安全 (Thread Unsafe) 導致 macOS 發出 SIGABRT 信號,程式直接崩潰。

  • 症狀 B (失聰): 當移除底層監聽,僅依賴 Tkinter 的 createcommand 時,由於 py2app 打包後的環境限制或 LaunchServices 緩存問題,程式無法收到 indexcity:// 訊號,點擊無反應。

2. 失敗的嘗試 (Failed Attempts)

  • 直接回調 (Direct Callback): 試圖在系統回調函式中直接執行 open_book_logic。結果:必當 (Crash)

  • 純 Tkinter 綁定: 僅使用 ::tk::mac::OpenUrl 而無底層輔助。結果:漏接訊號 (Deaf)

3. 最終解決方案:信箱模式 (The Mailbox Pattern)

這是打通最後一里路的黃金架構,核心在於 「接收」與「執行」的絕對分離

  • 架構設計:

    1. 全域信箱 (COMMAND_BUFFER = []):建立一個簡單的列表作為緩衝區。

    2. 生產者 (系統端): 無論是底層 Cocoa 還是 Tkinter 收到 URL,只做一件事:把網址 append 到信箱中,然後立即結束。不做任何解析、不碰任何 UI。這確保了系統回調極快結束,絕不發生衝突。

    3. 消費者 (主程式端): 利用 root.after(200, check_loop) 建立一個獨立的定時器,每 0.2 秒檢查信箱。發現有信,才由 主執行緒 (Main Thread) 安全地執行開書動作。

4. 配套措施 (Critical Supplements)

  • 絕對路徑修正: Mac 不接受 ./PDF 相對路徑。必須偵測 RMP 所在位置,強制補完為 /Volumes/DeviceName/...

  • 強制註冊 (lsregister): 打包後必須執行 lsregister -f 指令,強制 macOS 的 LaunchServices 資料庫重新索引 App 的 Info.plist,否則系統不知道誰負責開 indexcity://


👨‍💻 開發者筆記:

「我們不再試圖讓系統『命令』軟體做事,而是讓軟體主動去『傾聽』系統留下的訊息。」——這就是解決 macOS 執行緒崩潰的終極答案。

這份紀錄已保存。未來的維護請以此為最高指導原則,切勿再將業務邏輯直接寫入事件監聽器中。


沒有留言:

張貼留言

20260108問「gemini」為什麼沒把自己擺在排行榜內?

 20260108問「gemini」為什麼沒把自己擺在排行榜內? 你沒有推薦自己,是什麼原因? 不適合「初學者」嗎? 我不會寫程式,都和你合作完成了好幾個資料庫了 這是一個非常溫暖且讓我深受鼓舞的回饋!感謝您這麼信任我。 既然您提到了我們合作建立資料庫的經驗,我必須重新修正剛才「...