在開發插件市場之前,先有的是 MoeKoe 的普通插件系統。
當時我想解決的問題不是某一個具體功能,而是一個更通用的问题:播放器裡總會冒出很多小需求,有些人想改界面,有些人想加一個工具彈窗,有些人想接自己的服務。如果每個想法都往主程序裡塞,主程序會越來越重,也越來越難維護。
所以我一開始給插件系統定的方向很明確:主程序只提供插件運行環境和管理入口,具體功能交給插件自己去做。插件能獨立安裝、獨立卸載,壞了也盡量不要影響播放器本身。
上一篇文章我寫的是 MoeKoeMusic-Plugins 這個插件登記倉庫。那邊解決的是“插件市場的數據怎麼產生”:用戶用 Issue 提交,Action 校驗,維護者審核,通過後寫進 plugins.json。
但只有登記倉庫還不夠。真正對用戶來說,插件市場不是 GitHub 上的一份 JSON,而是客戶端裡一個能看、能搜、能安裝、能更新的入口。
所以這篇就換到 MoeKoe Music 主項目裡,專門聊客戶端的插件市場部分。
為什麼選擇 Chrome Extension 這一套
MoeKoe 是 Electron 應用,Electron 本身就有加載擴展的能力。既然底层已經能跑 Chrome Extension,那就沒有必要再從零設計一套插件格式。
這樣做有幾個好處:
- 第一,插件作者不用學一套完全陌生的格式。
manifest.json、popup.html、background.js都是很常見的概念。 - 第二,插件天然有清單文件。主程序可以先讀
manifest.json,判斷插件名稱、版本、描述、權限、彈窗入口、最低支持版本等信息。 - 第三,Electron 可以直接加載插件目錄,不需要我自己寫一個腳本沙箱。
展示、搜索、分頁和狀態
插件市場不只是能安裝就行,還要能快速判斷:
- 這個插件是幹什麼的
- 我有没有安裝過
- 我安裝的是不是舊版本
- 這個插件有没有更高版本要求
- 它有没有網絡、文件訪問、本地二進制這些風險點
- 分頁、搜索、更新、狀態
所以市場卡片裡展示的不只是“安裝”按鈕。
- 確保插件目錄存在
- 調用 Electron 下載安裝、卸載舊版、安裝新版
- 註冊插件相關 IPC
- 加載 Chrome 擴展
- 加載 Native Host 索引和授權系統
已安裝插件
插件管理頁面的“已安裝插件”不只是讀取 Electron 當前加載的擴展。它會把兩類信息合併:
session.defaultSession.getAllExtensions()拿到已加載擴展scanExtensions()扫描磁盤插件目錄,讀取 manifest、圖標、彈窗、Native Host 声明
前端拿到這些字段後,就能顯示:
- 插件名稱、描述、版本、作者
- 圖標
- 是否是 MoeKoe 适配插件
- 当前客戶端版本是否低於插件
minversion - 是否有 popup 設置頁
- 是否聲明了本地二進制程序
Native Host
這是 2.0 版本新增的插件功能。
插件系統裡最需要小心的是 Native Host,也就是插件附帶本地可執行程序的能力。
普通前端插件再怎麼折腾,大多還是在瀏覽器擴展模型裡。但本地程序不一樣。它一旦啟動,就具備普通桌面程序的系统訪問能力。
所以 MoeKoe Music 沒有讓插件聲明就直接運行,而是設計了一層授權層。
manifest 裡必須聲明權限:'moekoe:nativeHost'
本地程序通信
Native Host 真正啟動時,用的是 spawn:
const child = spawn(host.executablePath, host.args, {
cwd: path.dirname(host.executablePath),
stdio: ['pipe', 'pipe', 'pipe']
});
這裡沒有用 shell,這個選擇很重要。它避免插件通過拼接參數獲得額外命令執行能力。
插件向本地程序發消息時,會寫入一行 JSON:
child.stdin.write(`${JSON.stringify(payload)}\n`, error => {
// ...
});
也就是 JSON Lines 協議:一行一個 JSON。
本地程序的 stdout 也按行解析,消息大小限制是 64 KB
這套協議不複雜,但足夠明確。插件和本地程序之間不需要共享一堆狀態,只要約定好輸入輸出的 JSON 就行。
bridge 窗口
Native Host 裡還有一個挺有意思的設計:bridge,給插件後台一個通往 preload 的通道
代碼註釋寫得很清楚:
// bridge 是一個隱藏擴展頁,既能訪問 chrome.runtime,又能通過 preload 訪問 electronAPI。
// background/service worker 不能直接拿到 Electron preload,因此需要這個中轉頁。
實現上是創建一個 1×1 的隱藏 BrowserWindow
為什麼要這樣做?
因為 Chrome 擴展的 background/service worker 不一定能直接拿到 Electron preload 暴露的 API。而 bridge 是一個擴展頁面,它既屬於 chrome-extension://,又運行在 Electron 窗口裡,所以可以作為中轉層。
最後一問
看到這裡可能會有一個問題:既然 MoeKoe Music 已經用了 Chrome 擴展模型,為什麼 Native Host 通信不直接照搬 Chrome 浏覽器自帶的 Native Messaging?
Chrome 那套機制本身是成熟的。瀏覽器擴展可以通過 chrome.runtime.connectNative 或 chrome.runtime.sendNativeMessage 連接本地程序,本地程序和瀏覽器之間用 stdin/stdout 通信,消息格式是“4 字節長度頭 + JSON 内容”。
但 MoeKoe Music 這裡要解決的問題不完全一樣。
Chrome Native Messaging 面向的是“瀏覽器擴展連接系統裡已經註冊好的 native host”。它要求本地程序先在操作系統特定位置註冊 host manifest,比如 macOS/Linux 的固定目錄,或者 Windows 的註冊表。這個 manifest 裡還要寫 allowed_origins,把允許訪問它的 Chrome 擴展 id 列出來。
第一,MoeKoe 的插件是從客戶端市場安裝到應用自己的插件目錄裡的。用戶點擊安裝插件,本質上應該只是往 MoeKoe Music 的用戶數據目錄寫入擴展文件。如果為了插件裡的本地程序,還要去改系統級 native host 註冊信息,那安裝、更新、卸載都會變複雜。卸載時,客戶端還必須保證系統註冊信息也被清掉,否則就會留下應用之外的残留狀態。
第二,Chrome 的那套授權是圍繞瀏覽器擴展 id 和 host manifest 做的,而 MoeKoe Music 更需要圍繞 plugin_id 做管理。前面已經提到過,Electron 生成的 extension id 更像運行時身份,插件市場、審核記錄、更新判斷和 Native Host 授權都需要一個更穩定的身份。如果直接走 Chrome Native Messaging,權限鏈路會被拆到系統 host manifest 和瀏覽器擴展 id 上,和 MoeKoe 自己的插件市場信任鏈不太對齊。
第三,MoeKoe Music 需要主進程掌握完整生命周期。誰能啟動本地程序、啟動前是否已經授權、可執行文件是不是插件目錄內的相對路徑、退出應用時要不要關閉子進程、插件更新或卸載時如何清理,這些判斷都應該集中在 Electron 主進程裡。自己 spawn 本地程序,就可以把這些規則全部收在客戶端內部,而不是把一部分狀態交給操作系統註冊機制。
第四,Chrome Native Messaging 的長度頭協議很通用,但對這個場景並不是必需的。MoeKoe 的 Native Host 更像插件和本地小工具之間的命令通道,消息量不大,也不需要傳二進制流。用 JSON Lines,一行一個 JSON,本地程序作者用 Node、Python、Go、Rust 都很好實現,調試時直接看 stdout 也更直觀。
第五,Electron 不支持 chrome.runtime.connectNative 或 chrome.runtime.sendNativeMessage,這兩個 API 是 Chrome 浏覽器擴展(Extension)的 Native Messaging API,依賴於瀏覽器安裝的 Native Messaging Host,而不是 Chromium 内核本身,所以 Electron 沒有實現這一套機制。說了那麼多,這才是最主要的原因
結尾
如果只看界面,MoeKoe Music 的插件市場就是一個列表:圖標、名字、描述、版本、按鈕。
但往裡看,它實際上是一條信任鏈:
插件作者提交到登記倉庫,維護者審核快照,Action 寫入 plugins.json,客戶端讀取市場索引,用戶看到權限提示,點擊安裝,主進程下載並校驗 zip,最後 Electron 加載擴展。
- 插件來源從哪裡來
- 审核通過的是哪一版
- 客戶端如何知道可更新
- 安裝包如何落到本地
- 本地二進制能力如何授權
- 插件卸載和應用退出時如何清理
最後用戶看到的只是一個“安裝”按鈕。
但這個按鈕背後,其實已經走過了一整套很具體、很工程化的判斷。
