diff --git a/.gitattributes b/.gitattributes index d206a10a..d78d16cd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,6 +13,7 @@ *.ico binary *.keystore binary *.jks binary +*.wav binary # Resolved by `flutter pub get`; collapse it in review diffs. pubspec.lock linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edc60372..672186cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -263,7 +263,9 @@ jobs: TEST_ANALYZER_WINDOWS_PATHS=true dart test # 不加參數:dart_test.yaml 讓 live 預設跳過(ADR 0015 §決定 3)。 - # 身分測試要 cmake,ubuntu runner 內建。 + # 身分測試要 cmake,ubuntu runner 內建。插件執行環境的測試跑真的 + # QuickJS:test/support/quickjs.dart 預先載入 flutter_js 內附的 .so, + # 不用先建置桌面版;載入失敗會讓測試失敗,不會跳過。 - name: Unit and widget tests run: flutter test diff --git a/.trellis/spec/app/plugins/index.md b/.trellis/spec/app/plugins/index.md new file mode 100644 index 00000000..300f64f9 --- /dev/null +++ b/.trellis/spec/app/plugins/index.md @@ -0,0 +1,133 @@ +# 插件(`app/lib/plugins/`) + +寫一個插件、改宿主 API、加 DTO 欄位或能力、寫插件相關測試時適用。規則(安裝檔格式、能力與匯出 +一致、網域、全域只有 `fmp`、錯誤轉換、逾時、開發入口)與閘門見 `app/AGENTS.md` § 插件;為什麼 +這樣做,見 ADR 0014。這裡只寫怎麼做。 + +## 目錄 + +``` +lib/plugins/ + source_plugin.dart # SourcePlugin:App 其他部分只認它 + source_dto.dart # DTO v1 與 sourceDtoShapes(欄位表) + json_shape.dart # JsonShape、JsonFields:封閉物件的解碼 + script_source_plugin.dart # ScriptSourcePlugin、ScriptPluginLoader(載入、匯出檢查) + plugin_registry.dart # pluginRegistryProvider:已載入的插件 + manifest/ + plugin_manifest.dart # PluginManifest、PluginCapability、hostApiVersion、manifestShapes + plugin_file.dart # PluginFile:從安裝檔標頭取出 manifest + runtime/ # 只有這裡 import flutter_js(實際只有 plugin_worker.dart) + plugin_runtime.dart # PluginRuntime(主 isolate):spawn、訊息、宿主呼叫、看門狗、錯誤轉換 + plugin_worker.dart # 背景 isolate:QuickJS、prelude、Promise 工作、crypto + worker_protocol.dart # 兩邊之間的訊息 + js_prelude.dart # 建出 fmp 的 JS + plugin_host.dart # PluginHost(主 isolate):網路、storage、憑證、log、hostApiShapes + script_errors.dart # 結構化錯誤 → AppError + install/ + plugin_installer.dart # PluginInstaller:解析 → 載入 → 寫入 → 註冊 + dev_plugin_entry.dart # dev 的開發入口 + types/fmp-plugin.d.ts # 給插件作者的 TypeScript 型別 +``` + +## 寫一個插件 + +```js +/* ==FMP Plugin== +{ + "id": "example", + "name": "Example", + "version": "1.0.0", + "author": "you", + "apiVersion": 1, + "capabilities": ["search", "resolveStream"], + "allowedHosts": ["api.example.com", "cdn.example.com"] +} +==/FMP Plugin== */ + +export async function search({ keyword, page }) { + const response = await fmp.http.request({ + url: `https://api.example.com/search?q=${encodeURIComponent(keyword)}&p=${page}`, + }); + if (response.status === 429) throw { fmpError: 'RateLimited' }; + if (response.status !== 200) throw { fmpError: 'ParseError', message: `HTTP ${response.status}` }; + const json = JSON.parse(response.body); + return { + items: json.list.map((item) => ({ sourceId: item.id, title: item.name, durationMs: item.ms })), + hasMore: json.more, + }; +} + +export async function resolveStream({ sourceId, cid, formats }) { + // 依 formats 挑平台能播的;找不到就拋 NotFound 或 Unavailable,不回空清單。 + return { candidates: [{ url: 'https://cdn.example.com/a.m4a', container: 'mp4', codec: 'aac' }] }; +} +``` + +- 型別與每個欄位的限制看 `lib/plugins/types/fmp-plugin.d.ts`;物件多一個欄位就整個被拒收。 +- 能力名稱=匯出函式名稱。其他匯出(常數、helper)不影響。 +- 狀態碼與業務錯誤碼在插件裡轉成結構化錯誤(`.trellis/spec/app/errors/index.md` § 音源的錯誤 + 對應表);`fmp.http.request` 自己丟的錯誤(網域不符、限流重試後仍失敗、傳輸錯誤)直接讓它往上拋。 +- 要跨重啟的值(匿名 cookie 等)存 `fmp.storage`;`fmp.credentials.get()` 在 M1 一律是 `null`。 +- 沒有 `setTimeout`、`fetch`、`require`,也不能 `import` 其他 module:一個檔案就是全部。 +- 用 `fmp-test` 當範本:`app/test/fixtures/plugins/test_plugin/test_plugin.js`。 + +## 在 App 裡試插件 + +- 測試:`PluginHarness().load(source)`(`test/plugins/plugin_harness.dart`),HTTP 走假 adapter。 +- dev 實機:Windows `flutter run --dart-entrypoint-args=--fmp-dev-plugin=<路徑>`,或設環境變數 + `FMP_DEV_PLUGIN`;Android 見 `app/AGENTS.md` § 插件的 `adb` 指令。身分頁列出載入的插件。 + +## 加一個宿主 API + +1. `js_prelude.dart`:在 `fmp` 上加函式,經 `callAsync`(會等 I/O)或 `callSync`(純計算、log)送 + 一個 op 名稱與物件參數。 +2. 決定它在哪個 isolate 執行: + - 碰外界(網路、檔案、資料庫、憑證)或需要安全檢查的:`callAsync`,在主 isolate 的 + `plugin_host.dart` 的 `callAsync` `switch` 加 op(背景 isolate 以 `HostRequest` 轉過來)。插件 id + 只能用 `PluginHost.pluginId`,不收腳本傳來的 id。 + - 純計算:`callSync`,在 `plugin_worker.dart` 的 `_sync` 加 op,不送訊息。背景 isolate 只能做不需要 + 信任的事:它跑的是插件的程式。 + 參數用 `JsonFields` 讀;參數不對拋 `ArgumentError`/`FormatException`(腳本收到 `TypeError`), + 其他失敗拋 `AppError`(腳本收到帶 `fmpError` 的 Error)。 +3. `fmp-plugin.d.ts`:加到對應的 `Fmp*` interface;新的 JSON 物件同時加 interface 與 + `hostApiShapes` 的一列。`type_definitions_test.dart` 會比對。 +4. 測試:`test/plugins/runtime/plugin_runtime_test.dart` 的 `host API` 群組。碰到儲存或網路的 API + 要有「另一個插件看不到」的案例。 + +要不要加 `hostApiVersion`: + +- **只加東西**(新函式、DTO 或參數的新**選填**欄位):不加版本。舊插件照跑;新插件用了新東西,在 + 舊 App 上會得到 `TypeError` 或被拒收。要讓新插件在舊 App 上明確失敗,改用下一條。 +- **不相容**(改名、刪除、選填改必填、改語意、DTO 欄位改型別):`hostApiVersion` 加一。版本不同的 + manifest 一律 `Unsupported`(`PluginManifest.parse` 開頭),同時把 `apiVersion: 1` 的插件升級。 + 這是對外介面的破壞性變更,先告知擁有者(插件庫要同步發版)。 + +## 加一個 DTO 欄位或能力 + +- DTO:`source_dto.dart` 的類別、`fromJson`/`toJson` 與 `sourceDtoShapes` 一起改,`fmp-plugin.d.ts` + 同步。欄位名稱是插件的介面,列舉值寫死字串(`wireName`),不用 `.name`。 +- 能力:`PluginCapability` 已有 ADR 0014 的 12 個;新能力的方法加在 `SourcePlugin`(引入它的里程碑 + 才加,不寫空殼),`ScriptSourcePlugin` 以 `_invoke` 呼叫同名匯出,再加 `fmp-plugin.d.ts` 的 + `FmpPluginExports`。 + +## 測試 + +- `flutter test` 直接跑 QuickJS(`test/support/quickjs.dart`)。只在 Windows、Linux;macOS 會失敗。 +- `PluginHarness().runtime(script)` 測 runtime 與宿主 API:腳本只寫 module(沒有標頭),任意名稱的 + 匯出都能 `invoke`。`.load(source)` 測整條載入(manifest、匯出檢查、DTO 驗證);`pluginSource()` + 組標頭。storage 有外鍵,兩者預設先在 `installed_plugins` 放一列。 +- 看門狗用小的 `callTimeout`、`livenessGrace`(例如 500ms/300ms),太小會讓背景 isolate 還在建 + QuickJS 時就被判逾時。等非同步結果用 `pumpUntil`/`settle`;要等實際時間(計時器)就 await 一個由 + 計時器完成的 `Completer`,`pumpUntil` 不會等時間。 +- 同步卡住的測試(`while(true){}`)每個會留下一條忙著的執行緒到測試檔的行程結束,只寫必要的幾個。 +- 背景 isolate 當掉的情況用 `runtime(entryPoint: ...)` 換掉進入點(照 `worker_protocol.dart` 的協定 + 回訊息再拋錯),不在正式程式碼留開關。 +- 走 Riverpod 的測試(安裝、清單)用 `ProviderContainer(retry: (_, _) => null, ...)`:App 關了重試, + 不關的話失敗的 provider 會一直重試到測試逾時。 +- 錯誤 fixture 的契約測試、`checks.json` 在 PR 9b。 + +## Quality Check + +- `test/plugins/` 全綠;新的宿主 API 有隔離案例;`type_definitions_test.dart` 綠。 +- `lib/plugins/` 以外沒有 import `flutter_js`;沒有網址字面值(`fmp_url_literal`)。 +- 改了 `fmp` 形狀或 DTO:`fmp-plugin.d.ts` 一起改,`app/AGENTS.md` § 插件若規則變了也改。 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md index 68d1ad9f..6df3f093 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md @@ -24,6 +24,38 @@ - 實作用 opus:PR 2–10、12、13 與 YouTube.js 探針。 - `trellis-check` 一律 opus;研究代理一律 sonnet。 +## 進度與交接(2026-09-30 更新;compact 後從這裡接) + +- **已合併進 `main`**:#173(設計文件)、#174(PR 1 指令檔分家)、#175(PR 2 骨架與 CI)、#177(PR 3 lint)、#178(PR 4 平台層)、#179(PR 5 drift)、#180(PR 6 log 與設定)、#181(PR 7 錯誤模型)、#182(PR 8 網路層)。#176 是 CI 路徑探測,已關閉。 +- **isar/sqlite3 共存探針**:已完成,兩平台共存、全部 16KB 對齊(`research/isar-sqlite3-coexistence.md`,ADR 0010 已補)。 +- **PR 9a 完成**(分支 `feat/js-runtime`,子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-js-runtime/`):每插件一個背景 isolate 的 QuickJS、宿主 API v1、manifest、從檔案安裝與 dev 開發入口、測試插件 `fmp-test`;數字在該子任務 `research/notes.md` §4。 + - 實機:Windows dev 開發入口裝上、重啟後從資料庫載入、prod 不理會旗標;Android 模擬器 dev 的前兩項。模擬器上的 `com.personal.fmp` 是舊版 1.11.0,prod 沒裝上去驗;prod 那一段由單元測試守(`devPluginPath` 對 prod 一律回 `null`,有變異驗證)。 +- **下一步**:9b(fixture 錄製重播、`checks.json`、契約執行器)→ 9c(建 `1morr/fmp-plugins`、B 站插件、錄一次 fixture)→ YouTube.js 探針(與 10–13 並行)→ 10 播放核心 → 11 verify-on-device → 12 UI → 13 五平台建置與發版 workflow → 里程碑驗收。 +- **擁有者決定**:1–7 都在父任務 `prd.md`「擁有者的決定」。9a 期間新增了兩項: + - 決定 6:插件安裝檔是單一 `.js`,開頭帶 `==FMP Plugin==` manifest; + - 決定 7:插件在背景 isolate 執行;逾時先送存活探測,沒回應才停用到重啟。 +- **每個 PR 的固定流程**: + 1. 從最新 `main` 開分支; + 2. `task.py create … --parent .trellis/tasks/09-28-m1-skeleton-tracer --package app --no-start`; + 3. 寫 prd(繁中,列做什麼與驗收)與 `implement.jsonl`/`check.jsonl`; + 4. `task.py start`; + 5. 派 opus `trellis-implement`,驗證清單固定為:format、build_runner 後沒有實質變動、`dart analyze --fatal-infos`、`flutter analyze`、`flutter test`、哨兵、需要時建置; + 6. 使用者看得到的改動,由主對話實機驗證(Windows 用 msaa_tree.ps1 讀畫面文字;Android 用 adb 的 `run-as` 與 `screencap`); + 7. 派 opus `trellis-check`,要它試著攻破安全相關的部分; + 8. 把後續待辦寫進本檔; + 9. `git checkout --` 還原只有換行差異的產生檔(`generated_plugin*`、`app_database.g.dart`、`GeneratedPluginRegistrant.swift`); + 10. 分開 commit; + 11. `task.py finish`,再 `archive --no-commit --skip-branch-validation`,把 archive commit 掉; + 12. push,`gh pr create`(繁中描述+review 指南); + 13. 背景跑 `gh pr checks --watch`; + 14. 全綠後 `gh pr merge --merge`,main 快轉。 +- **地雷**: + - 文件或程式碼引用子任務的研究檔時,一律寫 archive 後的路徑 `.trellis/tasks/archive/2026-09/<任務>/…`。 + - CI 的 `app` job 工作目錄已經是 `app/`,路徑不要再加 `app/`。 + - 子代理有時用不了 context7 或 WebFetch,會改用 curl 或 pub cache 原始碼查證,這是可以接受的。 + - 子代理曾因 API 403(`oauth_org_not_allowed`)中斷,用 SendMessage 續跑就成功了。 + - 第 5 項的播放頁示意 Artifact 已被刪除,`phase2-plan.md` §10 的連結失效,決定仍以 ADR 0024 為準。 + ## 順序 ### 0. 合併 #173(設計文件進 `main`) @@ -115,16 +147,22 @@ - manifest 網域外的請求被拒; - 轉址超過 5 跳或出網域會失敗。 -### 9. JS 執行環境與插件 +### 9. JS 執行環境與插件(拆成 9a、9b、9c 三個 PR) + +- **9a** JS 執行環境、宿主 API v1、manifest 解析(安裝檔格式見 prd 擁有者決定 6)、`SourcePlugin` 轉接、從檔案安裝、測試插件;`flutter_js` 實測(含能否在 `flutter test` 內載入 QuickJS)。 +- **9b** fixture 錄製與重播 adapter、`checks.json`、`packages/plugin_contract` 契約執行器、CI 步驟。 +- **9c** 在 `1morr/fmp-plugins` 建 repo 與 B 站插件(`search`、`resolveStream`),錄一次 fixture;FMP 端無程式碼,或只有文件。 + +原清單: -- [ ] 在 `lib/plugins/` 建 `flutter_js`: +- [x] 在 `lib/plugins/` 建 `flutter_js`(9a): - 宿主 API v1 最小集; - manifest 驗證; - `apiVersion` 相容檢查; - 從檔案安裝。 -- [ ] TypeScript 型別定義。 +- [x] TypeScript 型別定義(9a)。 - [ ] `packages/plugin_contract/`:契約執行器、fixture 格式、`checks.json`。 -- [ ] `test/fixtures/plugins/test_plugin/`:合成資料、本機音檔。 +- [x] `test/fixtures/plugins/test_plugin/`:合成資料、本機音檔(9a)。 - [ ] 接真實 B 站時觀察:伺服器回不合法的 `Set-Cookie` 是否讓請求變成 `UnexpectedError`(`dio_cookie_manager` 的 `ignoreInvalidCookies` 預設 false;PR 8 檢查提出,沒有重現案例前不改)。 - [ ] 建立 `1morr/fmp-plugins`: - `bilibili/` 的 `search`、`resolveStream`; @@ -137,6 +175,12 @@ - 讀不到其他插件的 storage; - fixture 掃描不得有未遮蔽的憑證。 +9a 留下的後續: + +- [ ] 探測的取捨:插件若無限迴圈地呼叫宿主 API,每次都回應探測,只會一直得到 `NetworkError`、不會被停用(PR 9a 檢查提出)。M3 插件頁若要讓使用者手動停用,一併處理。 +- [ ] Android 的跨 isolate 成本:模擬器 debug 下每次宿主呼叫多約 7 ms,不經宿主的 `search` 也比純 Dart 對照慢,差額未查明。有實機時用 profile 模式重量(`integration_test/plugin_runtime_benchmark_test.dart`)。 +- [ ] Android 上 prod 的開發入口實機驗證:需要一台沒裝舊版的模擬器或實機(PR 13 或 M9 前)。 + ### 探針:YouTube.js(擁有者決定 3) - [ ] 9 合併後開一個子任務,時限約 2–3 個 session。 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/prd.md b/.trellis/tasks/09-28-m1-skeleton-tracer/prd.md index 5c5aa0fe..289b9a3b 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/prd.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/prd.md @@ -59,6 +59,9 @@ - M1 就建公開的 `1morr/fmp-plugins`,只放 README 與 `bilibili/`; - CI、`index.json`、插件頁留在 M3。 +6. **插件安裝檔格式**(2026-09-30):單一 `.js` 檔,開頭以 `/* ==FMP Plugin== … ==/FMP Plugin== */` 包一段 JSON manifest,App 不執行腳本就能讀出 manifest 並先檢查網域與能力(沿用使用者腳本 metadata block 的慣例;MusicFree、LX Music 也是一檔一插件)。插件庫可把 manifest 另存、由腳本合併成安裝檔;圖示只能用網址或內嵌 base64。 +7. **插件在背景 isolate 執行**(2026-09-30):`flutter_js` 0.8.7 的 QuickJS 沒有中斷機制,同步無窮迴圈會凍住 UI isolate(PR 9a 實測)。每個插件的 JS 執行環境放在自己的背景 isolate;呼叫逾時先送存活探測:背景 isolate 有回應就只讓這次呼叫以 `NetworkError` 失敗(只是在等網路),沒有回應或 isolate 已結束才把插件標成「沒有回應」並停用到 App 重啟(2026-09-30 擁有者確認);運算重的腳本(YouTube 解簽名)也不會讓畫面卡頓。不換引擎:可中斷的替代套件(`flutter_qjs_next`、`quickjs_engine`)都是單一作者、低採用的新套件。 + ## 需求 - **R1**:PR 1 完成指令檔與 spec 分家(決定 1,design §1),同時更正 ADR 0015、0021、0027 的事實。 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json index 96bf5199..b7adb204 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json @@ -26,7 +26,8 @@ "09-29-drift-schema", "09-29-logging-settings", "09-29-error-model", - "09-29-network-layer" + "09-29-network-layer", + "09-30-js-runtime" ], "parent": "09-26-fmp-rewrite", "relatedFiles": [], diff --git a/.trellis/tasks/archive/2026-09/09-30-js-runtime/check.jsonl b/.trellis/tasks/archive/2026-09/09-30-js-runtime/check.jsonl new file mode 100644 index 00000000..693d3cec --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-js-runtime/check.jsonl @@ -0,0 +1,7 @@ +{"file": "docs/adr/0014-script-source-plugins.md", "reason": "Plugin model, manifest, host API v1, install format"} +{"file": "docs/adr/0015-testing-gates-and-dev-environment.md", "reason": "Test plugin and contract runner context"} +{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/research/m1-tooling-facts.md", "reason": "flutter_js facts incl. flutter test loading"} +{"file": ".trellis/spec/app/network/index.md", "reason": "SourceHttpClient usage"} +{"file": ".trellis/spec/app/errors/index.md", "reason": "AppError mapping at the plugin boundary"} +{"file": ".trellis/spec/app/data/index.md", "reason": "installed_plugins and plugin_storage repositories"} +{"file": ".trellis/spec/app/lints/index.md", "reason": "Lint rules"} diff --git a/.trellis/tasks/archive/2026-09/09-30-js-runtime/implement.jsonl b/.trellis/tasks/archive/2026-09/09-30-js-runtime/implement.jsonl new file mode 100644 index 00000000..693d3cec --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-js-runtime/implement.jsonl @@ -0,0 +1,7 @@ +{"file": "docs/adr/0014-script-source-plugins.md", "reason": "Plugin model, manifest, host API v1, install format"} +{"file": "docs/adr/0015-testing-gates-and-dev-environment.md", "reason": "Test plugin and contract runner context"} +{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/research/m1-tooling-facts.md", "reason": "flutter_js facts incl. flutter test loading"} +{"file": ".trellis/spec/app/network/index.md", "reason": "SourceHttpClient usage"} +{"file": ".trellis/spec/app/errors/index.md", "reason": "AppError mapping at the plugin boundary"} +{"file": ".trellis/spec/app/data/index.md", "reason": "installed_plugins and plugin_storage repositories"} +{"file": ".trellis/spec/app/lints/index.md", "reason": "Lint rules"} diff --git a/.trellis/tasks/archive/2026-09/09-30-js-runtime/prd.md b/.trellis/tasks/archive/2026-09/09-30-js-runtime/prd.md new file mode 100644 index 00000000..596f28e7 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-js-runtime/prd.md @@ -0,0 +1,101 @@ +# JS 執行環境與插件載入(M1 PR 9a) + +父任務:`../09-28-m1-skeleton-tracer`(implement「9.」的 9a;prd 擁有者決定 6)。 + +依據: +- ADR 0014 §決定 1–5 與 2026-09-30 的補充:安裝檔格式; +- ADR 0015 §決定 6:測試插件; +- ADR 0012、0013:宿主 HTTP 經網路層、結構化錯誤轉成 `AppError`; +- ADR 0011:`log` 經門面。 + +## 做什麼 + +1. **引擎**:`flutter_js`(pub.dev 最新 stable),Android 與 Windows 用 QuickJS。 + - 先查官方文件、README 與原始碼: + - 怎麼執行腳本、怎麼跑 Promise/microtask; + - 怎麼從 JS 呼叫 Dart 的非同步函式; + - 每個插件一個獨立 runtime; + - 能不能執行 ES module 語法。 + - 若不支援 `export`,腳本改用約定的全域寫法,例如 `globalThis.fmpPlugin = { search, resolveStream }`,並更新 ADR 0014 補充句裡的範例說法。 + - 查證來源記在 `research/notes.md`。 +2. **安裝檔與 manifest**:`lib/plugins/manifest/`。 + - 從單一 `.js` 檔開頭的 `/* ==FMP Plugin== … ==/FMP Plugin== */` 取出 JSON,不執行腳本。 + - 欄位(ADR 0014 §決定 3): + - `id`、`name`、`version`、`author`、`apiVersion`; + - `capabilities`:本 PR 認得 ADR 的 12 個名稱,未知名稱就拒絕; + - `allowedHosts`; + - `login`:M1 只接受空值或省略; + - `retry`、`rateLimit`:對應 PR 7 的策略型別,可省略; + - `redaction`:追加的遮蔽名單; + - `defaults`、`icon`:網址或 `data:` base64。 + - 驗證: + - 必填欄位、型別; + - `apiVersion` 必須等於宿主支援的版本(v1),不相容就拒絕; + - `id` 格式:小寫英數與 `-`,長度上限; + - `allowedHosts` 只能是 host,不含 scheme 或路徑。 + - 錯誤以 `AppError`(`Unsupported` 或 `ParseError`)表達,附給使用者看的 key。 +3. **執行環境與宿主 API v1**:`lib/plugins/runtime/`。`flutter_js` 只准在這裡 import,`fmp_layer_imports` 已守。 + - 每插件一個 runtime,彼此看不到對方。 + - `http.request`:經 PR 8 的 `SourceHttpClient`,網域清單取自 manifest。JS 端拿到狀態碼、header、body 字串。每個請求的 `AuthRequirement` 由腳本宣告,預設 `never`。 + - `crypto`:M1 只提供 B 站需要的 `md5` 與 `sha256`(字串進、hex 出)。B 站 WBI 簽名要 md5,參考舊專案 `lib/data/sources/bilibili_source.dart`。 + - `storage`:該插件在 `plugin_storage` 的 get、set、delete(PR 5 的 repository),key 與 value 都是字串。 + - `credentials`:M1 一律回「沒有憑證」(`CredentialStore` 在 M3)。 + - `log`:經門面,tag 是插件 id,層級照門面。 + - **結構化錯誤**:腳本以約定的形狀 throw,例如 `{ fmpError: 'RateLimited', retryAfterSeconds, reason }`,宿主轉成對應的 `AppError`。其他例外在插件邊界包成 `UnexpectedError`(`AppError.wrap`,ADR 0013 §決定 2)。 + - 逾時:每次呼叫插件函式都有上限,例如 30 秒,超過就回 `NetworkError`,或另定的錯誤。 + - 啟動時把 manifest 的遮蔽名單交給遮蔽函式(PR 6 的追加 API)。 +4. **`SourcePlugin` 介面**:`lib/plugins/source_plugin.dart`,App 其他部分只認這個。 + - M1 的方法只有 `search` 與 `resolveStream`。其他能力的方法在引入它們的里程碑加,不寫空殼。 + - DTO,版本化,v1: + - `SearchQuery`:關鍵字、頁碼; + - `SearchPage`:項目、有沒有下一頁; + - `TrackSummary`:`TrackKey` 的三段、標題、上傳者、時長毫秒、`artwork`(`[{url, width?}]`); + - `StreamRequest`:`TrackKey`、平台可播格式、用途 `playback`; + - `StreamCandidate`:網址、headers、容器、編碼、位元率、`expiresAt`。 + - 輸入與輸出都驗證;插件回傳的形狀不對時回 `ParseError`。 + - 匯出一致性:manifest 宣告的能力,腳本必須都有匯出對應函式,否則拒絕載入(ADR 0014 §如何確認)。 +5. **從檔案安裝**:`lib/plugins/install/`,一個 service 接收檔案內容(bytes 或字串),依序: + 1. 解析 manifest; + 2. 在 runtime 試載入並檢查匯出一致; + 3. 寫進 `installed_plugins`(PR 5); + 4. 註冊到插件清單的 provider。 + + 已安裝同 id 時視為更新,storage 保留。選檔 UI 在 PR 12。本 PR 只提供 service,以及 dev 啟動時從命令列參數或環境變數讀路徑安裝的開發入口。只在 dev flavor 生效,寫明理由。 +6. **測試插件**:`app/test/fixtures/plugins/test_plugin/test_plugin.js`(ADR 0015 §決定 6)。 + - 用安裝檔格式,id 例如 `fmp-test`; + - 能力:`search`、`resolveStream`; + - 回傳合成資料,不發任何網路請求;串流指向 `app/` 內附的一個很短的本機音檔。音檔用 CC0,或自己用 `ffmpeg` 產生的靜音或正弦波,授權寫在旁邊。 + - 用來測執行環境、宿主 API、轉換;PR 10、12 的實機驗證也用它。 +7. **QuickJS 能否在 `flutter test` 內載入**(§7 的實測): + - 先試裸 `flutter test`; + - 不行就依研究的做法(先建置桌面產物,把原生庫放進 `PATH` 或設 `LIBQUICKJSC_TEST_PATH`); + - 再不行就改用 `integration_test`(ADR 0015 的退路)。 + - 把結論和實際可用的指令寫進 `app/AGENTS.md` 的驗證段;CI 的 `app` job 照這個結論跑執行環境的測試(Linux runner)。 +8. **`flutter_js` 實測**(§7),寫進 `research/notes.md`,並把數字交主對話轉給擁有者: + - Android 模擬器與 Windows 各量三件事:建立 runtime 的時間、載入測試插件的時間、一次 `search` 的往返時間(含 Promise); + - 建立 1 個與 3 個 runtime 後增加的記憶體(RSS); + - 量測用的程式碼放在 `integration_test/`,或一個只在 dev 的量測入口,不進正式路徑。 +9. **文件**: + - `app/AGENTS.md` 插件段:安裝檔格式、宿主 API 清單、結構化錯誤的形狀、`flutter_js` 只在 runtime 目錄; + - `.trellis/spec/app/plugins/index.md`(繁中):怎麼寫一個插件、怎麼加宿主 API(要改 `apiVersion` 嗎); + - TypeScript 型別定義 `app/lib/plugins/types/fmp-plugin.d.ts`,給插件作者用,內容與 DTO 一致。要有測試或腳本確認兩邊欄位一致;做不到就在 AGENTS.md 寫明靠 review。 + +## 驗收 + +- [ ] `app/`: + - format 通過; + - codegen 沒有變動; + - `dart analyze --fatal-infos`、`flutter analyze` 零問題; + - `flutter test` 全綠,執行環境的測試有真的跑 QuickJS,或寫明改走哪條路; + - 哨兵通過。 +- [ ] 測試: + - manifest 解析與拒絕的各種情況; + - 能力與匯出不一致時拒絕; + - `apiVersion` 不相容時拒絕; + - 網域外的請求被拒; + - 插件讀不到其他插件的 storage; + - 結構化錯誤轉成對應的 `AppError`; + - 逾時; + - 用測試插件做搜尋與解串流。 +- [ ] CI 的 `app` job 綠,而且跑到 QuickJS 的測試。 +- [ ] `flutter_js` 在 Android 與 Windows 的實測數字(主對話在實機上跑量測入口)。 diff --git a/.trellis/tasks/archive/2026-09/09-30-js-runtime/research/notes.md b/.trellis/tasks/archive/2026-09/09-30-js-runtime/research/notes.md new file mode 100644 index 00000000..6df3e498 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-js-runtime/research/notes.md @@ -0,0 +1,151 @@ +# flutter_js 查證與實測(M1 PR 9a) + +- 日期:2026-09-30 +- 環境:Flutter 3.47.5(Dart 3.13.4)、Windows 11 Pro for Workstations 10.0.26200、 + Android 模擬器 `emulator-5554`(Android 17,x86_64)。 +- 套件:`flutter_js` 0.8.7(pub.dev 最新,2026-01-27;`https://pub.dev/api/packages/flutter_js`)。 +- 所有結論來自套件原始碼(pub cache 的 `flutter_js-0.8.7/`)、上游 repo(`gh api`)或本機實測, + 逐條附來源。 + +## 1. API 與行為(原始碼) + +| 問題 | 結論 | 來源 | +|---|---|---| +| 各平台用哪個引擎 | `getJavascriptRuntime()`:Android、Windows、Linux 是 `QuickJsRuntime2`(dart:ffi),其他(iOS、macOS)是 `JavascriptCoreRuntime` | `lib/flutter_js.dart` | +| `getJavascriptRuntime()` 能不能用 | 不用:它預設 `xhr: true` 會裝 `fetch`/`XMLHttpRequest`(走 `package:http`,繞過宿主網路層),並裝 promise 輪詢(每 20ms 的 `Timer.periodic`)| `lib/flutter_js.dart`、`lib/extensions/fetch.dart`、`lib/extensions/handle_promises.dart` | +| 建構子另外裝了什麼 | `QuickJsRuntime2` 建構子呼叫 `init()`:`sendMessage`、`console`(寫到 `print`)、`setTimeout`(Timer 到期時呼叫 `evaluate`;`evaluate` 會在 runtime 釋放後重建引擎)| `lib/javascript_runtime.dart` 的 `init`、`_setupSetTimeout`;`lib/quickjs/quickjs_runtime2.dart` 的 `evaluate` → `_ensureEngine` | +| 執行腳本 | `evaluate(code, name:, evalFlags:)`,同步回 `JsEvalResult`(`rawResult` 是轉成 Dart 的值)| `quickjs_runtime2.dart` | +| Promise/microtask | JS 回傳的 Promise 轉成 Dart `Future`(`_jsToDart` 對 `jsIsPromise` 掛 `then`);Promise 工作**不會自己跑**,要呼叫 `executePendingJob()`(→ `JS_ExecutePendingJob` 迴圈)| `lib/quickjs/wrapper.dart`、`quickjs_runtime2.dart` 的 `_executePendingJob` | +| JS 呼叫 Dart 的非同步函式 | Dart 的 `Function` 轉成 JS 函式(`JSInvokable`);它回傳的 `Future` 轉成 JS Promise(`jsNewPromiseCapability`,Future 完成時呼叫 resolve/reject)。不需要 `sendMessage` 的 JSON 通道 | `wrapper.dart` 的 `_dartToJs` | +| 每插件一個 runtime | 每個 `QuickJsRuntime2` 各自 `JS_NewRuntime` + `JS_NewContext`,堆積不共用 | `quickjs_runtime2.dart` 的 `_ensureEngine`;實測見 §3 | +| ES module | 有 module loader:建構參數 `moduleHandler(name) → 原始碼`,由 `import()` 觸發(`JS_SetModuleLoaderFunc`);`moduleHandler` 丟錯時 import 被拒絕 | `quickjs_runtime2.dart`;上游 `abner/quickjs-c-bridge` 的 `cxx/libfastdev_quickjs_runtime.cpp` 的 `jsNewRuntime`;實測見 §3 | +| 釋放 | `dispose()`:關 port、`JS_FreeContext`、銷毀所有 Dart 端持有的 JS 參照、`JS_FreeRuntime`;洩漏的參照只 `print` | `quickjs_runtime2.dart` 的 `dispose`、`close`;`lib/quickjs/ffi.dart` 的 `jsFreeRuntime` | +| 未處理的 rejection | `hostPromiseRejectionHandler`;QuickJS 在拒絕的當下沒有處理者就回報,之後接上也不收回 | 上游 bridge 的 `js_promise_rejection_tracker`(`is_handled` 為真才略過);實測:`async` 函式拋錯、呼叫端隨後 `await` 也會報 | +| 逾時/記憶體上限 | **沒有作用**。Dart 端把 `timeout` 傳給 `jsNewRuntime`,但上游 bridge 的 `jsNewRuntime(JSChannel)` 只收一個參數、沒有 interrupt handler;Windows 的 `quickjs_c_bridge.dll` 匯出 65 個符號,沒有 `jsSetMemoryLimit`、沒有任何 interrupt 相關符號(傳 `memoryLimit` 會在 lookup 時拋錯)| `ffi.dart` 的 `jsNewRuntime`、`jsSetMemoryLimit`;上游 bridge 原始碼;DLL 匯出表(以 Python 解析 PE);實測:`timeout: 1000` 下 `while(true){}` 卡住 3 分鐘直到測試被停 | + +上游狀態(`gh api repos/abner/flutter_js`、`repos/abner/quickjs-c-bridge`,2026-09-30):flutter_js 最後一次 +commit 2026-01-27;quickjs-c-bridge 最後 push 2021-04-13。 + +### 採用的做法 + +- 繼承 `QuickJsRuntime2`、覆寫 `init()` 為不做事:不裝 `console`、`setTimeout`、`sendMessage`。 + 測試逐一比對全域名稱(`plugin_runtime_test.dart` 的 `the global object has only ...`)。 +- 宿主 API 以一段 prelude(`lib/plugins/runtime/js_prelude.dart`)建出唯讀的 `fmp`;兩個 Dart 函式 + 只經閉包傳入,全域看不到。 +- 插件腳本是 **ES module**(`export async function search(...)`),以 `import('fmp-plugin')` 載入, + loader 只對這個名稱回傳腳本。所以不需要改用全域寫法,ADR 0014 的補充句不用改。 +- Promise 工作:呼叫之後、每次宿主非同步函式完成後各執行一次;後者用 `scheduleMicrotask`(見 §4)。 +- 每個插件的 QuickJS 在自己的背景 isolate(§6,擁有者 2026-09-30 決定)。 +- iOS、macOS 目前也走 `QuickJsRuntime2`(App 在這兩個平台還沒支援);JavaScriptCore 在那兩個平台的 + 任務處理。 + +## 2. QuickJS 能否在 `flutter test` 內載入(§7) + +結論:**可以,裸 `flutter test` 就行,不必先建置桌面版,也不必設環境變數。** + +- flutter_js 在測試裡(`FLUTTER_TEST=true`,flutter_tools 會設)以檔名開原生庫:Windows + `quickjs_c_bridge.dll`;Linux `LIBQUICKJSC_TEST_PATH` 或 `libquickjs_c_bridge_plugin.so` + (`ffi.dart` 的 `_qjsLib`)。套件在 `windows/shared/`、`linux/shared/` 內附預先編譯的這兩個檔。 +- 實測(Windows):不做任何事時載入失敗(`error code: 126`)。先以絕對路徑 + `DynamicLibrary.open(/flutter_js-0.8.7/windows/shared/quickjs_c_bridge.dll)`,之後 flutter_js + 以檔名開就拿到同一份(LoadLibrary 對已載入的同名模組直接回傳,Microsoft Learn〈Dynamic-link library + search order〉)。 +- Linux:`libquickjs_c_bridge_plugin.so` 的 SONAME 就是 `libquickjs_c_bridge_plugin.so`,只依賴 + `libstdc++`、`libm`、`libgcc_s`、`libc`(解析 ELF dynamic section)。glibc 的 dlopen 會比對已載入物件的 + SONAME。以 Docker `python:3-slim`(Debian、glibc)實測:以檔名 dlopen 先失敗;以絕對路徑載入後再以 + 檔名 dlopen 成功,拿到同一個 handle,`jsNewRuntime` 符號存在。**完整的 `flutter test` 在 Linux 上只能在 + CI 驗證**(本機沒有 Linux 的 Flutter)。 +- 實作:`app/test/support/quickjs.dart` 從 `.dart_tool/package_config.json` 找 flutter_js 的位置並預先載入, + `test/flutter_test_config.dart` 對每個測試檔呼叫;Windows、Linux 找不到或載入失敗就拋錯(CI 不會默默跳過)。 +- 所以 CI 的 `app` job 不用加步驟,`flutter test` 就會跑到 QuickJS 的測試。`integration_test` 的退路不需要。 + +## 3. 探針實測(Windows,`flutter test`) + +- `evaluate('1+2')` → `3`;Dart 函式回 `Future` → JS `await` 拿到值;JS `async` 函式 → Dart `Future`。 +- `import('plugin')` 經 `moduleHandler` 載入 `export function search…`,拿到 namespace(`search,x`); + `import('other')`(handler 丟錯)→ Promise 被拒絕。直接以 `JSEvalFlag.MODULE` evaluate 不回傳匯出,不用。 +- 兩個 runtime:一個設 `globalThis.secret`,另一個 `typeof secret` 是 `undefined`。 +- 同步 `throw` → `JsEvalResult.isError`;Dart 函式丟錯 → JS 的 `catch` 拿到;參數個數不合 → + JS 拿到 `NoSuchMethodError`;`JSON.stringify(2**53)` 正常(沒有 BigInt)。 + +## 4. 實測數字(量測入口 `app/integration_test/plugin_runtime_benchmark_test.dart`) + +方法:`flutter test integration_test/plugin_runtime_benchmark_test.dart -d <裝置>`(dev flavor、**debug +模式**:`flutter test` 不支援 profile;x86_64 模擬器不能跑 profile)。Stopwatch 量主 isolate 看到的時間。 + +- 「建立 runtime」:載入一個空插件的完整流程:解析 manifest、建 HTTP client、**spawn 背景 isolate**、 + QuickJS runtime、prelude、module 載入、匯出檢查。 +- 「載入測試插件」:同上但換成 `fmp-test`。 +- 「search 往返」:DTO 轉 JSON、送到背景 isolate、Promise、一次 `fmp.storage.get`(跨 isolate 回主 isolate + 查 drift 記憶體資料庫)、結果送回、驗證。 +- 「宿主呼叫往返(跨 isolate)」:search 裡連續 20 次 `await fmp.credentials.get()`, + (中位數 − 沒有宿主呼叫的 search 中位數)÷ 20。 +- 「純 Dart isolate 往返」:對照組,spawn 一個只會回送的 isolate,量一次送收。 +- RSS 是 `ProcessInfo.currentRss` 的差(Dart 不能強制 GC,數字有雜訊;負值就是雜訊)。 + +### 背景 isolate 版(現行) + +| 項目 | Windows(3–4 次) | Android 模擬器(3–6 次) | +|---|---|---| +| 第一次建立 runtime(含 dlopen、JIT 首次編譯、spawn) | 49–82 ms | 64–206 ms | +| 建立 runtime(空插件,9 次中位數) | 1.5–2.0 ms | 13–18 ms | +| 載入測試插件(9 次中位數) | 1.5–1.6 ms | 11–21 ms | +| 第一次 search | 33–42 ms | 72–106 ms | +| search 往返(21 次中位數,含一次宿主非同步呼叫) | 0.8–0.9 ms | 11–20 ms | +| search 往返,沒有宿主呼叫 | 0.3 ms | 5.8–15.5 ms | +| 宿主呼叫往返(跨 isolate,每次) | 0.16–0.22 ms | 6.4–8.8 ms | +| 純 Dart isolate 往返(對照) | 0.035 ms | 1.8–8.2 ms | +| 1 個 runtime 的 RSS 增量 | −0.4–2.9 MB | 1.0–1.1 MB | +| 3 個 runtime 的 RSS 增量 | 4.6–9.0 MB | 3.1–3.4 MB | + +判讀:Windows 上背景 isolate 幾乎沒有成本。Android 模擬器上每次跨 isolate 的來回是毫秒級,而且純 Dart 的 +對照組本身就在 1.8–8.2 ms 之間跳,所以主要是模擬器上 isolate 訊息的延遲,不是 QuickJS;插件每多一次宿主 +呼叫約多 7 ms。網路請求本身是幾十到幾百毫秒,對搜尋、解串流影響小;要準確的數字得在實機以 profile 模式量 +(目前沒有實機)。記憶體:背景 isolate 與 QuickJS 合計每個插件約 1–3 MB。 + +### 主 isolate 版(已被取代,留作比較) + +| 項目 | Windows(3 次) | Android 模擬器(3 次) | +|---|---|---| +| 第一次建立 runtime | 63–97 ms | 89–149 ms | +| 建立 runtime(空插件,9 次中位數) | 1.5–3.1 ms | 3.0–8.3 ms | +| 載入測試插件(9 次中位數) | 1.7–3.4 ms | 2.7–6.8 ms | +| 第一次 search | 53–133 ms | 108–184 ms | +| search 往返(含一次宿主非同步呼叫) | 1.1–2.2 ms | 2.4–3.2 ms | +| search 往返,沒有宿主呼叫 | 0.4–1.2 ms | 1.2–1.5 ms | +| 1 個 runtime 的 RSS 增量 | −1.4–2.9 MB | 1.2–1.3 MB | +| 3 個 runtime 的 RSS 增量 | 2.8–8.6 MB | 2.2–3.3 MB | + +**發現並修正**(主 isolate 版時):宿主非同步函式完成後原本以 `Timer.run` 執行 Promise 工作;Android 的 UI +isolate 上它要等下一輪訊息迴圈,search 往返中位數 16–31 ms。改成 `scheduleMicrotask` 後 2.4–3.2 ms。背景 +isolate 版沿用 `scheduleMicrotask`。 + +## 5. 建置與相容性 + +- Android:flutter_js 的 `android/build.gradle` 把 Kotlin `jvmTarget` 寫死 1.8、沒設 Java 的 `compileOptions`, + AGP 9.1 預設 Java 11,`compileDebugKotlin` 失敗(Inconsistent JVM Target Compatibility)。在 + `app/android/build.gradle.kts` 只對 `flutter_js` 子專案把 Java 設成 1.8 後建置成功。 +- Android 原生庫 `libfastdev_quickjs_runtime.so` 來自 jitpack 的 `fast-development/android-js-runtimes` + 0.3.6(套件的 build.gradle 自己加了 jitpack repository)。 +- Flutter 3.47.5 建置時警告 flutter_js「apply Kotlin Gradle Plugin (KGP)」,要遷移到 Built-in Kotlin;目前只是 + 警告,之後的 Flutter 若改成錯誤,要等上游或自己 fork。 +- 同步的無窮迴圈中斷不了(§1 的逾時列):由 §6 的背景 isolate 與看門狗處理,卡住的執行緒仍回收不了。 + +## 6. 背景 isolate 與看門狗(擁有者 2026-09-30 決定;prd 擁有者決定 7、ADR 0014 補充) + +- 每個插件一個 `Isolate.spawn` 的背景 isolate(`lib/plugins/runtime/plugin_worker.dart`),裡面是那個插件的 + QuickJS。主 isolate(`plugin_runtime.dart`)執行宿主 API 的網路、storage、憑證、log,以訊息回覆 + (`worker_protocol.dart`);網域與插件 id 的檢查都在主 isolate。`crypto` 在背景 isolate 算。 +- 原生庫:`DynamicLibrary.open` 是整個行程共用的;背景 isolate 裡 flutter_js 以檔名再開一次就拿到已載入的那 + 份。`flutter test`(Windows)與 App(Windows dev 建置、Android 模擬器)都實測可以:所有 runtime 測試現在 + 都在背景 isolate 跑 QuickJS。 +- 看門狗:每次呼叫(含載入,從 spawn 完成起算)30 秒。到期時送 `Ping`,2 秒內收到 `Pong` 就是「在等東西」 + (例如慢的網路),這次呼叫以 `NetworkError` 失敗、插件照常;收不到就判定卡住:`PluginHealth.unresponsive`、 + 進行中與之後的呼叫以 `UnexpectedError` 失敗(插件的 bug:不可重試、通用訊息)、`log.report`、 + `Isolate.kill(priority: immediate)`、停用到 App 重啟。背景 isolate 意外結束(`onError`/`onExit`)同樣處理。 +- **與指示的差異**:指示是「逾時就標成沒有回應」。加了探測是因為呼叫的 30 秒也包含等網路(網路層的連線逾時 + 10 秒、最多重試 2 次),只看逾時會把網路慢的插件停用到重啟;只有事件迴圈也不動(同步卡住)才停用。 +- **剩下的限制**:`Isolate.kill` 要等 isolate 回到 Dart 的事件迴圈才生效,停在 QuickJS 原生碼裡的執行緒 + 會一直佔著一個核心,直到行程結束。 +- 實測(`flutter test`,Windows):`search` 跑 `while(true){}`、逾時 500 ms、探測 300 ms:呼叫在期限後以 + `UnexpectedError` 失敗、插件轉成沒有回應;卡住期間主 isolate 的計時器照常觸發、另一個插件照常回應。 diff --git a/.trellis/tasks/archive/2026-09/09-30-js-runtime/task.json b/.trellis/tasks/archive/2026-09/09-30-js-runtime/task.json new file mode 100644 index 00000000..19abf67d --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-js-runtime/task.json @@ -0,0 +1,26 @@ +{ + "id": "js-runtime", + "name": "js-runtime", + "title": "JS 執行環境與插件載入", + "description": "M1 PR 9a: flutter_js runtime, host API v1, manifest header parsing, SourcePlugin adapter, install from file, synthetic test plugin, flutter_js measurements", + "status": "completed", + "dev_type": null, + "scope": null, + "package": "app", + "priority": "P2", + "creator": "1morr", + "assignee": "1morr", + "createdAt": "2026-09-30", + "completedAt": "2026-09-30", + "branch": "feat/js-runtime", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "09-28-m1-skeleton-tracer", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/app/AGENTS.md b/app/AGENTS.md index bdb43921..67c7d31e 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -22,6 +22,13 @@ - `test/identity/windows_identity_test.dart` 要 `cmake`:CI 的 ubuntu runner 內建; Windows 的 PATH 上沒有時,測試以 vswhere 找 Visual Studio 附的那一份。 - 不帶 `--flavor` 的 run/build 是 dev(`pubspec.yaml` 的 `default-flavor`)。 +- 插件執行環境的測試在裸 `flutter test` 裡跑真的 QuickJS:`test/flutter_test_config.dart` + 經 `test/support/quickjs.dart` 先以絕對路徑載入 flutter_js 內附的原生庫(Windows、Linux), + 不必先建置桌面版;插件的背景 isolate 以檔名開到同一份(整個行程共用)。找不到就拋錯,CI 不會 + 默默跳過。macOS 不支援。看門狗的測試會留下一條忙著的執行緒,到那個測試檔的行程結束為止。 +- 插件執行環境的實機量測:`flutter test integration_test/plugin_runtime_benchmark_test.dart -d <裝置>` + (dev flavor;結果是 `FMP_BENCH` 開頭的行)。數字與方法在 + `.trellis/tasks/archive/2026-09/09-30-js-runtime/research/notes.md` §4。 ### 實機驗證 @@ -215,6 +222,55 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 `Referer`、`User-Agent`、`Origin`、`Range`。閘門:`media_headers_test.dart`;後端確實 經過它,由 PR 10 的測試接手。 +## 插件 + +`lib/plugins/`(ADR 0014)。怎麼寫插件、怎麼加宿主 API:`.trellis/spec/app/plugins/index.md`; +給插件作者的型別定義:`lib/plugins/types/fmp-plugin.d.ts`。 + +- 安裝檔是單一 `.js`:開頭(前面只准 BOM 與空白)以 `/* ==FMP Plugin==`、`==/FMP Plugin== */` + 包一段 JSON manifest,之後是 ES module。讀 manifest 不執行腳本。manifest 與 DTO 的物件是封閉的: + 不認得的欄位整個拒收。閘門:`test/plugins/manifest/`。 +- 能力與匯出函式同名、雙向一致:宣告了沒匯出、匯出了能力名稱卻沒宣告,都拒絕載入 + (`Unsupported`);其他名稱的匯出不管。`apiVersion` 必須等於 `hostApiVersion`。閘門: + `script_source_plugin_test.dart` 的 `exports and capabilities`、`plugin_manifest_test.dart`。 +- manifest 的 `allowedHosts` 管插件交給宿主的每個網址:`fmp.http.request`(網路層擋,見「網路」)、 + 串流候選(另外只准 `asset:///`,給測試插件)、封面、圖示。閘門:`script_source_plugin_test.dart` + 的 `returned values`、`plugin_runtime_test.dart` 的 `a host outside the manifest…`。 +- 腳本的全域只有 JS 內建、唯讀的 `fmp`(宿主 API v1)與轉到 `fmp.log` 的 `console`。不用 + flutter_js 的 `getJavascriptRuntime()`(會裝繞過網路層的 `fetch`/`XMLHttpRequest`),它建構子裝的 + `console`、`setTimeout`、`sendMessage` 也不裝。閘門:`plugin_runtime_test.dart` 的 + `the global object has only the built-ins, fmp and console`。 +- 每插件一個背景 isolate,裡面是那個插件的 QuickJS(prd 擁有者決定 7、ADR 0014 2026-09-30 補充)。 + 宿主 API 的網路、storage、憑證、log 在主 isolate 執行、以訊息回覆,網域與插件 id 的檢查只在主 + isolate;`crypto` 在背景 isolate 算。`PluginHost` 在建構時綁定插件 id,腳本沒有辦法指定別的插件。 + 閘門:`runtimes do not share globals`、`storage belongs to one plugin`。 +- 錯誤:結構化錯誤 `throw {fmpError: '', retryAfterSeconds?, reason?, message?}` + (或 Error 帶這些屬性)轉成那個類別,`message` 只進 log;宿主 API 丟出的錯誤被腳本再拋出時原樣 + 交出(保留網路紀錄 id);其他拋出的值是 `UnexpectedError`,載入時的語法錯誤與形狀不對的回傳值是 + `ParseError`。閘門:`plugin_runtime_test.dart` 的 `errors` 群組、`a host error thrown on keeps its + network record`。 +- 看門狗:每次呼叫(含載入,從 isolate 起來後算)30 秒。到期時探測背景 isolate:2 秒內有回應就只是 + 在等(例如網路),這次呼叫 `NetworkError`、插件照常;沒有回應(同步卡住)或背景 isolate 意外結束, + 插件轉成 `PluginHealth.unresponsive`:進行中與之後的呼叫都是 `UnexpectedError`(插件的 bug)、留在 + 清單上但停用到 App 重啟,並 `Isolate.kill`。閘門:`plugin_runtime_test.dart` 的 `timeouts and + disposal` 群組(真的 `while(true){}`、主 isolate 照常、另一個插件照常、背景 isolate 當掉)、 + `plugin_installer_test.dart` 的 `a plugin that stops responding…`。 +- **卡住的執行緒回收不了**:QuickJS 的原生碼中斷不了,`Isolate.kill` 要等 isolate 回到 Dart 才生效, + 那條執行緒一直忙到 App 結束。沒有閘門,已知限制。 +- `flutter_js` 只准在 `lib/plugins/runtime/` import(`fmp_layer_imports`),版本釘死 + (`pubspec.yaml` 的註解)。 +- `fmp-plugin.d.ts` 與 Dart 端一致:interface 的欄位與必填對 `manifestShapes`、`sourceDtoShapes`、 + `hostApiShapes`,`FmpHost` 對 prelude 實際建出的 `fmp`,能力、錯誤名稱、`Unavailable` 原因三個 + union 對 Dart 的列舉。閘門:`test/plugins/type_definitions_test.dart`(含變異案例)。函式參數的 + 型別不比對,review 時看。 +- 開發入口:dev flavor 啟動時安裝 `--fmp-dev-plugin=<路徑>` 或環境變數 `FMP_DEV_PLUGIN` 指的檔案; + Android 以 `adb shell am start -n com.personal.fmp.dev/com.personal.fmp.MainActivity --esal + dart_entrypoint_args --fmp-dev-plugin=` 帶參數。prod 不讀:這條路徑跳過安裝前的 + 確認(ADR 0014 §決定 6),參數與環境變數都能由別的程式帶入。`devPluginPath` 在 prod 一律回 + `null`。閘門:`plugin_installer_test.dart` 的 `development entry`(含 `prod reads neither…`)。 +- 測試插件 `test/fixtures/plugins/test_plugin/`(`fmp-test`)只以 dev flavor 的 asset 打包,串流指向 + 同目錄的 `tone.wav`(`asset:///…`)。prod 的建置只留下空目錄,沒有檔案。 + ## 設定 `lib/settings/`(ADR 0011 §決定 7)。怎麼加一個設定欄位:`.trellis/spec/app/settings/index.md`。 diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts index dbee657b..22b844e1 100644 --- a/app/android/build.gradle.kts +++ b/app/android/build.gradle.kts @@ -19,6 +19,24 @@ subprojects { project.evaluationDependsOn(":app") } +// flutter_js 0.8.7 的 android/build.gradle 把 Kotlin 的 jvmTarget 寫死成 1.8, +// 卻沒設 Java 的 compileOptions,AGP 的預設(11)與它不一致, +// compileDebugKotlin 會失敗(Inconsistent JVM Target Compatibility)。把那個 +// 子專案的 Java 也設成 1.8。它自己的 build.gradle 不設 compileOptions,所以在 +// 套用 library 插件時設定不會被蓋掉。升 flutter_js 時看還需不需要。 +subprojects { + if (name == "flutter_js") { + pluginManager.withPlugin("com.android.library") { + extensions.getByName("android").withGroovyBuilder { + "compileOptions" { + setProperty("sourceCompatibility", JavaVersion.VERSION_1_8) + setProperty("targetCompatibility", JavaVersion.VERSION_1_8) + } + } + } + } +} + tasks.register("clean") { delete(rootProject.layout.buildDirectory) } diff --git a/app/integration_test/plugin_runtime_benchmark_test.dart b/app/integration_test/plugin_runtime_benchmark_test.dart new file mode 100644 index 00000000..441322c6 --- /dev/null +++ b/app/integration_test/plugin_runtime_benchmark_test.dart @@ -0,0 +1,233 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:drift/drift.dart' show DatabaseConnection; +import 'package:drift/native.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/network/source_http_client.dart'; +import 'package:fmp/core/redaction/redactor.dart'; +import 'package:fmp/data/database/app_database.dart'; +import 'package:fmp/data/repositories/plugin_storage_repository.dart'; +import 'package:fmp/plugins/manifest/plugin_file.dart'; +import 'package:fmp/plugins/script_source_plugin.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:integration_test/integration_test.dart'; + +// flutter_js 的實機量測(M1 phase2-plan §7;PRD 9a 第 8 項)。每個插件在自己的 +// 背景 isolate,所以「建立 runtime」含 isolate 的 spawn。只量,不斷言; +// 結果以 `FMP_BENCH` 開頭的行印出。測試插件是 dev flavor 的 asset,所以要帶 +// `--flavor dev`(預設就是 dev): +// +// flutter test integration_test/plugin_runtime_benchmark_test.dart -d emulator-5554 +// flutter test integration_test/plugin_runtime_benchmark_test.dart -d windows +// +// 用 profile 以外的模式量到的是 debug(JIT)數字,Dart 端的部分偏慢;QuickJS +// 本身是原生碼,不受影響。 + +const _testPluginAsset = 'test/fixtures/plugins/test_plugin/test_plugin.js'; + +/// 一個不做事的插件:只量 runtime、宿主 API 與 module 載入的固定成本。 +const _emptyPlugin = ''' +/* ==FMP Plugin== +{"id": "fmp-empty", "name": "Empty", "version": "1", "author": "FMP", + "apiVersion": 1, "capabilities": ["search"], "allowedHosts": []} +==/FMP Plugin== */ +export function search() { return { items: [], hasMore: false }; } +'''; + +/// search 裡連續 20 次宿主非同步呼叫(主 isolate 回覆的 `credentials.get`), +/// 量跨 isolate 的來回。 +const _hostCallPlugin = ''' +/* ==FMP Plugin== +{"id": "fmp-host-calls", "name": "Host calls", "version": "1", "author": "FMP", + "apiVersion": 1, "capabilities": ["search"], "allowedHosts": []} +==/FMP Plugin== */ +export async function search() { + for (let i = 0; i < 20; i++) await fmp.credentials.get(); + return { items: [], hasMore: false }; +} +'''; + +void _report(String name, Object value) => + debugPrint('FMP_BENCH $name: $value'); + +double _ms(Duration d) => d.inMicroseconds / 1000; + +double _median(List values) { + final sorted = [...values]..sort(); + return sorted[sorted.length ~/ 2]; +} + +double _mb(int bytes) => bytes / (1024 * 1024); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('plugin runtime cost', (tester) async { + final redactor = Redactor(); + final log = Log(redactor: redactor, minimumLevel: LogLevel.info); + final database = AppDatabase(DatabaseConnection(NativeDatabase.memory())); + addTearDown(database.close); + final loader = ScriptPluginLoader( + log: log, + redactor: redactor, + httpClients: SourceHttpClientFactory(log: log), + storage: PluginStorageRepository(database), + ); + final testPlugin = PluginFile.parse( + await rootBundle.loadString(_testPluginAsset), + ); + final emptyPlugin = PluginFile.parse(_emptyPlugin); + final opened = []; + addTearDown(() { + for (final plugin in opened) { + plugin.close(); + } + }); + + Future<(ScriptSourcePlugin, double)> timedLoad(PluginFile file) async { + final watch = Stopwatch()..start(); + final plugin = await loader.load(file); + return (plugin, _ms(watch.elapsed)); + } + + _report('platform', Platform.operatingSystemVersion); + _report( + 'mode', + kReleaseMode ? 'release' : (kProfileMode ? 'profile' : 'debug'), + ); + + // 第一次含載入原生庫(dlopen)與 Dart 端的第一次編譯。 + final (coldEmpty, coldEmptyMs) = await timedLoad(emptyPlugin); + coldEmpty.close(); + _report('create runtime (first, empty plugin) ms', coldEmptyMs); + + final emptyTimes = []; + for (var i = 0; i < 9; i++) { + final (plugin, ms) = await timedLoad(emptyPlugin); + plugin.close(); + emptyTimes.add(ms); + } + _report( + 'create runtime (empty plugin, median of 9) ms', + _median(emptyTimes), + ); + + final testTimes = []; + for (var i = 0; i < 9; i++) { + final (plugin, ms) = await timedLoad(testPlugin); + plugin.close(); + testTimes.add(ms); + } + _report('load test plugin (median of 9) ms', _median(testTimes)); + + final (plugin, _) = await timedLoad(testPlugin); + opened.add(plugin); + final query = SearchQuery(keyword: 'bench'); + final firstSearch = Stopwatch()..start(); + await plugin.search(query); + _report('search round trip (first) ms', _ms(firstSearch.elapsed)); + final searchTimes = []; + for (var i = 0; i < 21; i++) { + final watch = Stopwatch()..start(); + await plugin.search(query); + searchTimes.add(_ms(watch.elapsed)); + } + _report('search round trip (median of 21) ms', _median(searchTimes)); + plugin.close(); + opened.remove(plugin); + + // 拆開上面的往返:沒有宿主呼叫的 search,與 search 裡那一次 storage 讀取。 + final (empty, _) = await timedLoad(emptyPlugin); + opened.add(empty); + final emptySearchTimes = []; + for (var i = 0; i < 21; i++) { + final watch = Stopwatch()..start(); + await empty.search(query); + emptySearchTimes.add(_ms(watch.elapsed)); + } + _report( + 'search round trip without host calls (median of 21) ms', + _median(emptySearchTimes), + ); + final (hostCalls, _) = await timedLoad(PluginFile.parse(_hostCallPlugin)); + opened.add(hostCalls); + final hostCallTimes = []; + for (var i = 0; i < 21; i++) { + final watch = Stopwatch()..start(); + await hostCalls.search(query); + hostCallTimes.add(_ms(watch.elapsed)); + } + _report( + 'host call round trip across isolates (median of 21, per call) ms', + ((_median(hostCallTimes) - _median(emptySearchTimes)) / 20) + .toStringAsFixed(3), + ); + hostCalls.close(); + opened.remove(hostCalls); + // 對照:不經 QuickJS 的純 Dart isolate 來回,看上一列有多少是訊息本身。 + final echoes = ReceivePort(); + final replies = StreamIterator(echoes); + await Isolate.spawn(_echo, echoes.sendPort); + await replies.moveNext(); + final echoPort = replies.current! as SendPort; + final echoTimes = []; + for (var i = 0; i < 21; i++) { + final watch = Stopwatch()..start(); + echoPort.send(i); + await replies.moveNext(); + echoTimes.add(_ms(watch.elapsed)); + } + echoPort.send(null); + await replies.cancel(); + _report( + 'plain Dart isolate round trip (median of 21) ms', + _median(echoTimes).toStringAsFixed(3), + ); + final storage = PluginStorageRepository(database); + final readTimes = []; + for (var i = 0; i < 21; i++) { + final watch = Stopwatch()..start(); + await storage.read('fmp-test', 'titlePrefix'); + readTimes.add(_ms(watch.elapsed)); + } + _report('storage read in Dart (median of 21) ms', _median(readTimes)); + empty.close(); + opened.remove(empty); + + // RSS:先讓前面的 runtime 釋放、事件佇列跑完,再量基準。 + await tester.pump(const Duration(milliseconds: 500)); + final baseline = ProcessInfo.currentRss; + _report('rss baseline MB', _mb(baseline).toStringAsFixed(1)); + for (var count = 1; count <= 3; count++) { + final (loaded, _) = await timedLoad(testPlugin); + await loaded.search(query); + opened.add(loaded); + await tester.pump(const Duration(milliseconds: 200)); + if (count == 1 || count == 3) { + _report( + 'rss delta with $count runtime(s) MB', + _mb(ProcessInfo.currentRss - baseline).toStringAsFixed(1), + ); + } + } + }); +} + +/// 對照組的背景 isolate:收到什麼回什麼,收到 `null` 就結束。 +void _echo(SendPort toMain) { + final commands = ReceivePort(); + toMain.send(commands.sendPort); + commands.listen((message) { + if (message == null) { + commands.close(); + } else { + toMain.send(message); + } + }); +} diff --git a/app/lib/app/fmp_app.dart b/app/lib/app/fmp_app.dart index 3250f6c2..efd27438 100644 --- a/app/lib/app/fmp_app.dart +++ b/app/lib/app/fmp_app.dart @@ -2,10 +2,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_ui/material_ui.dart'; import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/core/errors/app_error.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; +import 'package:fmp/plugins/install/dev_plugin_entry.dart'; +import 'package:fmp/plugins/plugin_registry.dart'; +import 'package:fmp/plugins/source_plugin.dart'; -/// App 根元件。目前只顯示 App 名稱、flavor 與資料目錄,供實機確認身分; -/// 正式的外殼在 M1 PR 12。 +/// App 根元件。目前只顯示 App 名稱、flavor、資料目錄與載入的插件,供實機確認 +/// 身分與插件的開發入口;正式的外殼在 M1 PR 12。 class FmpApp extends StatelessWidget { const FmpApp({super.key, required this.flavor}); @@ -38,9 +42,39 @@ class _IdentityPage extends ConsumerWidget { ), Text(flavor.name), SelectableText(ref.watch(dataDirectoryProvider).path), + const _PluginList(), ], ), ), ); } } + +/// 載入的插件(`id version`,沒有回應的加註),以及開發入口安裝失敗時的錯誤類別。 +class _PluginList extends ConsumerWidget { + const _PluginList(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final install = ref.watch(devPluginInstallProvider); + final plugins = ref.watch(pluginRegistryProvider); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (install case AsyncError(error: final AppError error)) + Text('Dev plugin: ${error.typeName}'), + ...switch (plugins) { + AsyncData(:final value) => [ + for (final plugin in value.values) + Text( + '${plugin.manifest.id} ${plugin.manifest.version}' + '${plugin.health == PluginHealth.unresponsive ? ' (unresponsive)' : ''}', + ), + ], + AsyncError(:final error) => [Text('Plugins: $error')], + _ => const [], + }, + ], + ); + } +} diff --git a/app/lib/core/core_providers.dart b/app/lib/core/core_providers.dart new file mode 100644 index 00000000..5b0ba935 --- /dev/null +++ b/app/lib/core/core_providers.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/redaction/redactor.dart'; + +/// App 唯一的 log 門面(ADR 0011 §決定 1)。`main()` 建立後以 override 注入; +/// 沒 override 就讀會拋錯。 +final logProvider = Provider( + (ref) => throw UnimplementedError( + 'logProvider is overridden by main() with the app log', + ), +); + +/// 門面用的那一個 [Redactor](ADR 0011 §決定 3)。插件的遮蔽名單加在它身上, +/// log 才遮得到;所以同樣由 `main()` 注入,不另建實例。 +final redactorProvider = Provider( + (ref) => throw UnimplementedError( + 'redactorProvider is overridden by main() with the log redactor', + ), +); diff --git a/app/lib/data/providers.dart b/app/lib/data/providers.dart index cd63be09..de83cb74 100644 --- a/app/lib/data/providers.dart +++ b/app/lib/data/providers.dart @@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fmp/data/database/app_database.dart'; import 'package:fmp/data/repositories/appearance_settings_repository.dart'; +import 'package:fmp/data/repositories/plugin_repository.dart'; +import 'package:fmp/data/repositories/plugin_storage_repository.dart'; /// App 唯一的資料庫。`main()` 在 `runApp` 之前開啟(`openAppDatabase`), /// 再以 override 注入;其他地方不自己開庫(ADR 0010 §決定 3)。 @@ -15,3 +17,11 @@ final appearanceSettingsRepositoryProvider = Provider( (ref) => AppearanceSettingsRepository(ref.watch(appDatabaseProvider)), ); + +final pluginRepositoryProvider = Provider( + (ref) => PluginRepository(ref.watch(appDatabaseProvider)), +); + +final pluginStorageRepositoryProvider = Provider( + (ref) => PluginStorageRepository(ref.watch(appDatabaseProvider)), +); diff --git a/app/lib/main.dart b/app/lib/main.dart index 9608afa0..9c6926a3 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -10,6 +10,7 @@ import 'package:fmp/app/database_error_app.dart'; import 'package:fmp/app/fmp_app.dart'; import 'package:fmp/app/unsupported_platform_app.dart'; import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/core/core_providers.dart'; import 'package:fmp/core/logging/log.dart'; import 'package:fmp/core/logging/log_file.dart'; import 'package:fmp/core/logging/uncaught_errors.dart'; @@ -19,15 +20,17 @@ import 'package:fmp/data/database/open_app_database.dart'; import 'package:fmp/data/providers.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; import 'package:fmp/platform/platform.dart'; +import 'package:fmp/plugins/install/dev_plugin_entry.dart'; -Future main() async { +Future main(List arguments) async { WidgetsFlutterBinding.ensureInitialized(); final flavor = AppFlavor.parse(appFlavor); final platform = AppPlatform.current(flavor); // 沒有資料目錄的平台不啟動資料層,也沒有 log 檔(能力宣告 dataDirectory 為假)。 final directory = await platform.dataDirectory?.resolve(); + final redactor = Redactor(); final log = Log( - redactor: Redactor(), + redactor: redactor, minimumLevel: buildDefaultLogLevel, file: directory == null ? null @@ -75,6 +78,13 @@ Future main() async { overrides: [ appDatabaseProvider.overrideWithValue(database), dataDirectoryProvider.overrideWithValue(directory), + logProvider.overrideWithValue(log), + redactorProvider.overrideWithValue(redactor), + // 插件的開發入口只在 dev(devPluginPath 在 prod 回 null;理由見 + // dev_plugin_entry.dart)。 + devPluginPathProvider.overrideWithValue( + devPluginPath(flavor, arguments, Platform.environment), + ), ], child: FmpApp(flavor: flavor), ), diff --git a/app/lib/plugins/install/dev_plugin_entry.dart b/app/lib/plugins/install/dev_plugin_entry.dart new file mode 100644 index 00000000..d7268a58 --- /dev/null +++ b/app/lib/plugins/install/dev_plugin_entry.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/core/core_providers.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/plugins/install/plugin_installer.dart'; +import 'package:fmp/plugins/source_plugin.dart'; + +// 開發入口:啟動時從指定的路徑安裝一個插件,給還沒有選檔 UI(PR 12)與插件頁 +// (M3)的這段期間,以及 dev 的實機驗證用(ADR 0027)。 +// +// 只在 dev flavor 生效,prod 不讀參數與環境變數([devPluginPath]):這條路徑跳過 +// ADR 0014 §決定 6 要求的安裝前確認(能力、網域、「以你的登入身分存取」的 +// 警告),而命令列參數、Android 的 intent extra(`dart_entrypoint_args`)與 +// 環境變數都能由別的程式帶入。正式版的安裝只經使用者看過確認的 UI。 + +/// 命令列參數:`--fmp-dev-plugin=<安裝檔路徑>`。 +const devPluginArgumentPrefix = '--fmp-dev-plugin='; + +/// 環境變數(Windows 的 `flutter run` 會傳給 App)。參數優先。 +const devPluginEnvironmentVariable = 'FMP_DEV_PLUGIN'; + +/// 從 [arguments] 與 [environment] 找開發入口要安裝的路徑;沒有、或 [flavor] +/// 不是 dev 就是 `null`。 +String? devPluginPath( + AppFlavor flavor, + List arguments, + Map environment, +) { + if (flavor != AppFlavor.dev) return null; + for (final argument in arguments) { + if (argument.startsWith(devPluginArgumentPrefix)) { + final path = argument.substring(devPluginArgumentPrefix.length); + if (path.isNotEmpty) return path; + } + } + final path = environment[devPluginEnvironmentVariable]; + return path == null || path.isEmpty ? null : path; +} + +/// 開發入口要安裝的路徑。`main()` 以 [devPluginPath] override;預設 `null`。 +final devPluginPathProvider = Provider((ref) => null); + +/// 開發入口:安裝 [devPluginPathProvider] 指的檔案;沒有路徑時是 `null`。失敗 +/// 經 `log.report` 記下並以 `AppError` 結束。 +final devPluginInstallProvider = FutureProvider((ref) async { + final path = ref.watch(devPluginPathProvider); + if (path == null) return null; + final log = ref.watch(logProvider); + try { + final plugin = await ref + .watch(pluginInstallerProvider) + .installBytes(await File(path).readAsBytes()); + log.info( + 'Installed a plugin from the development entry', + tag: 'plugins', + fields: {'pluginId': plugin.manifest.id}, + ); + return plugin; + } on Object catch (error, stackTrace) { + final appError = AppError.wrap(error, stackTrace); + log.report( + 'Failed to install the development plugin', + appError, + tag: 'plugins', + ); + throw appError; + } +}); diff --git a/app/lib/plugins/install/plugin_installer.dart b/app/lib/plugins/install/plugin_installer.dart new file mode 100644 index 00000000..84dff225 --- /dev/null +++ b/app/lib/plugins/install/plugin_installer.dart @@ -0,0 +1,73 @@ +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/data/providers.dart'; +import 'package:fmp/data/repositories/plugin_repository.dart'; +import 'package:fmp/plugins/manifest/plugin_file.dart'; +import 'package:fmp/plugins/plugin_registry.dart'; +import 'package:fmp/plugins/script_source_plugin.dart'; +import 'package:fmp/plugins/source_plugin.dart'; + +final pluginInstallerProvider = Provider( + (ref) => PluginInstaller( + loader: ref.watch(scriptPluginLoaderProvider), + repository: ref.watch(pluginRepositoryProvider), + register: (plugin) => + ref.read(pluginRegistryProvider.notifier).register(plugin), + ), +); + +/// 從安裝檔安裝插件(ADR 0014 §決定 6)。選檔的 UI 在 M1 PR 12;安裝前的 +/// 確認(能力、網域、警告)在 M3 的插件頁。 +/// +/// 依序:解析 manifest(不執行腳本)→ 在 runtime 載入並檢查匯出與能力一致 → +/// 寫進 `installed_plugins` → 加入插件清單。任一步失敗都不留下半套:已載入的 +/// runtime 關閉、資料庫沒有寫入。 +/// +/// 已安裝同 id 的插件視為更新:資料列覆蓋、storage 保留(`PluginRepository` +/// 以 upsert 寫入),清單裡的舊 runtime 關閉。 +final class PluginInstaller { + PluginInstaller({ + required this._loader, + required this._repository, + required this._register, + this._now = DateTime.now, + }); + + final ScriptPluginLoader _loader; + final PluginRepository _repository; + final Future Function(SourcePlugin plugin) _register; + final DateTime Function() _now; + + /// 安裝檔的位元組(UTF-8)。錯誤都是 `AppError`,見 [PluginFile.decode]、 + /// [ScriptPluginLoader.load]。 + Future installBytes(Uint8List bytes) => + _install(() => PluginFile.decode(bytes)); + + /// 安裝檔的文字。 + Future installSource(String source) => + _install(() => PluginFile.parse(source)); + + Future _install(PluginFile Function() parse) async { + final file = parse(); + final plugin = await _loader.load(file); + try { + await _repository.install( + InstalledPlugin( + id: file.manifest.id, + version: file.manifest.version, + manifestJson: file.manifestJson, + script: file.source, + installedAt: _now().toUtc(), + ), + ); + } on Object catch (error, stackTrace) { + plugin.close(); + throw AppError.wrap(error, stackTrace, pluginId: file.manifest.id); + } + await _register(plugin); + return plugin; + } +} diff --git a/app/lib/plugins/json_shape.dart b/app/lib/plugins/json_shape.dart new file mode 100644 index 00000000..6976d7b2 --- /dev/null +++ b/app/lib/plugins/json_shape.dart @@ -0,0 +1,115 @@ +/// 插件交換的 JSON 物件的欄位表:欄位名稱 → 是否必填。 +/// +/// manifest 與 DTO 都以它解碼:表外的鍵、必填卻缺少或為 `null` 的欄位都拋 +/// [FormatException]。嚴格是刻意的:插件作者(常是 AI)拼錯欄位名時立刻失敗, +/// 不會默默少一個值。`lib/plugins/types/fmp-plugin.d.ts` 裡同名的 interface +/// 必須與表一致(`test/plugins/type_definitions_test.dart` 比對)。 +typedef JsonShape = Map; + +/// 依 [JsonShape] 讀一個 JSON 物件。錯誤訊息帶 [path](例如 +/// `items[2].artwork`),只進 log。 +final class JsonFields { + JsonFields(Object? json, JsonShape shape, {required this.path}) + : _json = _object(json, path) { + for (final key in _json.keys) { + if (!shape.containsKey(key)) { + throw FormatException('$path: unknown field "$key"'); + } + } + for (final MapEntry(:key, value: required) in shape.entries) { + if (required && _json[key] == null) { + throw FormatException('$path: missing required field "$key"'); + } + } + } + + final Map _json; + final String path; + + static Map _object(Object? json, String path) { + if (json is Map) return json; + throw FormatException('$path: expected an object'); + } + + /// 欄位有值(不是缺少,也不是 `null`)。 + bool has(String key) => _json[key] != null; + + /// 原始值,給形狀由呼叫端自己檢查的欄位(例如 manifest 的 `login`)。 + Object? raw(String key) => _json[key]; + + String string(String key) => _require(key, optionalString(key)); + + String? optionalString(String key) => switch (_json[key]) { + null => null, + final String value => value, + _ => throw FormatException('$path.$key: expected a string'), + }; + + /// 去掉前後空白後不得為空。 + String nonEmptyString(String key) { + final value = string(key); + if (value.trim().isEmpty) { + throw FormatException('$path.$key: must not be empty'); + } + return value; + } + + int integer(String key, {int min = 0}) => + _require(key, optionalInteger(key, min: min)); + + /// JSON 的整數。`2.5` 這類小數不算;[min] 以下拋錯。 + int? optionalInteger(String key, {int min = 0}) { + final value = switch (_json[key]) { + null => null, + final int value => value, + _ => throw FormatException('$path.$key: expected an integer'), + }; + if (value != null && value < min) { + throw FormatException('$path.$key: must be at least $min'); + } + return value; + } + + bool? optionalBool(String key) => switch (_json[key]) { + null => null, + final bool value => value, + _ => throw FormatException('$path.$key: expected a boolean'), + }; + + List list(String key) => _require(key, optionalList(key)); + + List? optionalList(String key) => switch (_json[key]) { + null => null, + final List value => value, + _ => throw FormatException('$path.$key: expected an array'), + }; + + List stringList(String key) => [ + for (final (index, item) in list(key).indexed) + if (item is String) + item + else + throw FormatException('$path.$key[$index]: expected a string'), + ]; + + List? optionalStringList(String key) => + has(key) ? stringList(key) : null; + + Map? optionalObject(String key) => + has(key) ? _object(_json[key], '$path.$key') : null; + + /// 值全是字串的物件(例如 headers)。 + Map? optionalStringMap(String key) { + final object = optionalObject(key); + if (object == null) return null; + return { + for (final MapEntry(key: name, :value) in object.entries) + name: value is String + ? value + : throw FormatException('$path.$key.$name: expected a string'), + }; + } + + T _require(String key, T? value) => + value ?? (throw FormatException('$path: missing required field "$key"')); +} diff --git a/app/lib/plugins/manifest/plugin_file.dart b/app/lib/plugins/manifest/plugin_file.dart new file mode 100644 index 00000000..c57b4314 --- /dev/null +++ b/app/lib/plugins/manifest/plugin_file.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/plugins/manifest/plugin_manifest.dart'; + +/// 安裝檔標頭的開頭與結尾(ADR 0014 2026-09-30 補充;使用者腳本 metadata +/// block 的慣例)。 +const pluginHeaderStart = '/* ==FMP Plugin=='; +const pluginHeaderEnd = '==/FMP Plugin== */'; + +/// 一個安裝檔:單一 `.js` 檔,開頭以 [pluginHeaderStart]、[pluginHeaderEnd] +/// 包一段 JSON manifest,之後是 ES module 腳本。 +/// +/// 讀 manifest 不執行腳本:安裝前先檢查網域與能力(ADR 0014 §決定 6)。 +final class PluginFile { + const PluginFile._({ + required this.source, + required this.manifestJson, + required this.manifest, + }); + + /// 解析安裝檔的位元組(UTF-8,可帶 BOM)。不是合法 UTF-8 是 [ParseError]; + /// 其他錯誤見 [PluginFile.parse]。 + factory PluginFile.decode(Uint8List bytes) { + final String text; + try { + text = const Utf8Decoder().convert(bytes); + } on FormatException catch (error, stackTrace) { + throw ParseError(cause: error, stackTrace: stackTrace); + } + return PluginFile.parse(text); + } + + /// 解析安裝檔的文字。 + /// + /// 標頭必須是檔案的第一段內容(前面只准有 BOM 與空白),JSON 裡不能出現 + /// `*/`(會提早結束註解)。標頭有問題是 [ParseError];manifest 本身的錯誤 + /// 見 [PluginManifest.parse]。 + factory PluginFile.parse(String source) { + final text = source.startsWith('') ? source.substring(1) : source; + final start = text.length - text.trimLeft().length; + if (!text.startsWith(pluginHeaderStart, start)) { + throw _headerError('the file must start with "$pluginHeaderStart"'); + } + final jsonStart = start + pluginHeaderStart.length; + final end = text.indexOf(pluginHeaderEnd, jsonStart); + if (end < 0) { + throw _headerError('missing "$pluginHeaderEnd"'); + } + final json = text.substring(jsonStart, end); + if (json.contains('*/')) { + throw _headerError('the manifest must not contain "*/"'); + } + return PluginFile._( + source: text, + manifestJson: json.trim(), + manifest: PluginManifest.parse(json), + ); + } + + /// 整個檔案(去掉 BOM);安裝時原樣存進 `installed_plugins.script`,index 的 + /// SHA-256 針對的也是這個檔案。 + final String source; + + /// 標頭內的 JSON 原文。 + final String manifestJson; + final PluginManifest manifest; + + static ParseError _headerError(String message) => ParseError( + cause: FormatException('Plugin header: $message'), + stackTrace: StackTrace.current, + ); +} diff --git a/app/lib/plugins/manifest/plugin_manifest.dart b/app/lib/plugins/manifest/plugin_manifest.dart new file mode 100644 index 00000000..d1406dfb --- /dev/null +++ b/app/lib/plugins/manifest/plugin_manifest.dart @@ -0,0 +1,391 @@ +import 'dart:convert'; + +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/errors/retry_policy.dart'; +import 'package:fmp/core/network/allowed_hosts.dart'; +import 'package:fmp/core/redaction/redaction_lists.dart'; +import 'package:fmp/plugins/json_shape.dart'; + +/// 宿主支援的插件 API 版本(ADR 0014 §決定 5)。manifest 的 `apiVersion` 必須 +/// 等於它;宿主 API 或 DTO 有不相容的改動時加一。 +const hostApiVersion = 1; + +/// 插件能力(ADR 0014 §決定 4)。 +/// +/// 每個能力對應腳本裡一個同名的匯出函式([wireName]):manifest 宣告的能力都要 +/// 匯出,匯出了能力名稱的函式也都要宣告,否則拒絕載入。M1 只有 [search] 與 +/// [resolveStream] 有宿主端的方法(`SourcePlugin`),其他能力認得、檢查匯出, +/// 但還沒有人呼叫。 +enum PluginCapability { + search, + resolveStream, + trackDetail, + multiPart, + importPlaylist, + libraryRead, + libraryWrite, + charts, + live, + mix, + lyrics, + login; + + /// manifest 與匯出函式用的名稱。這是插件的介面,與 [name] 分開寫死,改 + /// enum 的名稱不會改到它。 + String get wireName => switch (this) { + search => 'search', + resolveStream => 'resolveStream', + trackDetail => 'trackDetail', + multiPart => 'multiPart', + importPlaylist => 'importPlaylist', + libraryRead => 'libraryRead', + libraryWrite => 'libraryWrite', + charts => 'charts', + live => 'live', + mix => 'mix', + lyrics => 'lyrics', + login => 'login', + }; + + /// 不認得的名稱回 `null`。 + static PluginCapability? fromWireName(String name) { + for (final capability in values) { + if (capability.wireName == name) return capability; + } + return null; + } +} + +/// manifest 追加的遮蔽名單(交給 `Redactor.addRules`,ADR 0011 §決定 3)。 +final class PluginRedaction { + const PluginRedaction({ + this.headerNames = const [], + this.keyNames = const [], + this.mediaCdns = const [], + }); + + final List headerNames; + final List keyNames; + final List mediaCdns; +} + +/// 插件的 manifest(ADR 0014 §決定 3)。由 [PluginManifest.parse] 從安裝檔的 +/// 標頭讀出;欄位與格式見 `lib/plugins/types/fmp-plugin.d.ts` 的 +/// `FmpPluginManifest`。 +final class PluginManifest { + const PluginManifest({ + required this.id, + required this.name, + required this.version, + required this.author, + required this.capabilities, + required this.allowedHosts, + this.retryPolicy, + this.rateLimitPolicy, + this.redaction = const PluginRedaction(), + this.defaults = const {}, + this.icon, + }); + + /// 解析 manifest 的 JSON 文字。 + /// + /// - 不是 JSON、欄位缺少或型別不對、格式不合:[ParseError]; + /// - `apiVersion` 不是 [hostApiVersion]、有不認得的能力、`login` 不是空值(M1 + /// 還沒有登入):[Unsupported]。 + /// + /// 原因只在 `cause`(進 log);使用者訊息用子類的預設 key。 + static PluginManifest parse(String json) { + final Object? decoded; + try { + decoded = jsonDecode(json); + } on FormatException catch (error, stackTrace) { + throw ParseError(cause: error, stackTrace: stackTrace); + } + // 先看版本:別的版本的欄位本來就可能不同,不拿 v1 的形狀去判它。 + if (decoded case {'apiVersion': final int version} + when version != hostApiVersion) { + throw Unsupported( + pluginId: _idOrNull(decoded), + cause: FormatException( + 'apiVersion $version is not supported (host supports ' + '$hostApiVersion)', + ), + stackTrace: StackTrace.current, + ); + } + try { + return _fromJson(decoded); + } on _UnsupportedManifest catch (error, stackTrace) { + throw Unsupported( + pluginId: _idOrNull(decoded), + cause: FormatException(error.message), + stackTrace: stackTrace, + ); + } on FormatException catch (error, stackTrace) { + throw ParseError( + pluginId: _idOrNull(decoded), + cause: error, + stackTrace: stackTrace, + ); + } + } + + /// 音源 id:小寫英數與 `-`,見 [isValidId]。 + final String id; + final String name; + final String version; + final String author; + final Set capabilities; + + /// 可以連的網域(ADR 0014 §決定 5):只有 host,小寫。網路請求、串流與封面 + /// 網址、圖示網址都只能在這些網域或它們的子網域。 + final List allowedHosts; + + /// 沒宣告就是 `null`(用 `const RetryPolicy()`)。 + final RetryPolicy? retryPolicy; + + /// 沒宣告就是 `null`(不限流)。 + final RateLimitPolicy? rateLimitPolicy; + final PluginRedaction redaction; + + /// 插件設定的預設值;M1 只保存,還沒有設定頁讀它。 + final Map defaults; + + /// `https` 網址(網域在 [allowedHosts] 內)或 `data:image/…;base64,` 網址。 + final Uri? icon; + + /// id 的格式:1–[maxIdLength] 個小寫英數與 `-`,不以 `-` 開頭或結尾。它是 + /// 曲目鍵的第一段(`TrackKey`),所以不能有 `:`。 + static bool isValidId(String id) => _idPattern.hasMatch(id); + + static const maxIdLength = 32; +} + +final _idPattern = RegExp(r'^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$'); + +/// 一段 DNS label;host 至少兩段,最後一段不是純數字(排除 IPv4)。 +final _hostLabel = RegExp(r'^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$'); +final _digits = RegExp(r'^[0-9]+$'); + +/// 圖示的 data 網址:只接受這幾種圖片、base64 編碼。 +final _dataIcon = RegExp( + r'^data:image/(?:png|jpeg|webp|gif|svg\+xml);base64,([A-Za-z0-9+/=]+)$', +); + +/// data 圖示解碼後的上限。 +const _maxIconBytes = 64 * 1024; + +/// 重試次數的上限:插件不能把一個失敗放大成大量請求。 +const _maxRetries = 5; + +const manifestShape = { + 'id': true, + 'name': true, + 'version': true, + 'author': true, + 'apiVersion': true, + 'capabilities': true, + 'allowedHosts': true, + 'login': false, + 'retry': false, + 'rateLimit': false, + 'redaction': false, + 'defaults': false, + 'icon': false, +}; +const retryShape = { + 'maxRetries': false, + 'baseDelayMs': false, + 'maxDelayMs': false, + 'maxRetryAfterMs': false, +}; +const rateLimitShape = { + 'maxConcurrentRequests': true, + 'minRequestIntervalMs': true, +}; +const redactionShape = { + 'headerNames': false, + 'keyNames': false, + 'mediaCdns': false, +}; +const mediaCdnShape = { + 'host': true, + 'signedQueryParameters': false, + 'signedPath': false, +}; + +/// manifest 的欄位表,鍵是 `fmp-plugin.d.ts` 裡的 interface 名稱。 +const manifestShapes = { + 'FmpPluginManifest': manifestShape, + 'FmpRetryPolicy': retryShape, + 'FmpRateLimitPolicy': rateLimitShape, + 'FmpRedaction': redactionShape, + 'FmpMediaCdn': mediaCdnShape, +}; + +/// 格式合法但這個版本不支援(轉成 [Unsupported])。 +final class _UnsupportedManifest implements Exception { + const _UnsupportedManifest(this.message); + + final String message; +} + +String? _idOrNull(Object? json) => switch (json) { + {'id': final String id} when PluginManifest.isValidId(id) => id, + _ => null, +}; + +PluginManifest _fromJson(Object? json) { + final fields = JsonFields(json, manifestShape, path: 'manifest'); + final id = fields.string('id'); + if (!PluginManifest.isValidId(id)) { + throw FormatException( + 'manifest.id: "$id" must be 1-${PluginManifest.maxIdLength} lowercase ' + 'letters, digits or "-", not starting or ending with "-"', + ); + } + // 版本在 parse 開頭已經比對過;這裡只確認它是整數。 + fields.integer('apiVersion'); + if (fields.raw('login') != null) { + throw const _UnsupportedManifest('manifest.login: login is not supported'); + } + final allowedHosts = [ + for (final (index, host) in fields.stringList('allowedHosts').indexed) + _host(host, 'manifest.allowedHosts[$index]'), + ]; + return PluginManifest( + id: id, + name: fields.nonEmptyString('name'), + version: fields.nonEmptyString('version'), + author: fields.nonEmptyString('author'), + capabilities: _capabilities(fields.stringList('capabilities')), + allowedHosts: List.unmodifiable(allowedHosts), + retryPolicy: _retry(fields.optionalObject('retry')), + rateLimitPolicy: _rateLimit(fields.optionalObject('rateLimit')), + redaction: _redaction(fields.optionalObject('redaction')), + defaults: Map.unmodifiable(fields.optionalObject('defaults') ?? const {}), + icon: _icon(fields.optionalString('icon'), allowedHosts), + ); +} + +Set _capabilities(List names) { + if (names.isEmpty) { + throw const FormatException('manifest.capabilities: must not be empty'); + } + final capabilities = {}; + for (final name in names) { + final capability = PluginCapability.fromWireName(name); + if (capability == null) { + throw _UnsupportedManifest('manifest.capabilities: unknown "$name"'); + } + if (!capabilities.add(capability)) { + throw FormatException('manifest.capabilities: "$name" is listed twice'); + } + } + return Set.unmodifiable(capabilities); +} + +/// 只接受小寫的 host:沒有 scheme、port、路徑、萬用字元。 +String _host(String host, String path) { + final labels = host.split('.'); + final valid = + host.length <= 253 && + labels.length >= 2 && + labels.every(_hostLabel.hasMatch) && + !_digits.hasMatch(labels.last); + if (!valid) { + throw FormatException( + '$path: "$host" is not a lowercase host name (no scheme, port, path or ' + 'wildcard)', + ); + } + return host; +} + +RetryPolicy? _retry(Map? json) { + if (json == null) return null; + final fields = JsonFields(json, retryShape, path: 'manifest.retry'); + const defaults = RetryPolicy(); + final maxRetries = fields.optionalInteger('maxRetries'); + if (maxRetries != null && maxRetries > _maxRetries) { + throw const FormatException( + 'manifest.retry.maxRetries: must be at most $_maxRetries', + ); + } + Duration? millis(String key) => switch (fields.optionalInteger(key)) { + null => null, + final value => Duration(milliseconds: value), + }; + return RetryPolicy( + maxRetries: maxRetries ?? defaults.maxRetries, + baseDelay: millis('baseDelayMs') ?? defaults.baseDelay, + maxDelay: millis('maxDelayMs') ?? defaults.maxDelay, + maxRetryAfter: millis('maxRetryAfterMs') ?? defaults.maxRetryAfter, + ); +} + +RateLimitPolicy? _rateLimit(Map? json) { + if (json == null) return null; + final fields = JsonFields(json, rateLimitShape, path: 'manifest.rateLimit'); + return RateLimitPolicy( + maxConcurrentRequests: fields.integer('maxConcurrentRequests', min: 1), + minRequestInterval: Duration( + milliseconds: fields.integer('minRequestIntervalMs'), + ), + ); +} + +PluginRedaction _redaction(Map? json) { + if (json == null) return const PluginRedaction(); + final fields = JsonFields(json, redactionShape, path: 'manifest.redaction'); + return PluginRedaction( + headerNames: List.unmodifiable( + fields.optionalStringList('headerNames') ?? const [], + ), + keyNames: List.unmodifiable( + fields.optionalStringList('keyNames') ?? const [], + ), + mediaCdns: List.unmodifiable([ + for (final (index, item) + in (fields.optionalList('mediaCdns') ?? []).indexed) + _mediaCdn(item, 'manifest.redaction.mediaCdns[$index]'), + ]), + ); +} + +MediaCdn _mediaCdn(Object? json, String path) { + final fields = JsonFields(json, mediaCdnShape, path: path); + return MediaCdn( + host: _host(fields.string('host'), '$path.host'), + signedQueryParameters: { + ...?fields.optionalStringList('signedQueryParameters'), + }, + signedPath: fields.optionalBool('signedPath') ?? false, + ); +} + +Uri? _icon(String? value, List allowedHosts) { + if (value == null) return null; + if (_dataIcon.firstMatch(value) case final match?) { + final List bytes; + try { + bytes = base64.decode(match[1]!); + } on FormatException { + throw const FormatException('manifest.icon: invalid base64'); + } + if (bytes.length > _maxIconBytes) { + throw const FormatException( + 'manifest.icon: data icon is larger than $_maxIconBytes bytes', + ); + } + return Uri.parse(value); + } + final uri = Uri.tryParse(value); + if (uri == null || !AllowedHosts(allowedHosts).allows(uri)) { + throw const FormatException( + 'manifest.icon: must be an https URL on an allowed host, or a ' + 'data:image/...;base64, URL', + ); + } + return uri; +} diff --git a/app/lib/plugins/plugin_registry.dart b/app/lib/plugins/plugin_registry.dart new file mode 100644 index 00000000..66bb0362 --- /dev/null +++ b/app/lib/plugins/plugin_registry.dart @@ -0,0 +1,92 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fmp/core/core_providers.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/network/source_http_client.dart'; +import 'package:fmp/data/providers.dart'; +import 'package:fmp/plugins/manifest/plugin_file.dart'; +import 'package:fmp/plugins/script_source_plugin.dart'; +import 'package:fmp/plugins/source_plugin.dart'; + +/// App 共用的 HTTP client 工廠:每個插件由它建一個 client(ADR 0012 §決定 1)。 +final sourceHttpClientFactoryProvider = Provider( + (ref) => SourceHttpClientFactory(log: ref.watch(logProvider)), +); + +final scriptPluginLoaderProvider = Provider( + (ref) => ScriptPluginLoader( + log: ref.watch(logProvider), + redactor: ref.watch(redactorProvider), + httpClients: ref.watch(sourceHttpClientFactoryProvider), + storage: ref.watch(pluginStorageRepositoryProvider), + ), +); + +/// 載入好的插件,鍵是插件 id。App 其他部分從這裡拿 [SourcePlugin]。 +final pluginRegistryProvider = + AsyncNotifierProvider>( + PluginRegistry.new, + ); + +/// 插件清單:啟動時載入 `installed_plugins` 裡的每個插件,安裝後由 +/// `PluginInstaller` 加入。 +/// +/// 載入失敗的插件經 `log.report` 記下後略過,不擋其他插件(曲目標示「音源 +/// 未安裝」等呈現在 M3 的插件頁)。沒有回應的插件留在清單上,狀態看 +/// `SourcePlugin.health`。provider 釋放時關閉所有插件。 +final class PluginRegistry extends AsyncNotifier> { + final _open = {}; + + @override + Future> build() async { + final loader = ref.watch(scriptPluginLoaderProvider); + final repository = ref.watch(pluginRepositoryProvider); + final log = ref.watch(logProvider); + ref.onDispose(() { + for (final plugin in _open) { + plugin.close(); + } + _open.clear(); + }); + final plugins = {}; + for (final installed in await repository.list()) { + try { + final plugin = await loader.load(PluginFile.parse(installed.script)); + _open.add(plugin); + _watch(plugin); + plugins[plugin.manifest.id] = plugin; + } on AppError catch (error) { + log.report('Failed to load an installed plugin', error, tag: 'plugins'); + } + } + return Map.unmodifiable(plugins); + } + + /// [plugin] 變成沒有回應時發出新的清單(內容相同),畫面才會讀到它的 + /// `health`。插件留在清單裡、停用到 App 重啟(prd 擁有者決定 7)。 + void _watch(SourcePlugin plugin) { + plugin.whenUnresponsive.then((_) { + if (!ref.mounted) return; + final current = state.value; + if (current != null && identical(current[plugin.manifest.id], plugin)) { + state = AsyncData(Map.unmodifiable({...current})); + } + }); + } + + /// 加入 [plugin];已有同 id 的就取代並關閉舊的(更新)。 + Future register(SourcePlugin plugin) async { + final built = await future; + // 等的期間別的 register 可能已經改了清單:以最新的為準,才不會蓋掉它。 + final current = state.value ?? built; + final id = plugin.manifest.id; + final previous = current[id]; + _open.add(plugin); + _watch(plugin); + state = AsyncData(Map.unmodifiable({...current, id: plugin})); + if (previous != null && !identical(previous, plugin)) { + _open.remove(previous); + previous.close(); + } + } +} diff --git a/app/lib/plugins/runtime/js_prelude.dart b/app/lib/plugins/runtime/js_prelude.dart new file mode 100644 index 00000000..c2adc906 --- /dev/null +++ b/app/lib/plugins/runtime/js_prelude.dart @@ -0,0 +1,149 @@ +/// 每個插件 runtime 在載入腳本之前先執行的 JS(宿主 API v1,ADR 0014 §決定 5)。 +/// +/// 它是一個函式運算式;插件的背景 isolate(`plugin_worker.dart`)以兩個 Dart +/// 函式與 API 版本呼叫它: +/// +/// - `hostAsync(op, argsJson)` 回 Promise<回覆 JSON>:網路、storage、憑證, +/// 以訊息交給主 isolate 執行; +/// - `hostSync(op, argsJson)` 直接回覆 JSON:crypto 在背景 isolate 算,log +/// 檢查形狀後送給主 isolate(不等回覆)。 +/// +/// 回覆是 `{ok: true, value}` 或 `{ok: false, error}`。兩個函式只留在閉包裡, +/// 全域上看不到;腳本能碰到的只有唯讀的 `fmp` 與轉到 `fmp.log` 的 `console`。 +/// JSON、Object.keys 在腳本執行前先取好,腳本改寫全域不影響宿主的溝通。 +/// +/// 回傳 `{load, call}`: +/// +/// - `load(name)` 以動態 `import()` 載入插件 module(名稱只對得到插件腳本), +/// 回覆匯出的函式名稱; +/// - `call(name, argJson)` 呼叫匯出的函式並等它完成。插件拋出的值轉成 +/// `{kind: 'structured' | 'thrown' | 'unserializable', ...}`,永遠 resolve, +/// 錯誤在 Dart 端轉成 `AppError`(`script_errors.dart`)。 +/// +/// 改了這裡的 `fmp` 形狀,同步改 `lib/plugins/types/fmp-plugin.d.ts` +/// (`type_definitions_test.dart` 比對兩邊的函式清單)。 +const jsPrelude = r''' +(function (hostAsync, hostSync, apiVersion) { + 'use strict'; + const parse = JSON.parse; + const stringify = JSON.stringify; + const freeze = Object.freeze; + const keys = Object.keys; + const defineProperty = Object.defineProperty; + + const toError = (e) => { + if (e.kind === 'argument') return new TypeError(e.message); + const error = new Error(e.fmpError); + error.fmpError = e.fmpError; + error.hostErrorId = e.hostErrorId; + if (e.retryAfterSeconds !== null) error.retryAfterSeconds = e.retryAfterSeconds; + if (e.reason !== null) error.reason = e.reason; + return error; + }; + const unwrap = (reply) => { + const result = parse(reply); + if (result.ok) return result.value; + throw toError(result.error); + }; + // stringify 對函式、Symbol 回 undefined:一律送 null,宿主當成參數不對。 + const args = (value) => { + const json = stringify(value); + return json === undefined ? 'null' : json; + }; + const callAsync = async (op, value) => unwrap(await hostAsync(op, args(value))); + const callSync = (op, value) => unwrap(hostSync(op, args(value))); + + const text = (value) => { + if (typeof value === 'string') return value; + try { + const json = stringify(value); + return json === undefined ? String(value) : json; + } catch (_) { + return String(value); + } + }; + const logAt = (level) => (message, fields) => + callSync('log', { level, message: text(message), fields: fields === undefined ? null : fields }); + const consoleAt = (level) => (...values) => + callSync('log', { level, message: values.map(text).join(' '), fields: null }); + + const fmp = freeze({ + apiVersion, + http: freeze({ + request: (request) => callAsync('http.request', request), + }), + crypto: freeze({ + md5: (text) => callSync('crypto.md5', { text }), + sha256: (text) => callSync('crypto.sha256', { text }), + }), + storage: freeze({ + get: (key) => callAsync('storage.get', { key }), + set: (key, value) => callAsync('storage.set', { key, value }), + delete: (key) => callAsync('storage.delete', { key }), + }), + credentials: freeze({ + get: () => callAsync('credentials.get', {}), + }), + log: freeze({ + debug: logAt('debug'), + info: logAt('info'), + warn: logAt('warn'), + error: logAt('error'), + }), + }); + defineProperty(globalThis, 'fmp', { value: fmp }); + defineProperty(globalThis, 'console', { + value: freeze({ + debug: consoleAt('debug'), + log: consoleAt('debug'), + info: consoleAt('info'), + warn: consoleAt('warn'), + error: consoleAt('error'), + }), + }); + + const describe = (e) => { + try { + if (e !== null && typeof e === 'object' && typeof e.fmpError === 'string') { + return { + kind: 'structured', + fmpError: e.fmpError, + hostErrorId: typeof e.hostErrorId === 'number' ? e.hostErrorId : null, + retryAfterSeconds: typeof e.retryAfterSeconds === 'number' ? e.retryAfterSeconds : null, + reason: typeof e.reason === 'string' ? e.reason : null, + message: typeof e.message === 'string' ? e.message : null, + }; + } + if (e instanceof Error) { + return { kind: 'thrown', name: String(e.name), message: String(e.message), stack: String(e.stack || '') }; + } + return { kind: 'thrown', name: typeof e, message: String(e), stack: '' }; + } catch (_) { + return { kind: 'thrown', name: 'unknown', message: 'the thrown value cannot be described', stack: '' }; + } + }; + const reply = (value) => { + try { + return stringify({ ok: true, value: value === undefined ? null : value }); + } catch (e) { + return stringify({ ok: false, error: { kind: 'unserializable', message: String(e && e.message) } }); + } + }; + const fail = (e) => stringify({ ok: false, error: describe(e) }); + + let plugin = null; + return { + load: (name) => import(name).then((module) => { + plugin = module; + return reply(keys(module).filter((key) => typeof module[key] === 'function')); + }, fail), + call: async (name, argument) => { + try { + return reply(await plugin[name](parse(argument))); + } catch (e) { + return fail(e); + } + }, + }; +}) +'''; diff --git a/app/lib/plugins/runtime/plugin_host.dart b/app/lib/plugins/runtime/plugin_host.dart new file mode 100644 index 00000000..78155e0a --- /dev/null +++ b/app/lib/plugins/runtime/plugin_host.dart @@ -0,0 +1,130 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/network/auth.dart'; +import 'package:fmp/core/network/source_http_client.dart'; +import 'package:fmp/data/repositories/plugin_storage_repository.dart'; +import 'package:fmp/plugins/json_shape.dart'; + +/// 宿主 API 的 JSON 形狀,鍵是 `fmp-plugin.d.ts` 裡的 interface 名稱。 +const hostApiShapes = { + 'HttpRequest': { + 'url': true, + 'method': false, + 'headers': false, + 'body': false, + 'auth': false, + }, + 'HttpResponse': {'status': true, 'url': true, 'headers': true, 'body': true}, +}; + +/// 一個插件的宿主 API v1(ADR 0014 §決定 5)在主 isolate 的那一半:網路、 +/// storage、憑證、log。插件腳本能碰到的外界只有這些,加上背景 isolate 自己算 +/// 的 `crypto`(`plugin_worker.dart`)。 +/// +/// 每個實例綁定一個插件:`pluginId` 在建構時固定,腳本沒有辦法指定別的插件, +/// 所以讀不到其他插件的 storage 與憑證。參數與回傳值是已解碼的 JSON;參數 +/// 不對拋 [ArgumentError] 或 [FormatException](腳本收到 `TypeError`),其他 +/// 失敗拋 `AppError`。 +final class PluginHost { + PluginHost({ + required this.pluginId, + required this._http, + required this._storage, + required this._log, + }); + + final String pluginId; + final SourceHttpClient _http; + final PluginStorageRepository _storage; + final Log _log; + + /// [close] 時完成:取消還在進行的請求。 + final _closed = Completer(); + + /// 非同步的宿主函式(網路、storage、憑證)。 + Future callAsync(String op, Object? args) => switch (op) { + 'http.request' => _request(args), + 'storage.get' => _storage.read(pluginId, _key(args)), + 'storage.set' => _storageSet(args), + 'storage.delete' => _storage.delete(pluginId, _key(args)), + // M1 沒有登入,一律「沒有憑證」;CredentialStore 在 M3(ADR 0012 §決定 3)。 + 'credentials.get' => Future.value(), + _ => throw ArgumentError.value(op, 'op', 'unknown host function'), + }; + + /// 腳本的 log(背景 isolate 已檢查過形狀):經門面寫入,tag 固定是插件 + /// id,腳本改不了。 + void writeLog(String level, String message, Map fields) { + _log.write( + switch (level) { + 'debug' => LogLevel.debug, + 'info' => LogLevel.info, + 'warn' => LogLevel.warning, + 'error' => LogLevel.error, + _ => throw ArgumentError.value(level, 'level'), + }, + message, + tag: pluginId, + fields: fields, + ); + } + + /// 取消進行中的請求並關閉連線。之後的呼叫結果不再交給腳本。 + void close() { + if (_closed.isCompleted) return; + _closed.complete(); + _http.close(); + } + + Future> _request(Object? args) async { + final fields = JsonFields( + args, + hostApiShapes['HttpRequest']!, + path: 'fmp.http.request', + ); + final response = await _http.send( + SourceRequest( + Uri.parse(fields.string('url')), + method: fields.optionalString('method') ?? 'GET', + headers: fields.optionalStringMap('headers') ?? const {}, + body: fields.optionalString('body'), + auth: _auth(fields.optionalString('auth')), + ), + abortTrigger: _closed.future, + ); + return { + 'status': response.statusCode, + 'url': response.url.toString(), + 'headers': response.headers, + 'body': utf8.decode(response.body, allowMalformed: true), + }; + } + + Future _storageSet(Object? args) { + final fields = JsonFields(args, const { + 'key': true, + 'value': true, + }, path: 'fmp.storage.set'); + return _storage.write(pluginId, _key(args), fields.string('value')); + } + + static String _key(Object? args) { + final key = switch (args) { + {'key': final String key} => key, + _ => throw ArgumentError('storage key must be a string'), + }; + if (key.isEmpty) throw ArgumentError('storage key must not be empty'); + return key; + } + + /// 預設 `never`(ADR 0012 §決定 2)。名稱是插件的介面,與 enum 分開寫死。 + static AuthRequirement _auth(String? name) => switch (name) { + null || 'never' => AuthRequirement.never, + 'userPreference' => AuthRequirement.userPreference, + 'required' => AuthRequirement.required, + _ => throw ArgumentError.value(name, 'auth'), + }; +} diff --git a/app/lib/plugins/runtime/plugin_runtime.dart b/app/lib/plugins/runtime/plugin_runtime.dart new file mode 100644 index 00000000..47873827 --- /dev/null +++ b/app/lib/plugins/runtime/plugin_runtime.dart @@ -0,0 +1,462 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; + +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/plugins/runtime/plugin_host.dart'; +import 'package:fmp/plugins/runtime/plugin_worker.dart'; +import 'package:fmp/plugins/runtime/script_errors.dart'; +import 'package:fmp/plugins/runtime/worker_protocol.dart'; +import 'package:fmp/plugins/source_plugin.dart'; + +/// 每次呼叫插件函式(含載入)的上限(ADR 0014;PRD 9a)。 +const defaultPluginCallTimeout = Duration(seconds: 30); + +/// 逾時後等背景 isolate 回應探測多久;等不到就判定它卡住。 +const defaultLivenessGrace = Duration(seconds: 2); + +/// 背景 isolate 的進入點;測試換掉它來模擬背景 isolate 當掉。 +typedef PluginWorkerEntry = void Function(PluginWorkerStart start); + +/// 插件停止回應或背景 isolate 意外結束。只當 [AppError] 的 cause(進 log)。 +final class PluginUnresponsive implements Exception { + const PluginUnresponsive(this.reason); + + final String reason; + + @override + String toString() => 'PluginUnresponsive: $reason'; +} + +/// 一個插件的 JS 執行環境的主 isolate 端(ADR 0014 §決定 2、5;prd 擁有者 +/// 決定 7)。 +/// +/// - QuickJS 在這個插件自己的背景 isolate(`plugin_worker.dart`);腳本同步 +/// 跑不停時卡住的是那個 isolate,UI 照常。 +/// - 宿主 API 的網路、storage、憑證、log 在這裡(主 isolate)執行,背景 +/// isolate 以訊息請求(`worker_protocol.dart`);網域與插件 id 的檢查都在 +/// 這一邊。`crypto` 在背景 isolate 算。 +/// - 看門狗:每次呼叫(含載入)有 [callTimeout]。到期時送 [Ping] 探測, +/// [livenessGrace] 內有回應就是「在等東西」(例如慢的網路),這次呼叫以 +/// [NetworkError] 失敗,插件照常;沒有回應就判定卡住:插件轉成 +/// [PluginHealth.unresponsive]、進行中與之後的呼叫都以 [UnexpectedError] +/// 失敗、呼叫 `Isolate.kill`。 +/// - **卡住的執行緒回收不了**:`Isolate.kill` 要等 isolate 回到 Dart 的事件 +/// 迴圈才生效,停在 QuickJS 原生碼裡的執行緒會一直忙到 App 結束。 +final class PluginRuntime { + PluginRuntime._({ + required this.pluginId, + required this._host, + required this._log, + required this.callTimeout, + required this.livenessGrace, + }); + + /// 在新的背景 isolate 載入 [script],回傳時已知道它匯出的函式 + /// ([exports])。 + /// + /// 腳本語法錯誤是 [ParseError];載入時拋出的其他錯誤照 [invoke] 的規則轉換; + /// 逾時照看門狗的規則;背景 isolate 起不來是 [UnexpectedError]。失敗時 + /// runtime 已釋放。 + static Future start({ + required String pluginId, + required String script, + required PluginHost host, + required Log log, + Duration callTimeout = defaultPluginCallTimeout, + Duration livenessGrace = defaultLivenessGrace, + PluginWorkerEntry entryPoint = pluginWorkerMain, + }) async { + final runtime = PluginRuntime._( + pluginId: pluginId, + host: host, + log: log, + callTimeout: callTimeout, + livenessGrace: livenessGrace, + ); + try { + await runtime._spawn(script, entryPoint); + return runtime; + } on AppError { + runtime.dispose(); + rethrow; + } on Object catch (error, stackTrace) { + // 例如 Isolate.spawn 失敗:插件邊界以上只看得到 AppError。 + runtime.dispose(); + throw AppError.wrap(error, stackTrace, pluginId: pluginId); + } + } + + final String pluginId; + + /// 每次呼叫的上限。 + final Duration callTimeout; + + /// 逾時後等探測回應的時間。 + final Duration livenessGrace; + + /// 腳本匯出的函式名稱。 + late final Set exports; + + final PluginHost _host; + final Log _log; + final _fromWorker = ReceivePort(); + Isolate? _isolate; + SendPort? _toWorker; + bool _disposed = false; + + PluginHealth _health = PluginHealth.ready; + final _unresponsive = Completer(); + + /// 等結果的呼叫;id 0 是載入。 + final _pending = {}; + int _lastCallId = 0; + + /// 進行中的探測。 + Future? _probe; + (int, Completer)? _pong; + int _lastPingId = 0; + + /// 宿主函式丟出的 [AppError],腳本以 `hostErrorId` 帶回來時原樣拋出(保留 + /// 網路紀錄 id 與原因)。沒有呼叫在進行時清空。 + final _hostErrors = {}; + int _lastHostErrorId = 0; + + PluginHealth get health => _health; + + /// 變成 [PluginHealth.unresponsive] 時完成。 + Future get whenUnresponsive => _unresponsive.future; + + /// 呼叫匯出的函式 [function],參數與回傳值都是 JSON。 + /// + /// 腳本拋出的錯誤:結構化錯誤轉成對應的 [AppError](`script_errors.dart`), + /// 宿主函式的錯誤原樣拋出,其他值包成 [UnexpectedError];回傳值無法轉成 JSON + /// 是 [ParseError];逾時照看門狗的規則;插件已沒有回應是 [UnexpectedError]。 + Future invoke(String function, Object? argument) async { + _checkUsable(); + if (!exports.contains(function)) { + throw ArgumentError.value(function, 'function', 'is not exported'); + } + // 先編碼再登記:編碼失敗時不留下等結果的呼叫。 + final argumentJson = jsonEncode(argument); + final id = ++_lastCallId; + final reply = _wait(id, function); + _toWorker!.send(CallRequest(id, function, argumentJson)); + try { + return _unwrap(await reply, loading: false); + } finally { + _forgetHostErrors(); + } + } + + /// 釋放:請背景 isolate 釋放 QuickJS 後結束,等待中的呼叫以 + /// [UnexpectedError] 結束。重複呼叫無作用。 + void dispose() { + if (_disposed) return; + _disposed = true; + _toWorker?.send(const Shutdown()); + _failPending( + (label) => UnexpectedError( + pluginId: pluginId, + cause: StateError('The plugin runtime was disposed during $label'), + stackTrace: StackTrace.current, + ), + ); + _stop(); + } + + Future _spawn(String script, PluginWorkerEntry entryPoint) async { + _fromWorker.listen(_onMessage); + // 先登記才收得到結果;計時從 isolate 起來之後算,spawn 本身不算腳本的時間。 + final loaded = _wait(0, 'load', startDeadline: false); + try { + _isolate = await Isolate.spawn( + entryPoint, + PluginWorkerStart( + toMain: _fromWorker.sendPort, + pluginId: pluginId, + script: script, + ), + onError: _fromWorker.sendPort, + onExit: _fromWorker.sendPort, + debugName: 'plugin $pluginId', + ); + } on Object { + // 沒有人會等 loaded 了:拿掉,dispose 才不會讓它以沒人接的錯誤結束。 + _pending.remove(0); + rethrow; + } + _pending[0]?.startDeadline(callTimeout, () => _onDeadline(0)); + try { + final value = _unwrap(await loaded, loading: true); + exports = Set.unmodifiable((value! as List).cast()); + } finally { + _forgetHostErrors(); + } + } + + void _checkUsable() { + if (_health == PluginHealth.unresponsive) { + throw _unresponsiveError('the plugin stopped responding earlier'); + } + if (_disposed) { + throw UnexpectedError( + pluginId: pluginId, + cause: StateError('The plugin runtime was disposed'), + stackTrace: StackTrace.current, + ); + } + } + + /// 登記一個等結果的呼叫並開始計時;結果到了就移除。 + Future _wait(int id, String label, {bool startDeadline = true}) { + final pending = _Pending(label); + if (startDeadline) { + pending.startDeadline(callTimeout, () => _onDeadline(id)); + } + _pending[id] = pending; + return pending.completer.future; + } + + /// 沒有呼叫在進行時清空 [_hostErrors]([_unwrap] 讀過之後才呼叫)。 + void _forgetHostErrors() { + if (_pending.isEmpty) _hostErrors.clear(); + } + + void _complete(int id, String reply) { + final pending = _pending.remove(id); + pending?.deadline?.cancel(); + pending?.completer.complete(reply); + } + + void _failPending(AppError Function(String label) error) { + final pending = [..._pending.values]; + _pending.clear(); + for (final call in pending) { + call.deadline?.cancel(); + call.completer.completeError(error(call.label), StackTrace.current); + } + } + + Future _onDeadline(int id) async { + if (!_pending.containsKey(id)) return; + final alive = await _isAlive(); + final pending = _pending[id]; + if (pending == null) return; + if (alive) { + _pending.remove(id); + pending.completer.completeError( + NetworkError( + pluginId: pluginId, + cause: TimeoutException( + 'Plugin call ${pending.label} did not finish', + callTimeout, + ), + stackTrace: StackTrace.current, + ), + ); + return; + } + _markUnresponsive( + '${pending.label} did not finish within $callTimeout and the worker ' + 'did not answer a ping within $livenessGrace', + ); + } + + /// 背景 isolate 的事件迴圈還在轉嗎:送 [Ping],[livenessGrace] 內等 [Pong]。 + Future _isAlive() => _probe ??= () async { + final toWorker = _toWorker; + if (toWorker == null) return false; + final pong = Completer(); + final id = ++_lastPingId; + _pong = (id, pong); + toWorker.send(Ping(id)); + final timer = Timer(livenessGrace, () { + if (!pong.isCompleted) pong.complete(false); + }); + final alive = await pong.future; + timer.cancel(); + _probe = null; + return alive; + }(); + + void _markUnresponsive(String reason) { + if (_disposed || _health == PluginHealth.unresponsive) return; + _health = PluginHealth.unresponsive; + _log.report( + 'Plugin stopped responding', + _unresponsiveError(reason), + tag: pluginId, + ); + // 卡在 QuickJS 原生碼裡的 isolate 不會真的停:kill 要等它回到事件迴圈。 + _isolate?.kill(priority: Isolate.immediate); + _failPending((label) => _unresponsiveError('$label: $reason')); + _stop(); + _unresponsive.complete(); + } + + /// 插件卡住或背景 isolate 死掉:插件本身的 bug,所以是 [UnexpectedError] + /// (不可重試、視為 bug 的通用訊息);狀態在 [health] 與清單上。 + AppError _unresponsiveError(String reason) => UnexpectedError( + pluginId: pluginId, + cause: PluginUnresponsive(reason), + stackTrace: StackTrace.current, + ); + + /// 不再收背景 isolate 的訊息、取消進行中的請求。 + void _stop() { + _host.close(); + _fromWorker.close(); + } + + void _onMessage(Object? message) { + switch (message) { + case WorkerReady(:final toWorker): + _toWorker = toWorker; + case LoadResult(:final replyJson): + _complete(0, replyJson); + case CallResult(:final id, :final replyJson): + _complete(id, replyJson); + case HostRequest(:final id, :final op, :final argumentsJson): + _reply(() => _host.callAsync(op, jsonDecode(argumentsJson))) + .then((reply) { + if (!_disposed && _health == PluginHealth.ready) { + _toWorker?.send(HostReply(id, reply)); + } + }); + case LogRequest(:final level, :final message, :final fieldsJson): + _host.writeLog( + level, + message, + (jsonDecode(fieldsJson) as Map).cast(), + ); + case RejectionReport(:final reason): + // QuickJS 在 Promise 被拒絕、當下還沒有處理者時就回報,之後接上 + // 處理者也不收回:async 函式拋錯而呼叫端隨後才 await 也會報。所以 + // 只記 debug,措辭照實寫;真正的錯誤經呼叫的結果拋出。 + _log.debug( + 'Promise rejected before a handler was attached', + tag: pluginId, + error: reason, + ); + case Pong(:final id): + if (_pong case (final expected, final pong) when expected == id) { + if (!pong.isCompleted) pong.complete(true); + } + // onError:[錯誤, stack](字串)。errorsAreFatal 預設為真,isolate 隨後 + // 結束。 + case [final Object? error, final Object? stack]: + _markUnresponsive('the worker failed: $error\n$stack'); + // onExit。 + case null: + _markUnresponsive('the worker exited'); + } + } + + Future _reply(Future Function() call) async { + try { + return jsonEncode({'ok': true, 'value': await call()}); + } on ArgumentError catch (error) { + return _argumentError(error.toString()); + } on FormatException catch (error) { + return _argumentError(error.message); + } on Object catch (error, stackTrace) { + return _appError(AppError.wrap(error, stackTrace, pluginId: pluginId)); + } + } + + static String _argumentError(String message) => jsonEncode({ + 'ok': false, + 'error': {'kind': 'argument', 'message': message}, + }); + + /// 腳本只看到類別名與重試資訊;原因(可能含網址、伺服器訊息)留在主 + /// isolate。 + String _appError(AppError error) { + final id = ++_lastHostErrorId; + _hostErrors[id] = error; + return jsonEncode({ + 'ok': false, + 'error': { + 'kind': 'host', + 'fmpError': error.typeName, + 'hostErrorId': id, + 'retryAfterSeconds': switch (error.retryAfter) { + null => null, + final delay => delay.inMilliseconds / 1000, + }, + 'reason': switch (error) { + Unavailable(:final reason) => unavailableReasonWireName(reason), + _ => null, + }, + }, + }); + } + + Object? _unwrap(String reply, {required bool loading}) { + final json = jsonDecode(reply) as Map; + if (json['ok'] == true) return json['value']; + final error = json['error']! as Map; + final stackTrace = StackTrace.current; + switch (error) { + case {'kind': 'structured', 'fmpError': final String name}: + final hostError = switch (error['hostErrorId']) { + final int id => _hostErrors[id], + _ => null, + }; + if (hostError != null && hostError.typeName == name) throw hostError; + throw structuredScriptError( + pluginId: pluginId, + fmpError: name, + retryAfterSeconds: error['retryAfterSeconds'] as num?, + reason: error['reason'] as String?, + message: error['message'] as String?, + ); + case {'kind': 'unserializable', 'message': final String? message}: + throw ParseError( + pluginId: pluginId, + cause: PluginScriptError('Unserializable', message ?? ''), + stackTrace: stackTrace, + ); + case { + 'kind': 'thrown', + 'name': final String name, + 'message': final String message, + 'stack': final String jsStack, + }: + final cause = PluginScriptError(name, message, jsStack); + // 載入時的語法錯誤:腳本本身讀不懂。 + if (loading && name == 'SyntaxError') { + throw ParseError( + pluginId: pluginId, + cause: cause, + stackTrace: stackTrace, + ); + } + throw UnexpectedError( + pluginId: pluginId, + cause: cause, + stackTrace: stackTrace, + ); + } + throw UnexpectedError( + pluginId: pluginId, + cause: FormatException('Unexpected reply from the worker: $reply'), + stackTrace: stackTrace, + ); + } +} + +final class _Pending { + _Pending(this.label); + + /// 函式名稱,或 `load`。只進 log。 + final String label; + final completer = Completer(); + Timer? deadline; + + void startDeadline(Duration timeout, void Function() onDeadline) { + deadline = Timer(timeout, onDeadline); + } +} diff --git a/app/lib/plugins/runtime/plugin_worker.dart b/app/lib/plugins/runtime/plugin_worker.dart new file mode 100644 index 00000000..f49406ee --- /dev/null +++ b/app/lib/plugins/runtime/plugin_worker.dart @@ -0,0 +1,189 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; + +import 'package:crypto/crypto.dart' as crypto; +import 'package:flutter_js/flutter_js.dart'; + +import 'package:fmp/plugins/json_shape.dart'; +import 'package:fmp/plugins/manifest/plugin_manifest.dart'; +import 'package:fmp/plugins/runtime/js_prelude.dart'; +import 'package:fmp/plugins/runtime/worker_protocol.dart'; + +/// 插件腳本載入成 ES module 時的名稱。module loader 只對這個名稱回傳腳本, +/// 其他 `import` 一律失敗。 +const pluginModuleName = 'fmp-plugin'; + +/// 插件背景 isolate 的進入點(`Isolate.spawn`):建立 QuickJS、執行 prelude、 +/// 載入腳本,之後處理主 isolate 的呼叫。 +/// +/// 這個 isolate 只跑 JS 與不碰外界的宿主函式(`crypto`、log 參數的形狀檢查); +/// 網路、storage、憑證都以 [HostRequest] 交給主 isolate,安全檢查在那裡。 +/// 腳本同步跑不停時卡住的是這個 isolate,主 isolate 照常。 +void pluginWorkerMain(PluginWorkerStart start) { + final commands = ReceivePort(); + // 先交出 port:之後即使載入卡住,主 isolate 的看門狗也送得到 Ping。 + start.toMain.send(WorkerReady(commands.sendPort)); + final worker = _Worker(start, commands); + commands.listen((message) => worker.handle(message as ToWorker)); + worker.load(); +} + +final class _Worker { + _Worker(this._start, this._commands) + : _engine = _QuickJs( + moduleHandler: (name) => name == pluginModuleName + ? _start.script + : throw ArgumentError.value(name, 'module', 'cannot be imported'), + hostPromiseRejectionHandler: (reason) => + _start.toMain.send(RejectionReport('$reason')), + ) { + final prelude = _engine.evaluate(jsPrelude, name: ''); + if (prelude.isError) { + throw StateError('The plugin prelude failed: ${prelude.stringResult}'); + } + final factory = prelude.rawResult as JSInvokable; + final api = factory.invoke([_hostAsync, _hostSync, hostApiVersion]) as Map; + factory.free(); + _load = api['load'] as JSInvokable; + _call = api['call'] as JSInvokable; + } + + final PluginWorkerStart _start; + final ReceivePort _commands; + final _QuickJs _engine; + late final JSInvokable _load; + late final JSInvokable _call; + bool _closed = false; + + /// 等主 isolate 回覆的宿主呼叫。 + final _hostCalls = >{}; + int _lastHostCallId = 0; + + void load() { + final promise = _load.invoke([pluginModuleName]) as Future; + _pump(); + promise.then( + (reply) => _start.toMain.send(LoadResult(reply! as String)), + onError: (Object _) => _start.toMain.send(const LoadResult(_noReply)), + ); + } + + void handle(ToWorker message) { + if (_closed) return; + switch (message) { + case CallRequest(:final id, :final function, :final argumentJson): + final promise = + _call.invoke([function, argumentJson]) as Future; + _pump(); + promise.then( + (reply) => _start.toMain.send(CallResult(id, reply! as String)), + onError: (Object _) => _start.toMain.send(CallResult(id, _noReply)), + ); + case HostReply(:final id, :final replyJson): + _hostCalls.remove(id)?.complete(replyJson); + // complete 先排了一個 microtask 把結果交給 listener(flutter_js 在 + // 那裡呼叫 JS 的 resolve),這個排在它之後,Promise 工作才會有東西 + // 跑。不用 Timer.run:在 Android 的 UI isolate 上實測它要等下一輪訊息 + // 迴圈,每次宿主呼叫多約一個 frame(PR 9a 的 research/notes.md §4)。 + scheduleMicrotask(_pump); + case Ping(:final id): + _start.toMain.send(Pong(id)); + case Shutdown(): + _closed = true; + _load.free(); + _call.free(); + _engine.dispose(); + _commands.close(); + } + } + + /// 執行排著的 Promise 工作,直到沒有為止。 + void _pump() { + if (!_closed) _engine.executePendingJob(); + } + + /// JS 的 `hostAsync(op, argsJson)`:交給主 isolate,回覆到了才 resolve。 + Future _hostAsync(String op, String args) { + final id = ++_lastHostCallId; + final completer = Completer(); + _hostCalls[id] = completer; + _start.toMain.send(HostRequest(id, op, args)); + return completer.future; + } + + /// JS 的 `hostSync(op, argsJson)`:不碰外界,在這裡算完。 + String _hostSync(String op, String args) { + try { + final arguments = jsonDecode(args); + return jsonEncode({'ok': true, 'value': _sync(op, arguments)}); + } on ArgumentError catch (error) { + return _argumentError(error.toString()); + } on FormatException catch (error) { + return _argumentError(error.message); + } + } + + Object? _sync(String op, Object? args) => switch (op) { + 'crypto.md5' => crypto.md5.convert(utf8.encode(_text(args))).toString(), + 'crypto.sha256' => + crypto.sha256.convert(utf8.encode(_text(args))).toString(), + 'log' => _log(args), + _ => throw ArgumentError.value(op, 'op', 'unknown host function'), + }; + + /// 檢查 log 參數的形狀後交給主 isolate;層級名稱在那裡再對一次。 + Object? _log(Object? args) { + final fields = JsonFields(args, const { + 'level': true, + 'message': true, + 'fields': false, + }, path: 'fmp.log'); + final level = fields.string('level'); + if (!const {'debug', 'info', 'warn', 'error'}.contains(level)) { + throw ArgumentError.value(level, 'level'); + } + _start.toMain.send( + LogRequest( + level, + fields.string('message'), + jsonEncode(fields.optionalObject('fields') ?? const {}), + ), + ); + return null; + } + + static String _text(Object? args) => switch (args) { + {'text': final String text} => text, + _ => throw ArgumentError('crypto input must be a string'), + }; + + /// prelude 的 `load`/`call` 本來永遠 resolve;腳本弄壞了 JSON(例如讓 + /// `Object.prototype.toJSON` 拋錯)時它連錯誤都回不了。改回這個,不讓它成為 + /// 背景 isolate 未捕捉的錯誤:那會被當成當掉,插件停用到重啟。 + static const _noReply = + '{"ok":false,"error":{"kind":"thrown","name":"NoReply",' + '"message":"the plugin could not encode its reply","stack":""}}'; + + static String _argumentError(String message) => jsonEncode({ + 'ok': false, + 'error': {'kind': 'argument', 'message': message}, + }); +} + +/// 不裝 flutter_js 預設全域的 QuickJS runtime。 +/// +/// [QuickJsRuntime2] 的建構子呼叫 [init],預設會加上 `console`(寫到 +/// `print`)、`setTimeout`(Timer 到期時呼叫 `evaluate`,runtime 已釋放也會 +/// 重建引擎)與 `sendMessage`;宿主 API 只有 `fmp`,所以都不要。也不用 +/// `getJavascriptRuntime()`:它會裝走 `package:http` 的 `fetch`,繞過宿主的 +/// 網路層。 +final class _QuickJs extends QuickJsRuntime2 { + _QuickJs({ + required super.moduleHandler, + required super.hostPromiseRejectionHandler, + }); + + @override + JavascriptRuntime init() => this; +} diff --git a/app/lib/plugins/runtime/script_errors.dart b/app/lib/plugins/runtime/script_errors.dart new file mode 100644 index 00000000..7c7aa636 --- /dev/null +++ b/app/lib/plugins/runtime/script_errors.dart @@ -0,0 +1,129 @@ +import 'package:fmp/core/errors/app_error.dart'; + +/// 腳本拋出的值(JS 的 name、message、stack)。只當 [AppError] 的 cause, +/// 經 `log.report` 遮蔽後進 log,不給使用者看。 +final class PluginScriptError implements Exception { + const PluginScriptError(this.name, this.message, [this.jsStack = '']); + + final String name; + final String message; + final String jsStack; + + @override + String toString() => + jsStack.isEmpty ? '$name: $message' : '$name: $message\n$jsStack'; +} + +/// `Unavailable` 的 `reason` 字串。插件的介面,與 enum 的名稱分開寫死。 +String unavailableReasonWireName(UnavailableReason reason) => switch (reason) { + UnavailableReason.region => 'region', + UnavailableReason.copyright => 'copyright', + UnavailableReason.membership => 'membership', + UnavailableReason.age => 'age', + UnavailableReason.previewOnly => 'previewOnly', +}; + +UnavailableReason? _reason(String? name) => switch (name) { + 'region' => UnavailableReason.region, + 'copyright' => UnavailableReason.copyright, + 'membership' => UnavailableReason.membership, + 'age' => UnavailableReason.age, + 'previewOnly' => UnavailableReason.previewOnly, + _ => null, +}; + +/// `retryAfterSeconds` 再大也不超過這個(與 `parseRetryAfter` 的上限相同): +/// 換成毫秒會超出 int,`Duration` 溢位成負的等待。 +const _longestRetryAfter = Duration(days: 36500); + +/// 把腳本拋出的結構化錯誤(`throw {fmpError: '<類別名>', ...}`,ADR 0014 +/// §決定 5)轉成 [AppError]。 +/// +/// - `fmpError` 是 [AppError] 的十個子類名之一:對應的子類;`retryAfterSeconds` +/// (有限的非負數,上限 [_longestRetryAfter])填 `retryAfter`; +/// - 名稱不認得、`Unavailable` 沒有合法的 `reason`:插件的 bug, +/// [UnexpectedError]。 +/// +/// 使用者訊息用子類的預設 key;插件給的 `message` 只進 cause。 +AppError structuredScriptError({ + required String pluginId, + required String fmpError, + num? retryAfterSeconds, + String? reason, + String? message, +}) { + final cause = PluginScriptError(fmpError, message ?? ''); + final stackTrace = StackTrace.current; + final retryAfter = + retryAfterSeconds != null && + retryAfterSeconds.isFinite && + retryAfterSeconds >= 0 + ? (retryAfterSeconds > _longestRetryAfter.inSeconds + ? _longestRetryAfter + : Duration(milliseconds: (retryAfterSeconds * 1000).round())) + : null; + AppError unexpected(String problem) => UnexpectedError( + pluginId: pluginId, + cause: PluginScriptError('InvalidStructuredError', problem), + stackTrace: stackTrace, + ); + return switch (fmpError) { + 'NetworkError' => NetworkError( + pluginId: pluginId, + retryAfter: retryAfter, + cause: cause, + stackTrace: stackTrace, + ), + 'RateLimited' => RateLimited( + pluginId: pluginId, + retryAfter: retryAfter, + cause: cause, + stackTrace: stackTrace, + ), + 'AuthRequired' => AuthRequired( + pluginId: pluginId, + cause: cause, + stackTrace: stackTrace, + ), + 'CredentialInvalid' => CredentialInvalid( + pluginId: pluginId, + cause: cause, + stackTrace: stackTrace, + ), + 'VerificationRequired' => VerificationRequired( + pluginId: pluginId, + cause: cause, + stackTrace: stackTrace, + ), + 'Unavailable' => switch (_reason(reason)) { + final reason? => Unavailable( + reason: reason, + pluginId: pluginId, + cause: cause, + stackTrace: stackTrace, + ), + null => unexpected('Unavailable needs a valid reason, got "$reason"'), + }, + 'NotFound' => NotFound( + pluginId: pluginId, + cause: cause, + stackTrace: stackTrace, + ), + 'ParseError' => ParseError( + pluginId: pluginId, + cause: cause, + stackTrace: stackTrace, + ), + 'Unsupported' => Unsupported( + pluginId: pluginId, + cause: cause, + stackTrace: stackTrace, + ), + 'UnexpectedError' => UnexpectedError( + pluginId: pluginId, + cause: cause, + stackTrace: stackTrace, + ), + _ => unexpected('unknown fmpError "$fmpError"'), + }; +} diff --git a/app/lib/plugins/runtime/worker_protocol.dart b/app/lib/plugins/runtime/worker_protocol.dart new file mode 100644 index 00000000..4710ea31 --- /dev/null +++ b/app/lib/plugins/runtime/worker_protocol.dart @@ -0,0 +1,116 @@ +import 'dart:isolate'; + +// 主 isolate 與插件背景 isolate 之間的訊息(prd 擁有者決定 7、ADR 0014 +// 2026-09-30 補充)。兩邊在同一個 isolate group(Isolate.spawn),所以直接傳 +// 這些物件;內容只有字串、數字與 SendPort。 +// +// 參數與結果一律是 JSON 字串:與 prelude 的 `{ok, value}`/`{ok, error}` +// 回覆同一個格式,兩邊都不必轉換 Dart 物件。 + +/// Isolate.spawn 的起始訊息。 +final class PluginWorkerStart { + const PluginWorkerStart({ + required this.toMain, + required this.pluginId, + required this.script, + }); + + final SendPort toMain; + final String pluginId; + final String script; +} + +/// 主 isolate → 背景 isolate。 +sealed class ToWorker { + const ToWorker(); +} + +/// 呼叫匯出的函式。 +final class CallRequest extends ToWorker { + const CallRequest(this.id, this.function, this.argumentJson); + + final int id; + final String function; + final String argumentJson; +} + +/// 宿主非同步函式的回覆([HostRequest] 的結果)。 +final class HostReply extends ToWorker { + const HostReply(this.id, this.replyJson); + + final int id; + final String replyJson; +} + +/// 看門狗的探測:背景 isolate 的事件迴圈還在轉就回 [Pong]。 +final class Ping extends ToWorker { + const Ping(this.id); + + final int id; +} + +/// 釋放 QuickJS 並結束 isolate。 +final class Shutdown extends ToWorker { + const Shutdown(); +} + +/// 背景 isolate → 主 isolate。 +sealed class ToMain { + const ToMain(); +} + +/// 背景 isolate 一開始就送:之後的訊息往這個 port 送。 +final class WorkerReady extends ToMain { + const WorkerReady(this.toWorker); + + final SendPort toWorker; +} + +/// 載入完成(或失敗)。[replyJson] 成功時的值是匯出的函式名稱。 +final class LoadResult extends ToMain { + const LoadResult(this.replyJson); + + final String replyJson; +} + +/// [CallRequest] 的結果。 +final class CallResult extends ToMain { + const CallResult(this.id, this.replyJson); + + final int id; + final String replyJson; +} + +/// 腳本呼叫宿主的非同步函式(網路、storage、憑證),在主 isolate 執行。 +final class HostRequest extends ToMain { + const HostRequest(this.id, this.op, this.argumentsJson); + + final int id; + final String op; + final String argumentsJson; +} + +/// 腳本寫的 log(`fmp.log`、`console`)。背景 isolate 只檢查形狀,tag 由主 +/// isolate 填成插件 id。 +final class LogRequest extends ToMain { + const LogRequest(this.level, this.message, this.fieldsJson); + + /// `debug`、`info`、`warn`、`error`。 + final String level; + final String message; + final String fieldsJson; +} + +/// Promise 被拒絕時還沒有處理者(只記 debug,見 `plugin_worker.dart`)。 +final class RejectionReport extends ToMain { + const RejectionReport(this.reason); + + final String reason; +} + +/// [Ping] 的回覆。 +final class Pong extends ToMain { + const Pong(this.id); + + final int id; +} diff --git a/app/lib/plugins/script_source_plugin.dart b/app/lib/plugins/script_source_plugin.dart new file mode 100644 index 00000000..f62e2c0e --- /dev/null +++ b/app/lib/plugins/script_source_plugin.dart @@ -0,0 +1,175 @@ +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/errors/retry_policy.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/network/allowed_hosts.dart'; +import 'package:fmp/core/network/source_http_client.dart'; +import 'package:fmp/core/redaction/redactor.dart'; +import 'package:fmp/data/repositories/plugin_storage_repository.dart'; +import 'package:fmp/plugins/manifest/plugin_file.dart'; +import 'package:fmp/plugins/manifest/plugin_manifest.dart'; +import 'package:fmp/plugins/runtime/plugin_host.dart'; +import 'package:fmp/plugins/runtime/plugin_runtime.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:fmp/plugins/source_plugin.dart'; + +/// 由 JS 腳本實作的 [SourcePlugin]:DTO 轉成 JSON 交給 [PluginRuntime],回傳值 +/// 驗證後轉回 DTO,形狀不對是 [ParseError]。 +final class ScriptSourcePlugin implements SourcePlugin { + ScriptSourcePlugin._(this.manifest, this._runtime) + : _allowedHosts = AllowedHosts(manifest.allowedHosts); + + @override + final PluginManifest manifest; + final PluginRuntime _runtime; + final AllowedHosts _allowedHosts; + + @override + PluginHealth get health => _runtime.health; + + @override + Future get whenUnresponsive => _runtime.whenUnresponsive; + + @override + Future search(SearchQuery query) async { + final json = await _invoke(PluginCapability.search, query.toJson()); + return _decode( + () => SearchPage.fromJson( + json, + sourceTypeId: manifest.id, + allowedHosts: _allowedHosts, + ), + ); + } + + @override + Future> resolveStream(StreamRequest request) async { + final json = await _invoke( + PluginCapability.resolveStream, + request.toJson(), + ); + return _decode( + () => StreamCandidate.listFromJson(json, allowedHosts: _allowedHosts), + ); + } + + @override + void close() => _runtime.dispose(); + + Future _invoke(PluginCapability capability, Object? argument) { + if (!manifest.capabilities.contains(capability)) { + throw Unsupported( + pluginId: manifest.id, + cause: StateError('${capability.wireName} is not declared'), + stackTrace: StackTrace.current, + ); + } + return _runtime.invoke(capability.wireName, argument); + } + + T _decode(T Function() decode) { + try { + return decode(); + } on FormatException catch (error, stackTrace) { + throw ParseError( + pluginId: manifest.id, + cause: error, + stackTrace: stackTrace, + ); + } + } +} + +/// 把安裝檔變成可用的 [ScriptSourcePlugin]:建網路 client 與宿主 API、在 +/// runtime 載入腳本、檢查匯出與能力一致。 +/// +/// App 共用的東西(log、遮蔽函式、HTTP client 工廠、storage)在這裡給一次。 +final class ScriptPluginLoader { + ScriptPluginLoader({ + required this._log, + required this._redactor, + required this._httpClients, + required this._storage, + this._callTimeout = defaultPluginCallTimeout, + this._livenessGrace = defaultLivenessGrace, + }); + + final Log _log; + final Redactor _redactor; + final SourceHttpClientFactory _httpClients; + final PluginStorageRepository _storage; + final Duration _callTimeout; + final Duration _livenessGrace; + + /// 載入 [file]。 + /// + /// manifest 宣告的能力沒有同名的匯出函式,或匯出了能力名稱的函式卻沒宣告: + /// [Unsupported](ADR 0014 §如何確認)。腳本本身的錯誤見 + /// [PluginRuntime.start]。 + Future load(PluginFile file) async { + final manifest = file.manifest; + // 遮蔽名單只增不減:插件更新或載入失敗都不收回(多遮的代價小於漏遮)。 + _redactor.addRules( + headerNames: manifest.redaction.headerNames, + keyNames: manifest.redaction.keyNames, + mediaCdns: manifest.redaction.mediaCdns, + ); + final host = PluginHost( + pluginId: manifest.id, + http: _httpClients.create( + pluginId: manifest.id, + allowedHosts: manifest.allowedHosts, + retryPolicy: manifest.retryPolicy ?? const RetryPolicy(), + rateLimitPolicy: manifest.rateLimitPolicy, + ), + storage: _storage, + log: _log, + ); + final PluginRuntime runtime; + try { + runtime = await PluginRuntime.start( + pluginId: manifest.id, + script: file.source, + host: host, + log: _log, + callTimeout: _callTimeout, + livenessGrace: _livenessGrace, + ); + } on Object { + host.close(); + rethrow; + } + final mismatch = _exportMismatch(manifest.capabilities, runtime.exports); + if (mismatch != null) { + runtime.dispose(); + throw Unsupported( + pluginId: manifest.id, + cause: StateError(mismatch), + stackTrace: StackTrace.current, + ); + } + return ScriptSourcePlugin._(manifest, runtime); + } + + static String? _exportMismatch( + Set declared, + Set exports, + ) { + final missing = [ + for (final capability in declared) + if (!exports.contains(capability.wireName)) capability.wireName, + ]; + final undeclared = [ + for (final name in exports) + if (PluginCapability.fromWireName(name) case final capability? + when !declared.contains(capability)) + name, + ]; + if (missing.isEmpty && undeclared.isEmpty) return null; + return [ + if (missing.isNotEmpty) + 'declared but not exported: ${missing.join(', ')}', + if (undeclared.isNotEmpty) + 'exported but not declared: ${undeclared.join(', ')}', + ].join('; '); + } +} diff --git a/app/lib/plugins/source_dto.dart b/app/lib/plugins/source_dto.dart new file mode 100644 index 00000000..c40c8189 --- /dev/null +++ b/app/lib/plugins/source_dto.dart @@ -0,0 +1,348 @@ +import 'package:flutter/foundation.dart'; + +import 'package:fmp/core/network/allowed_hosts.dart'; +import 'package:fmp/plugins/json_shape.dart'; + +// 宿主與插件交換的 DTO,apiVersion 1(ADR 0014 §決定 5)。 +// +// 送給插件的(SearchQuery、StreamRequest)在建構時檢查參數,錯了是 App 的 bug, +// 拋 ArgumentError;插件回傳的(SearchPage、StreamCandidate)以 fromJson 解碼, +// 形狀不對拋 FormatException,由 ScriptSourcePlugin 轉成 ParseError。 +// +// 欄位名稱是插件的介面:改名或改必填就是不相容的改動,要加 hostApiVersion。 +// `lib/plugins/types/fmp-plugin.d.ts` 的同名 interface 與 [sourceDtoShapes] +// 由 test/plugins/type_definitions_test.dart 比對。 + +/// DTO 的欄位表,鍵是 `fmp-plugin.d.ts` 裡的 interface 名稱。 +const sourceDtoShapes = { + 'SearchQuery': {'keyword': true, 'page': true}, + 'SearchPage': {'items': true, 'hasMore': true}, + 'TrackSummary': { + 'sourceId': true, + 'cid': false, + 'title': true, + 'uploader': false, + 'durationMs': false, + 'artwork': false, + }, + 'Artwork': {'url': true, 'width': false}, + 'StreamRequest': { + 'sourceId': true, + 'cid': false, + 'purpose': true, + 'formats': true, + }, + 'StreamFormat': {'container': true, 'codec': true}, + 'StreamResult': {'candidates': true}, + 'StreamCandidate': { + 'url': true, + 'headers': false, + 'container': false, + 'codec': false, + 'bitrate': false, + 'expiresAt': false, + }, +}; + +/// 搜尋的輸入。 +@immutable +final class SearchQuery { + SearchQuery({required this.keyword, this.page = 1}) { + if (keyword.trim().isEmpty) { + throw ArgumentError.value(keyword, 'keyword', 'must not be empty'); + } + RangeError.checkValueInInterval(page, 1, 1 << 31, 'page'); + } + + final String keyword; + + /// 從 1 開始。 + final int page; + + Map toJson() => {'keyword': keyword, 'page': page}; +} + +/// 搜尋的一頁結果。 +@immutable +final class SearchPage { + const SearchPage({required this.items, required this.hasMore}); + + /// 解碼插件回傳的值。[sourceTypeId] 是插件 id:曲目鍵的第一段由宿主填, + /// 插件不能宣稱別的音源的曲目。 + factory SearchPage.fromJson( + Object? json, { + required String sourceTypeId, + required AllowedHosts allowedHosts, + }) { + final fields = JsonFields( + json, + sourceDtoShapes['SearchPage']!, + path: 'SearchPage', + ); + final hasMore = fields.optionalBool('hasMore'); + return SearchPage( + items: List.unmodifiable([ + for (final (index, item) in fields.list('items').indexed) + TrackSummary._fromJson( + item, + sourceTypeId: sourceTypeId, + allowedHosts: allowedHosts, + path: 'SearchPage.items[$index]', + ), + ]), + hasMore: + hasMore ?? + (throw const FormatException('SearchPage.hasMore: missing')), + ); + } + + final List items; + final bool hasMore; +} + +/// 搜尋結果、歌單裡的一首曲目。 +@immutable +final class TrackSummary { + const TrackSummary({ + required this.sourceTypeId, + required this.sourceId, + this.cid, + required this.title, + this.uploader, + this.duration, + this.artwork = const [], + }); + + factory TrackSummary._fromJson( + Object? json, { + required String sourceTypeId, + required AllowedHosts allowedHosts, + required String path, + }) { + final fields = JsonFields( + json, + sourceDtoShapes['TrackSummary']!, + path: path, + ); + final durationMs = fields.optionalInteger('durationMs'); + return TrackSummary( + sourceTypeId: sourceTypeId, + sourceId: _sourceId(fields, path), + cid: fields.optionalInteger('cid'), + title: fields.nonEmptyString('title'), + uploader: fields.optionalString('uploader'), + duration: durationMs == null ? null : Duration(milliseconds: durationMs), + artwork: List.unmodifiable([ + for (final (index, item) + in (fields.optionalList('artwork') ?? []).indexed) + Artwork._fromJson( + item, + allowedHosts: allowedHosts, + path: '$path.artwork[$index]', + ), + ]), + ); + } + + /// 曲目鍵的三段(`TrackKey.format`):音源(插件 id)、音源內的 id、分 P 的 cid。 + final String sourceTypeId; + final String sourceId; + final int? cid; + + final String title; + final String? uploader; + final Duration? duration; + + /// 多尺寸封面(ADR 0016 §決定 4);宿主挑最接近顯示尺寸的一張。 + final List artwork; +} + +/// 封面的一個尺寸。 +@immutable +final class Artwork { + const Artwork({required this.url, this.width}); + + factory Artwork._fromJson( + Object? json, { + required AllowedHosts allowedHosts, + required String path, + }) { + final fields = JsonFields(json, sourceDtoShapes['Artwork']!, path: path); + final url = Uri.tryParse(fields.string('url')); + if (url == null || !allowedHosts.allows(url)) { + throw FormatException( + '$path.url: must be an https URL on an allowed host', + ); + } + return Artwork(url: url, width: fields.optionalInteger('width', min: 1)); + } + + final Uri url; + + /// 像素寬度;不知道就是 `null`。 + final int? width; +} + +/// 解析串流的用途(ADR 0014 §決定 5)。下載(ADR 0020)在 M6 加。 +enum StreamPurpose { + playback; + + String get wireName => switch (this) { + playback => 'playback', + }; +} + +/// 平台能播的一種格式(ADR 0018 §決定 6):插件依它挑候選。值是小寫的慣用 +/// 名稱(容器 `mp4`、`webm`,編碼 `aac`、`opus`)。 +@immutable +final class StreamFormat { + StreamFormat({required this.container, required this.codec}) { + if (container.isEmpty || codec.isEmpty) { + throw ArgumentError('container and codec must not be empty'); + } + } + + final String container; + final String codec; + + Map toJson() => {'container': container, 'codec': codec}; +} + +/// `resolveStream` 的輸入。 +@immutable +final class StreamRequest { + StreamRequest({ + required this.sourceId, + this.cid, + required this.formats, + this.purpose = StreamPurpose.playback, + }) { + if (sourceId.isEmpty || sourceId.contains(':')) { + throw ArgumentError.value(sourceId, 'sourceId', 'empty or contains ":"'); + } + if (formats.isEmpty) { + throw ArgumentError.value(formats, 'formats', 'must not be empty'); + } + } + + /// 曲目鍵的第二、三段;第一段就是被呼叫的插件。 + final String sourceId; + final int? cid; + + final List formats; + final StreamPurpose purpose; + + Map toJson() => { + 'sourceId': sourceId, + 'cid': ?cid, + 'purpose': purpose.wireName, + 'formats': [for (final format in formats) format.toJson()], + }; +} + +/// 一個候選串流。`resolveStream` 回傳依優先序排好的清單(ADR 0018 §決定 6)。 +@immutable +final class StreamCandidate { + const StreamCandidate({ + required this.url, + this.headers = const {}, + this.container, + this.codec, + this.bitrate, + this.expiresAt, + }); + + factory StreamCandidate._fromJson( + Object? json, { + required AllowedHosts allowedHosts, + required String path, + }) { + final fields = JsonFields( + json, + sourceDtoShapes['StreamCandidate']!, + path: path, + ); + final url = Uri.tryParse(fields.string('url')); + if (url == null || !(allowedHosts.allows(url) || _isAsset(url))) { + throw FormatException( + '$path.url: must be an https URL on an allowed host or an asset URL', + ); + } + final expiresAt = fields.optionalInteger('expiresAt'); + // DateTime 只放得下 ±8.64e15 毫秒,超過會拋 RangeError 而不是 FormatException。 + if (expiresAt != null && expiresAt > _maxEpochMilliseconds) { + throw FormatException('$path.expiresAt: out of range'); + } + return StreamCandidate( + url: url, + headers: Map.unmodifiable(fields.optionalStringMap('headers') ?? {}), + container: fields.optionalString('container'), + codec: fields.optionalString('codec'), + bitrate: fields.optionalInteger('bitrate'), + expiresAt: expiresAt == null + ? null + : DateTime.fromMillisecondsSinceEpoch(expiresAt, isUtc: true), + ); + } + + /// 解碼 `resolveStream` 的回傳值(`{candidates: [...]}`),至少一個候選。 + static List listFromJson( + Object? json, { + required AllowedHosts allowedHosts, + }) { + final fields = JsonFields( + json, + sourceDtoShapes['StreamResult']!, + path: 'StreamResult', + ); + final candidates = fields.list('candidates'); + if (candidates.isEmpty) { + throw const FormatException( + 'StreamResult.candidates: must not be empty; throw NotFound or ' + 'Unavailable instead', + ); + } + return List.unmodifiable([ + for (final (index, item) in candidates.indexed) + StreamCandidate._fromJson( + item, + allowedHosts: allowedHosts, + path: 'StreamResult.candidates[$index]', + ), + ]); + } + + /// `https` 網址(網域在 manifest 的允許清單內),或 App 內附的 asset + /// (`asset:///…`,測試插件用;不經網路)。 + final Uri url; + + /// 播放請求要帶的 headers;交給播放後端前先經 `mediaRequestHeaders`。 + final Map headers; + final String? container; + final String? codec; + + /// 每秒位元數。 + final int? bitrate; + + /// 網址的期限,插件從網址本身讀(ADR 0016 §決定 5);不知道就是 `null`。 + final DateTime? expiresAt; + + /// `asset:///`:沒有 host、userinfo、port。 + static bool _isAsset(Uri url) => + url.scheme == 'asset' && + url.host.isEmpty && + url.userInfo.isEmpty && + !url.hasPort && + url.path.length > 1; +} + +/// `DateTime` 能表示的最大 epoch 毫秒(ECMAScript 的時間值範圍)。 +const _maxEpochMilliseconds = 8640000000000000; + +String _sourceId(JsonFields fields, String path) { + final sourceId = fields.string('sourceId'); + if (sourceId.isEmpty || sourceId.contains(':')) { + throw FormatException('$path.sourceId: empty or contains ":"'); + } + return sourceId; +} diff --git a/app/lib/plugins/source_plugin.dart b/app/lib/plugins/source_plugin.dart new file mode 100644 index 00000000..5d20a28f --- /dev/null +++ b/app/lib/plugins/source_plugin.dart @@ -0,0 +1,37 @@ +import 'package:fmp/plugins/manifest/plugin_manifest.dart'; +import 'package:fmp/plugins/source_dto.dart'; + +/// 一個音源(ADR 0014 §決定 1)。App 其他部分只認這個介面與 [manifest] 的 +/// 能力宣告,分不出來源是誰、是不是腳本。 +/// +/// 方法只在引入它們的里程碑加,不先寫空殼;M1 只有 [search] 與 +/// [resolveStream]。呼叫沒宣告的能力拋 `Unsupported`。 +/// +/// 丟出的錯誤都是 `AppError`(ADR 0013 §決定 2)。 +abstract interface class SourcePlugin { + PluginManifest get manifest; + + /// 插件還能不能用。 + PluginHealth get health; + + /// 插件變成 [PluginHealth.unresponsive] 時完成;一直正常就不會完成。 + Future get whenUnresponsive; + + /// [PluginCapability.search]。 + Future search(SearchQuery query); + + /// [PluginCapability.resolveStream]:依優先序排好的候選,至少一個。 + Future> resolveStream(StreamRequest request); + + /// 釋放資源(JS runtime、連線)。之後的呼叫失敗。 + void close(); +} + +/// 插件的狀態(prd 擁有者決定 7)。 +enum PluginHealth { + ready, + + /// 呼叫逾時而背景 isolate 也不回應探測,或背景 isolate 意外結束。之後的 + /// 呼叫一律失敗,直到 App 重啟。 + unresponsive, +} diff --git a/app/lib/plugins/types/fmp-plugin.d.ts b/app/lib/plugins/types/fmp-plugin.d.ts new file mode 100644 index 00000000..1cad0f4c --- /dev/null +++ b/app/lib/plugins/types/fmp-plugin.d.ts @@ -0,0 +1,253 @@ +// FMP 插件的型別定義,apiVersion 1(ADR 0014 §決定 5)。 +// +// 插件是一個 .js 檔:開頭以 `/* ==FMP Plugin==` 與 `==/FMP Plugin== */` 包一段 +// JSON manifest(FmpPluginManifest),之後是 ES2020 module,每個宣告的能力匯出 +// 一個同名函式。宿主 API 在全域的 `fmp`(FmpHost);`console` 轉到 `fmp.log`。 +// 沒有 fetch、setTimeout、檔案系統,也碰不到其他插件的資料。 +// +// 物件的欄位是封閉的:多出不認得的欄位,宿主整個拒收(ParseError)。選填欄位 +// 可以省略或給 null。 +// +// 與宿主的 Dart 端一致:interface 的欄位對 `manifestShapes`、`sourceDtoShapes`、 +// `hostApiShapes`,FmpHost 對 `js_prelude.dart` 實際建出的 `fmp` +// (app/test/plugins/type_definitions_test.dart 比對)。 + +// ---------------------------------------------------------------- manifest + +export type FmpCapability = + | 'search' + | 'resolveStream' + | 'trackDetail' + | 'multiPart' + | 'importPlaylist' + | 'libraryRead' + | 'libraryWrite' + | 'charts' + | 'live' + | 'mix' + | 'lyrics' + | 'login'; + +export interface FmpPluginManifest { + /** 1–32 個小寫英數與 `-`,不以 `-` 開頭或結尾。 */ + id: string; + name: string; + version: string; + author: string; + /** 必須等於宿主支援的版本。 */ + apiVersion: 1; + /** 不能是空的;每個能力要有同名的匯出函式,反之亦然。 */ + capabilities: FmpCapability[]; + /** + * 可以連的網域:只有小寫 host,涵蓋子網域。請求、串流與封面網址、圖示都 + * 只能在這些網域。 + */ + allowedHosts: string[]; + /** M1 沒有登入,只能省略或 null。 */ + login?: null; + retry?: FmpRetryPolicy | null; + rateLimit?: FmpRateLimitPolicy | null; + /** 追加到宿主的遮蔽名單(只增不減)。 */ + redaction?: FmpRedaction | null; + defaults?: Record | null; + /** `https` 網址(網域在 allowedHosts)或 `data:image/…;base64,`(≤ 64 KiB)。 */ + icon?: string | null; +} + +export interface FmpRetryPolicy { + /** 0–5,預設 2。 */ + maxRetries?: number | null; + baseDelayMs?: number | null; + maxDelayMs?: number | null; + /** 伺服器的 Retry-After 超過它就不重試。 */ + maxRetryAfterMs?: number | null; +} + +export interface FmpRateLimitPolicy { + /** 至少 1。 */ + maxConcurrentRequests: number; + minRequestIntervalMs: number; +} + +export interface FmpRedaction { + headerNames?: string[] | null; + keyNames?: string[] | null; + mediaCdns?: FmpMediaCdn[] | null; +} + +export interface FmpMediaCdn { + host: string; + signedQueryParameters?: string[] | null; + signedPath?: boolean | null; +} + +// ---------------------------------------------------------------- DTO + +export interface SearchQuery { + keyword: string; + /** 從 1 開始。 */ + page: number; +} + +export interface SearchPage { + items: TrackSummary[]; + hasMore: boolean; +} + +export interface TrackSummary { + /** 音源內的 id,不能含 `:`。曲目鍵的第一段(音源)由宿主填成插件 id。 */ + sourceId: string; + /** 分 P 的 cid(非負整數)。 */ + cid?: number | null; + title: string; + uploader?: string | null; + /** 非負整數毫秒。 */ + durationMs?: number | null; + artwork?: Artwork[] | null; +} + +export interface Artwork { + /** `https`,網域在 allowedHosts。 */ + url: string; + width?: number | null; +} + +export interface StreamRequest { + sourceId: string; + cid?: number | null; + purpose: 'playback'; + /** 平台能播的格式;依它挑候選。 */ + formats: StreamFormat[]; +} + +export interface StreamFormat { + container: string; + codec: string; +} + +export interface StreamResult { + /** 依優先序;至少一個,找不到就拋 NotFound 或 Unavailable。 */ + candidates: StreamCandidate[]; +} + +export interface StreamCandidate { + /** `https`(網域在 allowedHosts)或 App 內附的 `asset:///…`。 */ + url: string; + headers?: Record | null; + container?: string | null; + codec?: string | null; + /** 每秒位元數。 */ + bitrate?: number | null; + /** 網址的期限(UTC epoch 毫秒),從網址本身讀。 */ + expiresAt?: number | null; +} + +// ---------------------------------------------------------------- 宿主 API + +export interface HttpRequest { + /** `https`,網域在 allowedHosts;否則拋 Unsupported,不發請求。 */ + url: string; + /** RFC 9110 的大寫方法,預設 GET。只有冪等方法會自動重試。 */ + method?: string | null; + headers?: Record | null; + body?: string | null; + /** 帶不帶登入憑證(ADR 0012),預設 never。 */ + auth?: 'never' | 'userPreference' | 'required' | null; +} + +export interface HttpResponse { + status: number; + /** 跟隨轉址後的網址。 */ + url: string; + /** 名稱小寫。 */ + headers: Record; + /** 以 UTF-8 解碼(不合法的位元組換成 U+FFFD)。 */ + body: string; +} + +export interface FmpHttp { + /** 經宿主的網路層:網域檢查、重試、限流、網路紀錄。429 等錯誤以 FmpError 拋出。 */ + request(request: HttpRequest): Promise; +} + +export interface FmpCrypto { + /** 字串的 UTF-8 位元組,小寫 hex。 */ + md5(text: string): string; + sha256(text: string): string; +} + +/** 這個插件自己的 key/value;移除插件時清除。 */ +export interface FmpStorage { + get(key: string): Promise; + set(key: string, value: string): Promise; + delete(key: string): Promise; +} + +export interface FmpCredentials { + /** 這個插件自己的登入憑證;M1 一律 null。 */ + get(): Promise | null>; +} + +/** 寫進宿主的 log(經遮蔽),tag 是插件 id。 */ +export interface FmpLog { + debug(message: unknown, fields?: Record): void; + info(message: unknown, fields?: Record): void; + warn(message: unknown, fields?: Record): void; + error(message: unknown, fields?: Record): void; +} + +export interface FmpHost { + readonly apiVersion: 1; + readonly http: FmpHttp; + readonly crypto: FmpCrypto; + readonly storage: FmpStorage; + readonly credentials: FmpCredentials; + readonly log: FmpLog; +} + +// ---------------------------------------------------------------- 錯誤 + +export type FmpErrorName = + | 'NetworkError' + | 'RateLimited' + | 'AuthRequired' + | 'CredentialInvalid' + | 'VerificationRequired' + | 'Unavailable' + | 'NotFound' + | 'ParseError' + | 'Unsupported' + | 'UnexpectedError'; + +export type FmpUnavailableReason = + | 'region' + | 'copyright' + | 'membership' + | 'age' + | 'previewOnly'; + +/** + * 結構化錯誤:`throw { fmpError: 'RateLimited', retryAfterSeconds: 5 }`, + * 或在 Error 上加這些屬性。宿主轉成同名的錯誤類別;其他拋出的值都當成插件的 + * bug(UnexpectedError)。宿主 API 拋出的錯誤也是這個形狀,原樣再拋出即可。 + * message 只進 log,使用者看到的是宿主依類別顯示的訊息。 + */ +export interface FmpError { + fmpError: FmpErrorName; + retryAfterSeconds?: number; + /** fmpError 為 Unavailable 時必填。 */ + reason?: FmpUnavailableReason; + message?: string; +} + +// ---------------------------------------------------------------- 匯出 + +/** 插件 module 的匯出:宣告了哪個能力就匯出哪個。 */ +export interface FmpPluginExports { + search?(query: SearchQuery): SearchPage | Promise; + resolveStream?(request: StreamRequest): StreamResult | Promise; +} + +declare global { + const fmp: FmpHost; +} diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc index e71a16d2..36cf8130 100644 --- a/app/linux/flutter/generated_plugin_registrant.cc +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_js_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterJsPlugin"); + flutter_js_plugin_register_with_registrar(flutter_js_registrar); } diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake index be1ee3e5..ff5c5434 100644 --- a/app/linux/flutter/generated_plugins.cmake +++ b/app/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_js ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817a..7c7baa09 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import flutter_js func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FlutterJsPlugin.register(with: registry.registrar(forPlugin: "FlutterJsPlugin")) } diff --git a/app/pubspec.lock b/app/pubspec.lock index 764de84e..c4533e47 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -210,7 +210,7 @@ packages: source: hosted version: "1.15.1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -310,6 +310,19 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_js: + dependency: "direct main" + description: + name: flutter_js + sha256: "046428059ac7bef71e305dc35fc24be933b684684a43e83eab7ebd0dc407ad85" + url: "https://pub.dev" + source: hosted + version: "0.8.7" flutter_lints: dependency: "direct dev" description: @@ -344,6 +357,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -368,6 +386,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -384,6 +410,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: transitive description: @@ -632,6 +663,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.3" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" pub_semver: dependency: transitive description: @@ -797,6 +836,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" talker: dependency: "direct main" description: @@ -925,6 +972,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 04c35dd5..a1e663ad 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -14,6 +14,8 @@ workspace: - packages/fmp_lints dependencies: + # 插件宿主 API 的 crypto.md5/sha256(ADR 0014 §決定 5)。 + crypto: ^3.0.7 # 網路層(ADR 0012 §決定 1):每插件一個 dio 與記憶體 cookie jar。三個都只准 # 在 lib/core/network/ import(fmp_layer_imports)。 cookie_jar: ^4.0.9 @@ -24,6 +26,11 @@ dependencies: drift: ^2.35.0 flutter: sdk: flutter + # 插件的 JS 引擎(ADR 0014 §決定 2),只准在 lib/plugins/runtime/ import + # (fmp_layer_imports)。釘死版本:執行環境繼承 QuickJsRuntime2 並依賴它在 + # 建構子呼叫 init(),測試直接載入套件內附的原生庫(test/support/quickjs.dart), + # 兩者都是套件的內部結構,升版時要重看。 + flutter_js: 0.8.7 # 狀態管理(ADR 0013)。不用 riverpod_generator:provider 少,手寫即可。 flutter_riverpod: ^3.4.3 # Flutter 3.47 起 Material 以獨立套件發佈,app/ 直接用它,不 import @@ -45,6 +52,9 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + # 插件執行環境的實機量測(integration_test/plugin_runtime_benchmark_test.dart)。 + integration_test: + sdk: flutter yaml: ^3.1.4 flutter: @@ -52,3 +62,9 @@ flutter: # 沒帶 --flavor 的 run/build/test 一律是開發版(ADR 0015 §決定 8); # 發版必須明確帶 --flavor prod。 default-flavor: dev + # 測試插件(ADR 0015 §決定 6)只打包進開發版:實機驗證與量測從 asset 讀 + # 腳本,它的串流指向同目錄的音檔(asset:///…)。 + assets: + - path: test/fixtures/plugins/test_plugin/ + flavors: + - dev diff --git a/app/test/app/fmp_app_test.dart b/app/test/app/fmp_app_test.dart index 30a04441..e79bd32c 100644 --- a/app/test/app/fmp_app_test.dart +++ b/app/test/app/fmp_app_test.dart @@ -4,21 +4,61 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fmp/app/app_scope.dart'; import 'package:fmp/app/fmp_app.dart'; import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/core/core_providers.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/redaction/redactor.dart'; +import 'package:fmp/data/providers.dart'; +import 'package:fmp/data/repositories/plugin_repository.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; +import '../plugins/plugin_harness.dart'; +import '../support/memory_database.dart'; + void main() { - testWidgets('shows the app name, flavor and data directory', (tester) async { + testWidgets('shows the app name, flavor, data directory and plugins', ( + tester, + ) async { + final database = memoryDatabase(); + await tester.runAsync( + () => PluginRepository(database).install( + InstalledPlugin( + id: 'fmp-test', + version: '1.0.0', + manifestJson: '{}', + script: testPluginFile.readAsStringSync(), + installedAt: DateTime.utc(2026, 9, 30), + ), + ), + ); + final redactor = Redactor(); + await tester.pumpWidget( appProviderScope( overrides: [ dataDirectoryProvider.overrideWithValue(Directory('/data/fmp-dev')), + appDatabaseProvider.overrideWithValue(database), + redactorProvider.overrideWithValue(redactor), + logProvider.overrideWithValue( + Log(redactor: redactor, minimumLevel: LogLevel.debug), + ), ], child: const FmpApp(flavor: AppFlavor.dev), ), ); + // 插件在背景 isolate 載入:spawn 與 port 的訊息要真的事件迴圈,所以在 + // runAsync 裡讓它跑,直到清單出現。 + final plugin = find.text('fmp-test 1.0.0'); + for (var round = 0; round < 100 && plugin.evaluate().isEmpty; round++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + } expect(find.text('FMP Dev'), findsOneWidget); expect(find.text('dev'), findsOneWidget); expect(find.text('/data/fmp-dev'), findsOneWidget); + expect(plugin, findsOneWidget); }); } diff --git a/app/test/fixtures/plugins/test_plugin/README.md b/app/test/fixtures/plugins/test_plugin/README.md new file mode 100644 index 00000000..ea54e0cb --- /dev/null +++ b/app/test/fixtures/plugins/test_plugin/README.md @@ -0,0 +1,20 @@ +# 測試插件 `fmp-test` + +ADR 0015 §決定 6 的測試插件:合成資料、不連網。執行環境的測試、實機驗證與量測 +都用它;它與旁邊的音檔以 dev flavor 的 asset 打包(`pubspec.yaml` 的 +`flutter.assets`),prod 不含。 + +- `test_plugin.js`:安裝檔(標頭 manifest + ES module),能力 `search`、 + `resolveStream`。 +- `tone.wav`:2 秒 440 Hz 正弦波,16 kHz 單聲道 16-bit PCM,64 044 bytes。 + +## `tone.wav` 的來源與授權 + +2026-09-30 以 FFmpeg 9.0 的 `lavfi` 合成,沒有取材任何既有錄音: + +```sh +ffmpeg -f lavfi -i "sine=frequency=440:duration=2:sample_rate=16000" \ + -ac 1 -c:a pcm_s16le -map_metadata -1 -fflags +bitexact -flags:a +bitexact tone.wav +``` + +以 [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) 釋出至公有領域。 diff --git a/app/test/fixtures/plugins/test_plugin/test_plugin.js b/app/test/fixtures/plugins/test_plugin/test_plugin.js new file mode 100644 index 00000000..e95a6ea5 --- /dev/null +++ b/app/test/fixtures/plugins/test_plugin/test_plugin.js @@ -0,0 +1,50 @@ +/* ==FMP Plugin== +{ + "id": "fmp-test", + "name": "FMP Test Plugin", + "version": "1.0.0", + "author": "FMP", + "apiVersion": 1, + "capabilities": ["search", "resolveStream"], + "allowedHosts": [] +} +==/FMP Plugin== */ + +// FMP 的測試插件(ADR 0015 §決定 6):合成資料、不發任何網路請求。串流指向 +// App 內附的音檔(dev flavor 的 asset),給執行環境的測試、實機驗證與量測用。 + +const TONES = [220, 440, 880]; +const PAGE_SIZE = 2; +const TONE_ASSET = 'asset:///test/fixtures/plugins/test_plugin/tone.wav'; + +// 標題的前綴可以用 storage 改(`titlePrefix`),順便走一次非同步的宿主函式。 +export async function search(query) { + const prefix = (await fmp.storage.get('titlePrefix')) ?? 'Test tone'; + const start = (query.page - 1) * PAGE_SIZE; + const items = TONES.slice(start, start + PAGE_SIZE).map((hz) => ({ + sourceId: `tone-${hz}`, + title: `${prefix} ${hz} Hz (${query.keyword})`, + uploader: 'FMP', + durationMs: 2000, + artwork: [], + })); + return { items, hasMore: start + PAGE_SIZE < TONES.length }; +} + +export async function resolveStream(request) { + if (!/^tone-\d+$/.test(request.sourceId)) { + throw { fmpError: 'NotFound', message: `no such tone: ${request.sourceId}` }; + } + // 內附的音檔只有 440 Hz 一個;其他音高也回它。 + return { + candidates: [ + { + url: TONE_ASSET, + container: 'wav', + codec: 'pcm_s16le', + bitrate: 256000, + expiresAt: null, + }, + ], + }; +} diff --git a/app/test/fixtures/plugins/test_plugin/tone.wav b/app/test/fixtures/plugins/test_plugin/tone.wav new file mode 100644 index 00000000..889e21c5 Binary files /dev/null and b/app/test/fixtures/plugins/test_plugin/tone.wav differ diff --git a/app/test/flutter_test_config.dart b/app/test/flutter_test_config.dart index 5d443b00..a269a03c 100644 --- a/app/test/flutter_test_config.dart +++ b/app/test/flutter_test_config.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'support/quickjs.dart'; + /// 測試預設零聯網的第二道防線(ADR 0015 §決定 3;第一道是 `dart_test.yaml` /// 把 `live` tag 設成跳過)。 /// @@ -15,6 +17,8 @@ import 'package:flutter_test/flutter_test.dart'; Future testExecutable(FutureOr Function() testMain) async { TestWidgetsFlutterBinding.ensureInitialized(); HttpOverrides.global = NoNetworkHttpOverrides(); + // 插件執行環境的測試要真的跑 QuickJS(見 support/quickjs.dart)。 + loadQuickJsForTests(); await testMain(); } diff --git a/app/test/plugins/install/plugin_installer_test.dart b/app/test/plugins/install/plugin_installer_test.dart new file mode 100644 index 00000000..63decfb3 --- /dev/null +++ b/app/test/plugins/install/plugin_installer_test.dart @@ -0,0 +1,263 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/core/core_providers.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/data/providers.dart'; +import 'package:fmp/data/repositories/plugin_repository.dart'; +import 'package:fmp/plugins/install/dev_plugin_entry.dart'; +import 'package:fmp/plugins/install/plugin_installer.dart'; +import 'package:fmp/plugins/plugin_registry.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:fmp/plugins/source_plugin.dart'; + +import '../../support/pump_until.dart'; +import '../plugin_harness.dart'; + +/// 與 App 同一套 provider;資料庫、log、HTTP、載入器(含逾時)用 [harness] 的。重試照 App 關閉 +/// (`appProviderScope`,ADR 0013 §決定 4)。 +ProviderContainer _container(PluginHarness harness, {String? devPluginPath}) { + final container = ProviderContainer( + retry: (_, _) => null, + overrides: [ + appDatabaseProvider.overrideWithValue(harness.database), + logProvider.overrideWithValue(harness.log), + redactorProvider.overrideWithValue(harness.redactor), + sourceHttpClientFactoryProvider.overrideWithValue(harness.httpClients), + scriptPluginLoaderProvider.overrideWithValue(harness.loader), + devPluginPathProvider.overrideWithValue(devPluginPath), + ], + ); + addTearDown(container.dispose); + return container; +} + +String _version(String version, {String body = ''}) => pluginSource( + 'export function search() { return { items: [], hasMore: false }; }' + '\n$body', +).replaceFirst('"version": "1.0.0"', '"version": "$version"'); + +void main() { + test('installs, stores and registers a plugin', () async { + final harness = PluginHarness(); + final container = _container(harness); + + final plugin = await container + .read(pluginInstallerProvider) + .installBytes(testPluginFile.readAsBytesSync()); + + final stored = (await harness.plugins.byId('fmp-test'))!; + expect(stored.version, '1.0.0'); + expect(stored.script, testPluginFile.readAsStringSync()); + expect(jsonDecode(stored.manifestJson), containsPair('id', 'fmp-test')); + final registered = await container.read(pluginRegistryProvider.future); + expect(registered.keys, ['fmp-test']); + expect(identical(registered['fmp-test'], plugin), isTrue); + final page = await plugin.search(SearchQuery(keyword: 'x')); + expect(page.items, isNotEmpty); + }); + + test('the registry loads installed plugins at start', () async { + final harness = PluginHarness(); + await _container(harness) + .read(pluginInstallerProvider) + .installSource(_version('1.0.0')); + + final registered = await _container(harness) + .read(pluginRegistryProvider.future); + + expect(registered.keys, ['plugin-a']); + }); + + test( + 'a stored plugin that no longer loads is reported and skipped', + () async { + final harness = PluginHarness(); + await _container(harness) + .read(pluginInstallerProvider) + .installSource(_version('1.0.0')); + await harness.plugins.install( + InstalledPlugin( + id: 'broken', + version: '1', + manifestJson: '{}', + script: 'not a plugin file', + installedAt: DateTime.utc(2026, 9, 30), + ), + ); + + final registered = await _container(harness) + .read(pluginRegistryProvider.future); + + expect(registered.keys, ['plugin-a']); + expect( + harness.records('plugins').single.message, + 'Failed to load an installed plugin', + ); + }, + ); + + test('installing the same id updates it and keeps its storage', () async { + final harness = PluginHarness(); + final container = _container(harness); + final installer = container.read(pluginInstallerProvider); + final old = await installer.installSource(_version('1.0.0')); + await harness.storage.write('plugin-a', 'k', 'kept'); + + final updated = await installer.installSource(_version('2.0.0')); + + expect((await harness.plugins.byId('plugin-a'))!.version, '2.0.0'); + expect(await harness.storage.read('plugin-a', 'k'), 'kept'); + final registered = await container.read(pluginRegistryProvider.future); + expect(identical(registered['plugin-a'], updated), isTrue); + // 被取代的 runtime 已關閉。 + await expectLater( + old.search(SearchQuery(keyword: 'x')), + throwsA(isA()), + ); + }); + + test('a plugin that fails to load leaves nothing behind', () async { + final harness = PluginHarness(); + final container = _container(harness); + + await expectLater( + container + .read(pluginInstallerProvider) + .installSource( + pluginSource('export function other() {}'), // search 沒匯出 + ), + throwsA(isA()), + ); + + expect(await harness.plugins.list(), isEmpty); + expect(await container.read(pluginRegistryProvider.future), isEmpty); + }); + + test('plugins registered at the same time are all listed', () async { + final harness = PluginHarness(); + final container = _container(harness); + const body = + 'export function search() { return { items: [], hasMore: false }; }'; + final a = await harness.load(pluginSource(body), installed: false); + final b = await harness.load( + pluginSource(body, id: 'plugin-b'), + installed: false, + ); + final registry = container.read(pluginRegistryProvider.notifier); + + await Future.wait([registry.register(a), registry.register(b)]); + + final registered = await container.read(pluginRegistryProvider.future); + expect(registered.keys, unorderedEquals(['plugin-a', 'plugin-b'])); + }); + + test('a plugin that stops responding stays listed as unresponsive', () async { + final harness = PluginHarness( + callTimeout: const Duration(milliseconds: 500), + livenessGrace: const Duration(milliseconds: 300), + ); + final container = _container(harness); + final plugin = await container + .read(pluginInstallerProvider) + .installSource( + pluginSource('export function search() { while (true) {} }'), + ); + var emitted = 0; + container.listen(pluginRegistryProvider, (_, _) => emitted++); + + await expectLater( + plugin.search(SearchQuery(keyword: 'x')), + throwsA(isA()), + ); + await plugin.whenUnresponsive; + await pumpUntil(() => emitted > 0); + + final registered = await container.read(pluginRegistryProvider.future); + expect(registered['plugin-a']!.health, PluginHealth.unresponsive); + }); + + group('development entry', () { + test('reads the path from the argument, then the environment', () { + expect( + devPluginPath( + AppFlavor.dev, + ['--verbose', '--fmp-dev-plugin=C:/p/a.js'], + {'FMP_DEV_PLUGIN': '/b.js'}, + ), + 'C:/p/a.js', + ); + expect( + devPluginPath(AppFlavor.dev, const [], {'FMP_DEV_PLUGIN': '/b.js'}), + '/b.js', + ); + expect( + devPluginPath(AppFlavor.dev, const ['--fmp-dev-plugin='], const {}), + isNull, + ); + expect( + devPluginPath(AppFlavor.dev, const [], const {'FMP_DEV_PLUGIN': ''}), + isNull, + ); + }); + + test('prod reads neither the argument nor the environment', () { + expect( + devPluginPath( + AppFlavor.prod, + ['--fmp-dev-plugin=C:/p/a.js'], + {'FMP_DEV_PLUGIN': '/b.js'}, + ), + isNull, + ); + }); + + test('installs the file at the path', () async { + final harness = PluginHarness(); + final container = _container(harness, devPluginPath: testPluginFile.path); + + final plugin = await container.read(devPluginInstallProvider.future); + + expect(plugin!.manifest.id, 'fmp-test'); + expect((await container.read(pluginRegistryProvider.future)).keys, [ + 'fmp-test', + ]); + }); + + test('does nothing without a path', () async { + final container = _container(PluginHarness()); + + expect(await container.read(devPluginInstallProvider.future), isNull); + }); + + test('reports a file that cannot be read', () async { + final harness = PluginHarness(); + final missing = '${Directory.systemTemp.path}/fmp-missing-plugin.js'; + final container = _container(harness, devPluginPath: missing); + + await expectLater( + container.read(devPluginInstallProvider.future), + throwsA(isA()), + ); + expect( + harness.records('plugins').single.message, + 'Failed to install the development plugin', + ); + }); + }); + + test('bytes that are not UTF-8 are a ParseError', () async { + final container = _container(PluginHarness()); + + await expectLater( + container + .read(pluginInstallerProvider) + .installBytes(Uint8List.fromList([0xFF])), + throwsA(isA()), + ); + }); +} diff --git a/app/test/plugins/manifest/plugin_file_test.dart b/app/test/plugins/manifest/plugin_file_test.dart new file mode 100644 index 00000000..64583499 --- /dev/null +++ b/app/test/plugins/manifest/plugin_file_test.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/plugins/manifest/plugin_file.dart'; + +import '../plugin_harness.dart'; + +void main() { + test('reads the manifest from the header without running the script', () { + // 腳本本身語法錯誤也讀得到 manifest:標頭的解析不執行腳本。 + final file = PluginFile.parse(pluginSource('this is not javascript (((')); + + expect(file.manifest.id, 'plugin-a'); + expect(file.manifestJson, startsWith('{')); + expect(file.manifestJson, endsWith('}')); + expect(file.source, contains('this is not javascript')); + }); + + test('reads the test plugin', () { + final file = PluginFile.parse(testPluginFile.readAsStringSync()); + + expect(file.manifest.id, 'fmp-test'); + expect(file.manifest.allowedHosts, isEmpty); + }); + + test('allows a BOM and leading blank lines', () { + final file = PluginFile.decode( + Uint8List.fromList([ + 0xEF, 0xBB, 0xBF, // + ...utf8.encode('\n\n${pluginSource('')}'), + ]), + ); + + expect(file.manifest.id, 'plugin-a'); + expect(file.source, isNot(startsWith(''))); + }); + + group('rejects as ParseError', () { + for (final (description, source) in [ + ('no header', 'export function search() {}'), + ('code before the header', '// hi\n${pluginSource('')}'), + ( + 'a header without its end', + '/* ==FMP Plugin==\n{"id": "a"}\n*/ export function search() {}', + ), + ( + 'a manifest containing */', + pluginSource('').replaceFirst('"Plugin plugin-a"', '"a */ b"'), + ), + ]) { + test(description, () { + expect(() => PluginFile.parse(source), throwsA(isA())); + }); + } + + test('bytes that are not UTF-8', () { + expect( + () => PluginFile.decode(Uint8List.fromList([0xFF, 0xFE, 0x00])), + throwsA(isA()), + ); + }); + }); + + test('manifest errors come through unchanged', () { + expect( + () => PluginFile.parse( + pluginSource('').replaceFirst('"apiVersion": 1', '"apiVersion": 9'), + ), + throwsA(isA()), + ); + }); +} diff --git a/app/test/plugins/manifest/plugin_manifest_test.dart b/app/test/plugins/manifest/plugin_manifest_test.dart new file mode 100644 index 00000000..813901d4 --- /dev/null +++ b/app/test/plugins/manifest/plugin_manifest_test.dart @@ -0,0 +1,298 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/errors/retry_policy.dart'; +import 'package:fmp/plugins/manifest/plugin_manifest.dart'; + +/// 最小的合法 manifest;[changes] 覆蓋或加欄位,值為 `#remove` 就拿掉。 +String manifest([Map changes = const {}]) { + final json = { + 'id': 'plugin-a', + 'name': 'Plugin A', + 'version': '1.0.0', + 'author': 'FMP tests', + 'apiVersion': 1, + 'capabilities': ['search'], + 'allowedHosts': ['example.test'], + }; + for (final MapEntry(:key, :value) in changes.entries) { + if (value == '#remove') { + json.remove(key); + } else { + json[key] = value; + } + } + return jsonEncode(json); +} + +Matcher _rejectedAs() => throwsA(isA()); + +void main() { + test('parses the required fields', () { + final parsed = PluginManifest.parse(manifest()); + + expect(parsed.id, 'plugin-a'); + expect(parsed.name, 'Plugin A'); + expect(parsed.version, '1.0.0'); + expect(parsed.author, 'FMP tests'); + expect(parsed.capabilities, {PluginCapability.search}); + expect(parsed.allowedHosts, ['example.test']); + expect(parsed.retryPolicy, isNull); + expect(parsed.rateLimitPolicy, isNull); + expect(parsed.defaults, isEmpty); + expect(parsed.icon, isNull); + }); + + test('parses the optional fields', () { + final parsed = PluginManifest.parse( + manifest({ + 'capabilities': ['search', 'resolveStream'], + 'allowedHosts': ['example.test', 'cdn.example'], + 'login': null, + 'retry': {'maxRetries': 1, 'baseDelayMs': 250}, + 'rateLimit': {'maxConcurrentRequests': 2, 'minRequestIntervalMs': 300}, + 'redaction': { + 'keyNames': ['sid'], + 'mediaCdns': [ + { + 'host': 'cdn.example', + 'signedQueryParameters': ['sig'], + }, + ], + }, + 'defaults': {'quality': 'high'}, + 'icon': 'https://cdn.example/icon.png', + }), + ); + + expect(parsed.capabilities, { + PluginCapability.search, + PluginCapability.resolveStream, + }); + expect(parsed.retryPolicy!.maxRetries, 1); + expect(parsed.retryPolicy!.baseDelay, const Duration(milliseconds: 250)); + expect(parsed.retryPolicy!.maxDelay, const RetryPolicy().maxDelay); + expect(parsed.rateLimitPolicy!.maxConcurrentRequests, 2); + expect( + parsed.rateLimitPolicy!.minRequestInterval, + const Duration(milliseconds: 300), + ); + expect(parsed.redaction.keyNames, ['sid']); + expect(parsed.redaction.mediaCdns.single.host, 'cdn.example'); + expect(parsed.redaction.mediaCdns.single.signedQueryParameters, {'sig'}); + expect(parsed.defaults, {'quality': 'high'}); + expect(parsed.icon, Uri.parse('https://cdn.example/icon.png')); + }); + + test('knows the twelve capabilities of ADR 0014', () { + final names = [for (final c in PluginCapability.values) c.wireName]; + + expect(names, [ + 'search', + 'resolveStream', + 'trackDetail', + 'multiPart', + 'importPlaylist', + 'libraryRead', + 'libraryWrite', + 'charts', + 'live', + 'mix', + 'lyrics', + 'login', + ]); + expect( + PluginManifest.parse(manifest({'capabilities': names})).capabilities, + PluginCapability.values.toSet(), + ); + }); + + test('accepts a data icon', () { + final icon = 'data:image/png;base64,${base64.encode([1, 2, 3])}'; + + expect( + PluginManifest.parse(manifest({'icon': icon})).icon.toString(), + icon, + ); + }); + + group('apiVersion', () { + test('another version is Unsupported, whatever its other fields', () { + expect( + () => PluginManifest.parse( + jsonEncode({'id': 'plugin-a', 'apiVersion': 2, 'newField': true}), + ), + throwsA( + isA().having((e) => e.pluginId, 'pluginId', 'plugin-a'), + ), + ); + expect( + () => PluginManifest.parse(manifest({'apiVersion': 0})), + _rejectedAs(), + ); + }); + + test('a missing or non-integer version is a ParseError', () { + expect( + () => PluginManifest.parse(manifest({'apiVersion': '#remove'})), + _rejectedAs(), + ); + expect( + () => PluginManifest.parse(manifest({'apiVersion': '1'})), + _rejectedAs(), + ); + }); + }); + + group('rejects as Unsupported', () { + for (final (description, changes) in [ + ( + 'an unknown capability', + { + 'capabilities': ['search', 'teleport'], + }, + ), + ('a login method (not in M1)', {'login': 'cookie'}), + ]) { + test(description, () { + expect( + () => PluginManifest.parse(manifest(changes)), + _rejectedAs(), + ); + }); + } + }); + + group('rejects as ParseError', () { + test('text that is not JSON or not an object', () { + expect(() => PluginManifest.parse('{'), _rejectedAs()); + expect(() => PluginManifest.parse('[]'), _rejectedAs()); + }); + + for (final field in [ + 'id', + 'name', + 'version', + 'author', + 'capabilities', + 'allowedHosts', + ]) { + test('a missing $field', () { + expect( + () => PluginManifest.parse(manifest({field: '#remove'})), + _rejectedAs(), + ); + }); + } + + for (final (description, changes) in <(String, Map)>[ + ('an unknown field', {'homepage': 'x'}), + ('a wrong type', {'name': 1}), + ('an empty name', {'name': ' '}), + ('no capabilities', {'capabilities': []}), + ( + 'a capability listed twice', + { + 'capabilities': ['search', 'search'], + }, + ), + ('an id with upper case', {'id': 'Plugin-A'}), + ('an id with a colon', {'id': 'plugin:a'}), + ('an id ending with -', {'id': 'plugin-'}), + ('an id longer than 32', {'id': 'a' * 33}), + ( + 'a host with a scheme', + { + 'allowedHosts': ['https://example.test'], + }, + ), + ( + 'a host with a path', + { + 'allowedHosts': ['example.test/api'], + }, + ), + ( + 'a host with a port', + { + 'allowedHosts': ['example.test:443'], + }, + ), + ( + 'a wildcard host', + { + 'allowedHosts': ['*.example.test'], + }, + ), + ( + 'a bare top-level domain', + { + 'allowedHosts': ['com'], + }, + ), + ( + 'an IP address', + { + 'allowedHosts': ['10.0.0.1'], + }, + ), + ( + 'an upper-case host', + { + 'allowedHosts': ['Example.test'], + }, + ), + ( + 'too many retries', + { + 'retry': {'maxRetries': 6}, + }, + ), + ( + 'a negative delay', + { + 'retry': {'baseDelayMs': -1}, + }, + ), + ( + 'a fractional delay', + { + 'retry': {'baseDelayMs': 1.5}, + }, + ), + ( + 'an unknown retry field', + { + 'retry': {'jitter': true}, + }, + ), + ( + 'a rate limit without its interval', + { + 'rateLimit': {'maxConcurrentRequests': 1}, + }, + ), + ( + 'a rate limit of zero requests', + { + 'rateLimit': {'maxConcurrentRequests': 0, 'minRequestIntervalMs': 0}, + }, + ), + ('an icon on another host', {'icon': 'https://evil.test/icon.png'}), + ('an http icon', {'icon': 'http://example.test/icon.png'}), + ( + 'a data icon that is not an image', + {'icon': 'data:text/html;base64,PGI+'}, + ), + ('defaults that are not an object', {'defaults': 'x'}), + ]) { + test(description, () { + expect( + () => PluginManifest.parse(manifest(changes)), + _rejectedAs(), + ); + }); + } + }); +} diff --git a/app/test/plugins/plugin_harness.dart b/app/test/plugins/plugin_harness.dart new file mode 100644 index 00000000..a8d4402f --- /dev/null +++ b/app/test/plugins/plugin_harness.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/network/source_http_client.dart'; +import 'package:fmp/core/redaction/redactor.dart'; +import 'package:fmp/data/database/app_database.dart'; +import 'package:fmp/data/repositories/plugin_repository.dart'; +import 'package:fmp/data/repositories/plugin_storage_repository.dart'; +import 'package:fmp/plugins/manifest/plugin_file.dart'; +import 'package:fmp/plugins/runtime/plugin_host.dart'; +import 'package:fmp/plugins/runtime/plugin_runtime.dart'; +import 'package:fmp/plugins/runtime/plugin_worker.dart'; +import 'package:fmp/plugins/script_source_plugin.dart'; + +import '../support/fake_http_adapter.dart'; +import '../support/memory_database.dart'; + +/// 測試插件的安裝檔(`test/fixtures/plugins/test_plugin/`)。 +final testPluginFile = File('test/fixtures/plugins/test_plugin/test_plugin.js'); + +/// 組一個安裝檔:標頭加上 [body](ES module)。 +String pluginSource( + String body, { + String id = 'plugin-a', + List capabilities = const ['search'], + List allowedHosts = const ['example.test'], +}) => + ''' +/* ==FMP Plugin== +{ + "id": "$id", + "name": "Plugin $id", + "version": "1.0.0", + "author": "FMP tests", + "apiVersion": 1, + "capabilities": [${capabilities.map((c) => '"$c"').join(', ')}], + "allowedHosts": [${allowedHosts.map((h) => '"$h"').join(', ')}] +} +==/FMP Plugin== */ +$body +'''; + +/// 插件的執行環境加上記憶體資料庫、假 HTTP adapter(不聯網)、log 與遮蔽 +/// 函式。建立的 runtime 與插件在測試結束時釋放。 +final class PluginHarness { + PluginHarness({ + FutureOr Function(RequestOptions options)? handler, + this.callTimeout = defaultPluginCallTimeout, + this.livenessGrace = defaultLivenessGrace, + }) : adapter = FakeHttpAdapter(handler ?? (_) => reply(200)) { + log = Log(redactor: redactor, minimumLevel: LogLevel.debug); + database = memoryDatabase(); + plugins = PluginRepository(database); + storage = PluginStorageRepository(database); + // 重試的等待立刻完成。 + httpClients = SourceHttpClientFactory( + log: log, + createAdapter: () => adapter, + wait: (_) async {}, + random: math.Random(7), + ); + loader = ScriptPluginLoader( + log: log, + redactor: redactor, + httpClients: httpClients, + storage: storage, + callTimeout: callTimeout, + livenessGrace: livenessGrace, + ); + } + + final FakeHttpAdapter adapter; + final Duration callTimeout; + final Duration livenessGrace; + final redactor = Redactor(); + late final Log log; + late final AppDatabase database; + late final PluginRepository plugins; + late final PluginStorageRepository storage; + late final SourceHttpClientFactory httpClients; + late final ScriptPluginLoader loader; + + /// 在 runtime 載入 [script](只有 module,沒有標頭),網域清單是 + /// [allowedHosts];[entryPoint] 換掉背景 isolate 的進入點。[installed] 為真時先在 `installed_plugins` 放一列, + /// storage 才寫得進去(外鍵)。 + Future runtime( + String script, { + String pluginId = 'plugin-a', + List allowedHosts = const ['example.test'], + bool installed = true, + PluginWorkerEntry entryPoint = pluginWorkerMain, + }) async { + if (installed) await install(pluginId); + final host = PluginHost( + pluginId: pluginId, + http: httpClients.create(pluginId: pluginId, allowedHosts: allowedHosts), + storage: storage, + log: log, + ); + final runtime = await PluginRuntime.start( + pluginId: pluginId, + script: script, + host: host, + log: log, + callTimeout: callTimeout, + livenessGrace: livenessGrace, + entryPoint: entryPoint, + ); + addTearDown(runtime.dispose); + return runtime; + } + + /// 以 [ScriptPluginLoader] 載入安裝檔 [source];[installed] 同 [runtime]。 + Future load( + String source, { + bool installed = true, + }) async { + final file = PluginFile.parse(source); + if (installed) await install(file.manifest.id); + final plugin = await loader.load(file); + addTearDown(plugin.close); + return plugin; + } + + /// 在 `installed_plugins` 放一列(內容不重要,只為了外鍵)。 + Future install(String pluginId) => plugins.install( + InstalledPlugin( + id: pluginId, + version: '1.0.0', + manifestJson: '{}', + script: '', + installedAt: DateTime.utc(2026, 9, 30), + ), + ); + + /// 記憶體歷史裡 tag 為 [tag] 的紀錄。 + List records(String tag) => [ + for (final record in log.history) + if (record.tag == tag) record, + ]; +} diff --git a/app/test/plugins/runtime/plugin_runtime_test.dart b/app/test/plugins/runtime/plugin_runtime_test.dart new file mode 100644 index 00000000..5318a0dd --- /dev/null +++ b/app/test/plugins/runtime/plugin_runtime_test.dart @@ -0,0 +1,640 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/network/source_http_client.dart'; +import 'package:fmp/plugins/runtime/worker_protocol.dart'; +import 'package:fmp/plugins/source_plugin.dart'; + +import '../../support/fake_http_adapter.dart'; +import '../../support/pump_until.dart'; +import '../plugin_harness.dart'; + +/// QuickJS 的標準內建(ES2020,QuickJS 的 `JS_NewContext`;沒有 BigInt)。 +const _builtIns = { + 'AggregateError', 'Array', 'ArrayBuffer', 'Boolean', 'DataView', 'Date', // + 'Error', 'EvalError', 'Float32Array', 'Float64Array', 'Function', + 'Infinity', 'Int16Array', 'Int32Array', 'Int8Array', 'InternalError', + 'JSON', 'Map', 'Math', 'NaN', 'Number', 'Object', 'Promise', 'Proxy', + 'RangeError', 'ReferenceError', 'Reflect', 'RegExp', 'Set', + 'SharedArrayBuffer', 'String', 'Symbol', 'SyntaxError', 'TypeError', + 'URIError', 'Uint16Array', 'Uint32Array', 'Uint8Array', + 'Uint8ClampedArray', 'WeakMap', 'WeakSet', '__date_clock', 'decodeURI', + 'decodeURIComponent', 'encodeURI', 'encodeURIComponent', 'escape', 'eval', + 'globalThis', 'isFinite', 'isNaN', 'parseFloat', 'parseInt', 'undefined', + 'unescape', +}; + +void main() { + group('engine', () { + test('runs an ES module in QuickJS and returns JSON', () async { + final runtime = await PluginHarness().runtime(''' +export function echo(value) { return { echoed: value, sum: 1 + 2 }; } +export const notAFunction = 1; +'''); + + expect(runtime.exports, {'echo'}); + expect(await runtime.invoke('echo', {'a': 'b'}), { + 'echoed': {'a': 'b'}, + 'sum': 3, + }); + }); + + test('the global object has only the built-ins, fmp and console', () async { + final runtime = await PluginHarness().runtime(''' +export function globals() { return Object.getOwnPropertyNames(globalThis); } +'''); + + final names = (await runtime.invoke('globals', null) as List).toSet(); + + // flutter_js 自己的 setTimeout、sendMessage、fetch 都不在。 + expect(names.difference(_builtIns), {'fmp', 'console'}); + }); + + test('fmp cannot be replaced or changed', () async { + final runtime = await PluginHarness().runtime(''' +export function tamper() { + const results = []; + for (const attempt of [ + () => { globalThis.fmp = {}; }, + () => { fmp.http = {}; }, + () => { fmp.http.request = () => 1; }, + ]) { + try { attempt(); results.push('changed'); } catch (e) { results.push(e.name); } + } + return results; +} +'''); + + expect(await runtime.invoke('tamper', null), [ + 'TypeError', + 'TypeError', + 'TypeError', + ]); + }); + + test('runtimes do not share globals', () async { + final harness = PluginHarness(); + final a = await harness.runtime(''' +export function set() { globalThis.secret = 'a'; return typeof secret; } +'''); + final b = await harness.runtime(''' +export function get() { return typeof secret; } +''', pluginId: 'plugin-b'); + + expect(await a.invoke('set', null), 'string'); + expect(await b.invoke('get', null), 'undefined'); + }); + + test('a plugin cannot import other modules', () async { + final runtime = await PluginHarness().runtime(''' +export async function load(name) { + try { await import(name); return 'loaded'; } catch (e) { return 'rejected'; } +} +'''); + + expect(await runtime.invoke('load', 'fmp-host'), 'rejected'); + expect(await runtime.invoke('load', './other.js'), 'rejected'); + }); + + test('invoking a function that is not exported is a bug', () async { + final runtime = await PluginHarness().runtime('export function a() {}'); + + expect(() => runtime.invoke('b', null), throwsArgumentError); + }); + + test('an argument that is not JSON leaves no call waiting', () async { + final runtime = await PluginHarness().runtime('export function a() {}'); + + await expectLater( + runtime.invoke('a', Object()), + throwsA(isA()), + ); + // 留下等結果的呼叫的話,dispose 會讓它以沒人接的錯誤結束。 + runtime.dispose(); + await settle(); + }); + }); + + group('host API', () { + test('http.request goes through the network layer', () async { + final harness = PluginHarness( + handler: (options) => reply( + 200, + body: '{"q":"${options.uri.queryParameters['q']}"}', + headers: {'Content-Type': 'application/json'}, + ), + ); + final runtime = await harness.runtime(''' +export async function fetchIt() { + const response = await fmp.http.request({ + url: 'https://api.example.test/search?q=tone', + headers: { Referer: 'https://example.test/' }, + }); + return { + status: response.status, + body: JSON.parse(response.body), + contentType: response.headers['content-type'], + }; +} +'''); + + expect(await runtime.invoke('fetchIt', null), { + 'status': 200, + 'body': {'q': 'tone'}, + 'contentType': ['application/json'], + }); + final sent = harness.adapter.requests.single; + expect(sent.method, 'GET'); + expect(sent.headers['Referer'], 'https://example.test/'); + final record = harness.records(networkLogTag).single; + expect(record.fields['pluginId'], 'plugin-a'); + }); + + test('a host outside the manifest is refused without a request', () async { + final harness = PluginHarness(); + final runtime = await harness.runtime(''' +export async function leak() { + await fmp.http.request({ url: 'https://evil.test/' }); +} +export async function caught() { + try { + await fmp.http.request({ url: 'https://evil.test/' }); + } catch (e) { + return [e.fmpError, e instanceof Error]; + } +} +'''); + + await expectLater( + runtime.invoke('leak', null), + throwsA(isA()), + ); + expect(await runtime.invoke('caught', null), ['Unsupported', true]); + expect(harness.adapter.requests, isEmpty); + }); + + test('a host error thrown on keeps its network record', () async { + final harness = PluginHarness( + handler: (options) => throw DioException.connectionError( + requestOptions: options, + reason: 'refused', + ), + ); + final runtime = await harness.runtime(''' +export async function fetchIt() { + await fmp.http.request({ url: 'https://example.test/' }); +} +'''); + + final error = await runtime + .invoke('fetchIt', null) + .then( + (_) => fail('expected an error'), + onError: (Object error) => error as AppError, + ); + + expect(error, isA()); + expect(error.pluginId, 'plugin-a'); + // 預設重試兩次;帶的是最後一次送出的紀錄 id。 + expect( + error.networkRecordId, + harness.records(networkLogTag).last.fields['id'], + ); + }); + + test('storage belongs to one plugin', () async { + final harness = PluginHarness(); + const script = ''' +export async function write(value) { await fmp.storage.set('k', value); } +export async function read() { return fmp.storage.get('k'); } +export async function remove() { await fmp.storage.delete('k'); } +'''; + final a = await harness.runtime(script); + final b = await harness.runtime(script, pluginId: 'plugin-b'); + + await a.invoke('write', 'from a'); + expect(await a.invoke('read', null), 'from a'); + expect(await b.invoke('read', null), isNull); + expect(await harness.storage.read('plugin-a', 'k'), 'from a'); + expect(await harness.storage.read('plugin-b', 'k'), isNull); + + await a.invoke('remove', null); + expect(await a.invoke('read', null), isNull); + }); + + test('crypto hashes the UTF-8 bytes to lowercase hex', () async { + final runtime = await PluginHarness().runtime(''' +export function hashes() { + return [fmp.crypto.md5(''), fmp.crypto.md5('中文'), fmp.crypto.sha256('abc')]; +} +'''); + + expect(await runtime.invoke('hashes', null), [ + 'd41d8cd98f00b204e9800998ecf8427e', + 'a7bac2239fcdcb3a067903d8077c4a07', + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ]); + }); + + test('credentials are empty in M1', () async { + final runtime = await PluginHarness().runtime(''' +export async function credentials() { return fmp.credentials.get(); } +'''); + + expect(await runtime.invoke('credentials', null), isNull); + }); + + test( + 'log and console write through the facade, tagged with the id', + () async { + final harness = PluginHarness(); + final runtime = await harness.runtime(''' +export function logIt() { + fmp.log.info('searching', { page: 2 }); + fmp.log.warn('slow'); + console.log('value', { a: 1 }, 3); + console.error('bad'); +} +'''); + + await runtime.invoke('logIt', null); + + final records = harness.records('plugin-a'); + expect( + [for (final r in records) (r.level, r.message)], + [ + (LogLevel.info, 'searching'), + (LogLevel.warning, 'slow'), + (LogLevel.debug, 'value {"a":1} 3'), + (LogLevel.error, 'bad'), + ], + ); + expect(records.first.fields, {'page': 2}); + }, + ); + + test('plugin logs are redacted', () async { + final harness = PluginHarness(); + final runtime = await harness.runtime(''' +export function logIt() { fmp.log.info('cookie SESSDATA=FAKE_SESSDATA_123'); } +'''); + + await runtime.invoke('logIt', null); + + expect( + harness.records('plugin-a').single.message, + isNot(contains('FAKE_SESSDATA_123')), + ); + }); + + test('bad arguments throw a TypeError in the script', () async { + final runtime = await PluginHarness().runtime(''' +export async function misuse() { + const results = []; + for (const attempt of [ + () => fmp.storage.get(1), + () => fmp.http.request({ url: 'https://example.test/', verb: 'GET' }), + () => fmp.http.request({ url: 'https://example.test/', auth: 'always' }), + () => fmp.crypto.md5(), + () => fmp.http.request(function () {}), + ]) { + try { await attempt(); results.push('ok'); } catch (e) { results.push(e.name); } + } + return results; +} +'''); + + expect(await runtime.invoke('misuse', null), [ + 'TypeError', + 'TypeError', + 'TypeError', + 'TypeError', + 'TypeError', + ]); + }); + }); + + group('errors', () { + Future thrown(String throwStatement) async { + final runtime = await PluginHarness().runtime(''' +export async function fail() { $throwStatement } +'''); + return runtime + .invoke('fail', null) + .then( + (_) => fail('expected an error'), + onError: (Object error) => error as AppError, + ); + } + + for (final (name, matcher) in [ + ('NetworkError', isA()), + ('RateLimited', isA()), + ('AuthRequired', isA()), + ('CredentialInvalid', isA()), + ('VerificationRequired', isA()), + ('NotFound', isA()), + ('ParseError', isA()), + ('Unsupported', isA()), + ('UnexpectedError', isA()), + ]) { + test('a structured $name becomes that AppError', () async { + final error = await thrown("throw { fmpError: '$name' };"); + + expect(error, matcher); + expect(error.pluginId, 'plugin-a'); + }); + } + + test('RateLimited carries retryAfterSeconds', () async { + final error = await thrown( + "throw { fmpError: 'RateLimited', retryAfterSeconds: 2.5 };", + ); + + expect(error, isA()); + expect(error.retryAfter, const Duration(milliseconds: 2500)); + }); + + test('a retryAfterSeconds too large for Duration is capped', () async { + final error = await thrown( + "throw { fmpError: 'RateLimited', retryAfterSeconds: 1e300 };", + ); + + // 不能溢位成負的等待;上限與 parseRetryAfter 相同。 + expect(error.retryAfter, const Duration(days: 36500)); + }); + + test('Unavailable carries its reason', () async { + final error = await thrown( + "throw Object.assign(new Error('blocked'), " + "{ fmpError: 'Unavailable', reason: 'copyright' });", + ); + + expect( + error, + isA().having( + (e) => e.reason, + 'reason', + UnavailableReason.copyright, + ), + ); + }); + + for (final (description, statement) in [ + ('an unknown fmpError', "throw { fmpError: 'Teapot' };"), + ('Unavailable without a reason', "throw { fmpError: 'Unavailable' };"), + ('a TypeError', 'null.x;'), + ('a thrown string', "throw 'boom';"), + ]) { + test('$description becomes UnexpectedError', () async { + expect(await thrown(statement), isA()); + }); + } + + test('the thrown message and JS stack reach the log only', () async { + final harness = PluginHarness(); + final runtime = await harness.runtime(''' +export async function fail() { throw new RangeError('page out of range'); } +'''); + + final error = await runtime + .invoke('fail', null) + .then( + (_) => fail('expected an error'), + onError: (Object error) => error as AppError, + ); + harness.log.report('Search failed', error, tag: 'test'); + + expect(error.toString(), isNot(contains('page out of range'))); + final record = harness.records('test').single; + expect(record.error, contains('RangeError: page out of range')); + expect(record.error, contains('fail')); + }); + + test('a return value that is not JSON is a ParseError', () async { + final runtime = await PluginHarness().runtime(''' +export function cyclic() { const a = {}; a.self = a; return a; } +'''); + + await expectLater( + runtime.invoke('cyclic', null), + throwsA(isA()), + ); + }); + + test('a script that breaks JSON fails the call, not the plugin', () async { + final runtime = await PluginHarness().runtime(''' +export function poison() { + Object.prototype.toJSON = function () { throw new Error('poisoned'); }; + return 1; +} +'''); + + await expectLater( + runtime.invoke('poison', null), + throwsA(isA()), + ); + // 回不了話是這次呼叫的錯誤,不是背景 isolate 當掉。 + await settle(); + expect(runtime.health, PluginHealth.ready); + }); + + test('a syntax error is a ParseError at load', () async { + await expectLater( + PluginHarness().runtime('export function (('), + throwsA(isA()), + ); + }); + + test('throwing at the top level fails the load', () async { + final harness = PluginHarness(); + await expectLater( + harness.runtime("throw { fmpError: 'Unsupported' };"), + throwsA(isA()), + ); + await expectLater( + harness.runtime("throw new Error('top');", pluginId: 'plugin-b'), + throwsA(isA()), + ); + }); + }); + + group('timeouts and disposal', () { + test('a call still waiting on a promise fails, the plugin stays', () async { + final runtime = + await PluginHarness( + callTimeout: const Duration(milliseconds: 300), + livenessGrace: const Duration(milliseconds: 300), + ).runtime(''' +export function hang() { return new Promise(() => {}); } +export function ok() { return 'ok'; } +'''); + + final error = await runtime + .invoke('hang', null) + .then( + (_) => fail('expected an error'), + onError: (Object error) => error as AppError, + ); + + // 背景 isolate 回應了探測:只是在等,不是卡住。 + expect(error, isA()); + expect(error.pluginId, 'plugin-a'); + expect(runtime.health, PluginHealth.ready); + expect(await runtime.invoke('ok', null), 'ok'); + }); + + test( + 'a plugin stuck in a loop becomes unresponsive; others go on', + () async { + final harness = PluginHarness( + callTimeout: const Duration(milliseconds: 500), + livenessGrace: const Duration(milliseconds: 300), + ); + final stuck = await harness.runtime(''' +export function spin() { while (true) {} } +'''); + final other = await harness.runtime(''' +export function ok(value) { return value; } +''', pluginId: 'plugin-b'); + + final failure = stuck + .invoke('spin', null) + .then((_) => 'finished', onError: (Object error) => error); + // 卡住的是背景 isolate:主 isolate 的計時器照常觸發,另一個插件照常回應。 + final tick = Completer(); + Timer(const Duration(milliseconds: 50), tick.complete); + expect(await other.invoke('ok', 'during'), 'during'); + await tick.future; + expect(stuck.health, PluginHealth.ready); + + final error = await failure; + + expect(error, isA()); + expect((error as AppError).pluginId, 'plugin-a'); + expect(stuck.health, PluginHealth.unresponsive); + await stuck.whenUnresponsive; + await expectLater( + stuck.invoke('spin', null), + throwsA(isA()), + ); + expect( + harness.records('plugin-a').map((r) => r.message), + contains('Plugin stopped responding'), + ); + expect(await other.invoke('ok', 'after'), 'after'); + expect(other.health, PluginHealth.ready); + }, + ); + + test('a load stuck in a loop fails the start', () async { + await expectLater( + PluginHarness( + callTimeout: const Duration(milliseconds: 500), + livenessGrace: const Duration(milliseconds: 300), + ).runtime('while (true) {}\nexport function a() {}'), + throwsA(isA()), + ); + }); + + test('a worker that dies fails the call instead of hanging', () async { + final runtime = await PluginHarness().runtime( + '', + entryPoint: _crashOnCall, + ); + + await expectLater( + runtime.invoke('boom', null), + throwsA(isA()), + ); + await runtime.whenUnresponsive; + expect(runtime.health, PluginHealth.unresponsive); + }); + + test('a worker that cannot be spawned fails the start', () async { + // 進入點捕獲了 ReceivePort:傳不到新的 isolate,Isolate.spawn 失敗。 + final unsendable = ReceivePort(); + addTearDown(unsendable.close); + + await expectLater( + PluginHarness().runtime( + '', + entryPoint: (start) => unsendable.sendPort.send(start.pluginId), + ), + throwsA(isA()), + ); + }); + + test('a worker that dies while loading fails the start', () async { + await expectLater( + PluginHarness().runtime('', entryPoint: _crashAtStart), + throwsA(isA()), + ); + }); + + test('disposing fails the calls still waiting', () async { + final runtime = await PluginHarness().runtime( + 'export function hang() { return new Promise(() => {}); }', + ); + Object? failure; + unawaited( + runtime.invoke('hang', null).catchError((Object e) => failure = e), + ); + + await settle(); + runtime.dispose(); + await pumpUntil(() => failure != null); + + expect(failure, isA()); + await expectLater( + runtime.invoke('hang', null), + throwsA(isA()), + ); + }); + + test('disposing cancels requests in flight', () async { + final started = Completer(); + final harness = PluginHarness( + handler: (options) { + started.complete(); + return Completer().future; + }, + ); + final runtime = await harness.runtime(''' +export async function fetchIt() { await fmp.http.request({ url: 'https://example.test/' }); } +'''); + unawaited(runtime.invoke('fetchIt', null).catchError((Object _) => null)); + + await started.future; + runtime.dispose(); + + // 掛著的請求被取消,不是等到逾時。 + await pumpUntil( + () => harness + .records(networkLogTag) + .any((record) => record.fields['error'] == 'Cancelled'), + ); + expect(harness.adapter.requests, hasLength(1)); + }); + }); +} + +/// 背景 isolate 的替身:照協定載入,收到第一個呼叫就拋錯(isolate 隨之結束)。 +void _crashOnCall(PluginWorkerStart start) { + final commands = ReceivePort(); + start.toMain + ..send(WorkerReady(commands.sendPort)) + ..send(const LoadResult('{"ok":true,"value":["boom"]}')); + commands.listen((message) { + if (message is CallRequest) throw StateError('worker crashed'); + }); +} + +/// 背景 isolate 的替身:一開始就拋錯。 +void _crashAtStart(PluginWorkerStart start) => + throw StateError('worker crashed at start'); diff --git a/app/test/plugins/script_source_plugin_test.dart b/app/test/plugins/script_source_plugin_test.dart new file mode 100644 index 00000000..4dd437b5 --- /dev/null +++ b/app/test/plugins/script_source_plugin_test.dart @@ -0,0 +1,309 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/plugins/source_dto.dart'; + +import 'plugin_harness.dart'; + +final _formats = [StreamFormat(container: 'mp4', codec: 'aac')]; + +void main() { + group('the test plugin', () { + test('searches with synthetic results', () async { + final plugin = await PluginHarness().load( + testPluginFile.readAsStringSync(), + ); + + final first = await plugin.search(SearchQuery(keyword: 'hi')); + final second = await plugin.search(SearchQuery(keyword: 'hi', page: 2)); + + expect(first.items.map((t) => t.sourceId), ['tone-220', 'tone-440']); + expect(first.hasMore, isTrue); + expect(second.items.map((t) => t.sourceId), ['tone-880']); + expect(second.hasMore, isFalse); + final track = first.items.first; + expect(track.sourceTypeId, 'fmp-test'); + expect(track.cid, isNull); + expect(track.title, 'Test tone 220 Hz (hi)'); + expect(track.uploader, 'FMP'); + expect(track.duration, const Duration(seconds: 2)); + expect(track.artwork, isEmpty); + }); + + test('reads its own storage while searching', () async { + final harness = PluginHarness(); + final plugin = await harness.load(testPluginFile.readAsStringSync()); + await harness.storage.write('fmp-test', 'titlePrefix', 'Tone'); + + final page = await plugin.search(SearchQuery(keyword: 'x')); + + expect(page.items.first.title, 'Tone 220 Hz (x)'); + }); + + test('resolves a stream to the bundled tone', () async { + final plugin = await PluginHarness().load( + testPluginFile.readAsStringSync(), + ); + + final candidates = await plugin.resolveStream( + StreamRequest(sourceId: 'tone-440', formats: _formats), + ); + + final candidate = candidates.single; + expect( + candidate.url, + Uri.parse('asset:///test/fixtures/plugins/test_plugin/tone.wav'), + ); + expect(candidate.container, 'wav'); + expect(candidate.codec, 'pcm_s16le'); + expect(candidate.bitrate, 256000); + expect(candidate.expiresAt, isNull); + expect(candidate.headers, isEmpty); + }); + + test('an unknown track is NotFound', () async { + final plugin = await PluginHarness().load( + testPluginFile.readAsStringSync(), + ); + + await expectLater( + plugin.resolveStream( + StreamRequest(sourceId: 'song-1', formats: _formats), + ), + throwsA(isA()), + ); + }); + }); + + group('exports and capabilities', () { + test('a declared capability must be exported', () async { + await expectLater( + PluginHarness().load( + pluginSource( + 'export function search() {}', + capabilities: ['search', 'resolveStream'], + ), + ), + throwsA(isA()), + ); + }); + + test('an exported capability must be declared', () async { + await expectLater( + PluginHarness().load( + pluginSource( + 'export function search() {}\nexport function charts() {}', + ), + ), + throwsA(isA()), + ); + }); + + test('other exports are allowed', () async { + final plugin = await PluginHarness().load( + pluginSource( + 'export function search() {}\n' + 'export function helper() {}\n' + 'export const version = 2;', + ), + ); + + expect(plugin.manifest.id, 'plugin-a'); + }); + + test('calling an undeclared capability is Unsupported', () async { + final plugin = await PluginHarness().load( + pluginSource('export function search() {}'), + ); + + expect( + () => plugin.resolveStream( + StreamRequest(sourceId: 'a', formats: _formats), + ), + throwsA(isA()), + ); + }); + + test('the manifest redaction list reaches the log', () async { + final harness = PluginHarness(); + final source = + pluginSource( + "export function search() { fmp.log.info('ticket=FAKE_TICKET_42'); " + 'return { items: [], hasMore: false }; }', + ).replaceFirst( + '"allowedHosts"', + '"redaction": {"keyNames": ["ticket"]},\n "allowedHosts"', + ); + final plugin = await harness.load(source); + + await plugin.search(SearchQuery(keyword: 'x')); + + expect( + harness.records('plugin-a').single.message, + isNot(contains('FAKE_TICKET_42')), + ); + }); + }); + + group('returned values', () { + Future search(String result) async { + final plugin = await PluginHarness().load( + pluginSource( + 'export function search() { return $result; }', + allowedHosts: ['example.test'], + ), + ); + return plugin + .search(SearchQuery(keyword: 'x')) + .then((page) => page, onError: (Object error) => error); + } + + Future resolve(String result) async { + final plugin = await PluginHarness().load( + pluginSource( + 'export function resolveStream() { return $result; }', + capabilities: ['resolveStream'], + allowedHosts: ['example.test'], + ), + ); + return plugin + .resolveStream(StreamRequest(sourceId: 'a', formats: _formats)) + .then((list) => list, onError: (Object error) => error); + } + + test('the plugin cannot claim another source', () async { + final page = await search( + "{ items: [{ sourceId: 'a1', sourceTypeId: 'other', title: 'A' }], " + 'hasMore: false }', + ); + + // sourceTypeId 不是插件的欄位:表外的鍵,整個結果不收。 + expect(page, isA()); + }); + + test('artwork on an allowed host is kept', () async { + final page = await search( + "{ items: [{ sourceId: 'a1', cid: 7, title: 'A', artwork: " + "[{ url: 'https://img.example.test/a.jpg', width: 320 }] }], " + 'hasMore: true }', + ); + + page as SearchPage; + final artwork = page.items.single.artwork.single; + expect(artwork.url, Uri.parse('https://img.example.test/a.jpg')); + expect(artwork.width, 320); + expect(page.items.single.cid, 7); + }); + + for (final (description, result) in [ + ('a missing hasMore', '{ items: [] }'), + ('an unknown field', '{ items: [], hasMore: false, total: 3 }'), + ( + 'a track without a title', + "{ items: [{ sourceId: 'a1' }], hasMore: false }", + ), + ( + 'a source id with a colon', + "{ items: [{ sourceId: 'a:1', title: 'A' }], hasMore: false }", + ), + ( + 'a fractional duration', + "{ items: [{ sourceId: 'a1', title: 'A', durationMs: 1.5 }], " + 'hasMore: false }', + ), + ( + 'artwork on another host', + "{ items: [{ sourceId: 'a1', title: 'A', artwork: " + "[{ url: 'https://evil.test/a.jpg' }] }], hasMore: false }", + ), + ('undefined', 'undefined'), + ('a string', "'a track'"), + ]) { + test('search rejects $description as ParseError', () async { + expect(await search(result), isA()); + }); + } + + test('a stream on an allowed host keeps its fields', () async { + final candidates = await resolve( + "{ candidates: [{ url: 'https://cdn.example.test/a.m4a', " + "headers: { Referer: 'https://example.test/' }, container: 'mp4', " + "codec: 'aac', bitrate: 192000, expiresAt: 1790000000000 }] }", + ); + + candidates as List; + final candidate = candidates.single; + expect(candidate.headers, {'Referer': 'https://example.test/'}); + expect( + candidate.expiresAt, + DateTime.fromMillisecondsSinceEpoch(1790000000000, isUtc: true), + ); + }); + + for (final (description, result) in [ + ('no candidates', '{ candidates: [] }'), + ('an http stream', "{ candidates: [{ url: 'http://example.test/a' }] }"), + ( + 'a stream on another host', + "{ candidates: [{ url: 'https://evil.test/a' }] }", + ), + ('a file stream', "{ candidates: [{ url: 'file:///etc/passwd' }] }"), + ( + 'an asset URL with a host', + "{ candidates: [{ url: 'asset://evil.test/a.wav' }] }", + ), + ( + 'an expiry DateTime cannot hold', + "{ candidates: [{ url: 'https://example.test/a', " + 'expiresAt: 9000000000000000 }] }', + ), + ( + 'headers that are not strings', + "{ candidates: [{ url: 'https://example.test/a', headers: { a: 1 } }] }", + ), + ]) { + test('resolveStream rejects $description as ParseError', () async { + expect(await resolve(result), isA()); + }); + } + }); + + group('inputs', () { + test('what the host sends is checked before the call', () { + expect(() => SearchQuery(keyword: ' '), throwsArgumentError); + expect(() => SearchQuery(keyword: 'a', page: 0), throwsRangeError); + expect( + () => StreamRequest(sourceId: 'a:1', formats: _formats), + throwsArgumentError, + ); + expect( + () => StreamRequest(sourceId: 'a', formats: const []), + throwsArgumentError, + ); + }); + + test('the plugin receives the documented JSON', () async { + final plugin = await PluginHarness().load( + pluginSource( + 'export function search(query) { return { items: [{ sourceId: ' + "'q', title: JSON.stringify(query) }], hasMore: false }; }\n" + 'export function resolveStream(request) { return { candidates: ' + "[{ url: 'https://example.test/' + encodeURIComponent(" + 'JSON.stringify(request)) }] }; }', + capabilities: ['search', 'resolveStream'], + ), + ); + + final page = await plugin.search(SearchQuery(keyword: 'k', page: 3)); + final stream = await plugin.resolveStream( + StreamRequest(sourceId: 'bv1', cid: 9, formats: _formats), + ); + + expect(page.items.single.title, '{"keyword":"k","page":3}'); + expect( + Uri.decodeComponent(stream.single.url.pathSegments.single), + '{"sourceId":"bv1","cid":9,"purpose":"playback",' + '"formats":[{"container":"mp4","codec":"aac"}]}', + ); + }); + }); +} diff --git a/app/test/plugins/type_definitions_test.dart b/app/test/plugins/type_definitions_test.dart new file mode 100644 index 00000000..e0e55b2e --- /dev/null +++ b/app/test/plugins/type_definitions_test.dart @@ -0,0 +1,207 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/plugins/json_shape.dart'; +import 'package:fmp/plugins/manifest/plugin_manifest.dart'; +import 'package:fmp/plugins/runtime/plugin_host.dart'; +import 'package:fmp/plugins/runtime/script_errors.dart'; +import 'package:fmp/plugins/source_dto.dart'; + +import 'plugin_harness.dart'; + +// `lib/plugins/types/fmp-plugin.d.ts` 是給插件作者的介面說明。這裡以一個只 +// 認得那個檔案寫法的小解析器,比對它與 Dart 端實際的欄位表、宿主 API 與 +// 列舉;檔案結構改了,先改解析器再改比對。 + +// 換行統一成 LF:Windows 的 checkout 可能是 CRLF,變異案例以 `\n` 比對。 +final _dts = File('lib/plugins/types/fmp-plugin.d.ts') + .readAsStringSync() + .replaceAll('\r\n', '\n'); + +/// Dart 端有欄位表的 interface。 +final _shapes = { + ...manifestShapes, + ...sourceDtoShapes, + ...hostApiShapes, +}; + +/// 沒有 JSON 欄位表的 interface:宿主 API 由執行中的 runtime 比對,其他是 +/// 說明用的型別。 +const _apiInterfaces = { + 'FmpHost', + 'FmpHttp', + 'FmpCrypto', + 'FmpStorage', + 'FmpCredentials', + 'FmpLog', + 'FmpError', + 'FmpPluginExports', +}; + +String _withoutComments(String source) => source + .replaceAll(RegExp(r'/\*[\s\S]*?\*/'), '') + .replaceAll(RegExp(r'//[^\n]*'), ''); + +/// 每個 interface 的成員:名稱 → 是否必填(沒有 `?`)。 +Map> _interfaces(String dts) { + final source = _withoutComments(dts); + final result = >{}; + for (final match in RegExp(r'interface\s+(\w+)\s*\{').allMatches(source)) { + var depth = 1; + var end = match.end; + while (depth > 0) { + final char = source[end++]; + if (char == '{') depth++; + if (char == '}') depth--; + } + final body = source.substring(match.end, end - 1); + result[match[1]!] = { + for (final statement in body.split(';')) + if (RegExp(r'^(?:readonly\s+)?(\w+)(\?)?\s*[:(]') + .firstMatch(statement.trim()) + case final member?) + member[1]!: member[2] == null, + }; + } + return result; +} + +/// `type Name = 'a' | 'b';` 的字串值。 +Set _union(String dts, String name) { + final source = _withoutComments(dts); + final match = RegExp('type\\s+$name\\s*=([^;]*);').firstMatch(source); + if (match == null) return {}; + return { + for (final value in RegExp("'([^']*)'").allMatches(match[1]!)) value[1]!, + }; +} + +/// d.ts 與 Dart 欄位表的差異;一致時是空的。 +List _shapeDifferences(String dts) { + final interfaces = _interfaces(dts); + final differences = []; + for (final MapEntry(key: name, value: shape) in _shapes.entries) { + final members = interfaces[name]; + if (members == null) { + differences.add('$name is missing from the d.ts'); + } else if (!_sameShape(members, shape)) { + differences.add('$name: d.ts $members, Dart $shape'); + } + } + for (final name in interfaces.keys) { + if (!_shapes.containsKey(name) && !_apiInterfaces.contains(name)) { + differences.add('$name has no Dart shape'); + } + } + return differences; +} + +bool _sameShape(Map a, Map b) => + a.length == b.length && + a.entries.every((entry) => b[entry.key] == entry.value); + +void main() { + test('interfaces match the Dart JSON shapes', () { + expect(_shapeDifferences(_dts), isEmpty); + }); + + group('the comparison', () { + test('catches a missing field', () { + final mutated = _dts.replaceFirst(' uploader?: string | null;\n', ''); + + expect(mutated, isNot(_dts)); + expect(_shapeDifferences(mutated), [startsWith('TrackSummary:')]); + }); + + test('catches a field that became optional', () { + final mutated = _dts.replaceFirst( + ' hasMore: boolean;', + ' hasMore?: boolean;', + ); + + expect(_shapeDifferences(mutated), [startsWith('SearchPage:')]); + }); + + test('catches an interface without a Dart shape', () { + final mutated = + '$_dts\nexport interface LyricsPage { lines: string[]; }\n'; + + expect(_shapeDifferences(mutated), ['LyricsPage has no Dart shape']); + }); + + test('ignores comments, layout and member order', () { + final reformatted = _dts + .replaceAll('\n ', '\n\t\t') + .replaceFirst( + ' sourceId: string;\n cid?: number | null;', + ' // extra: string;\n /* hidden: number; */ cid?: number | null; ' + 'sourceId: string;', + ); + + expect(reformatted, isNot(_dts)); + expect(_shapeDifferences(reformatted), isEmpty); + }); + }); + + test('FmpHost matches the fmp object the prelude builds', () async { + final runtime = await PluginHarness().runtime(''' +export function shape() { + const out = {}; + for (const key of Object.keys(fmp)) { + out[key] = typeof fmp[key] === 'object' ? Object.keys(fmp[key]).sort() : typeof fmp[key]; + } + return out; +} +'''); + final interfaces = _interfaces(_dts); + final host = interfaces['FmpHost']!; + final hostTypes = { + for (final match in RegExp( + r'readonly\s+(\w+):\s*(Fmp\w+);', + ).allMatches(_withoutComments(_dts))) + match[1]!: match[2]!, + }; + + final actual = await runtime.invoke('shape', null) as Map; + + expect(actual.keys.toSet(), host.keys.toSet()); + expect(actual['apiVersion'], 'number'); + for (final MapEntry(key: member, value: type) in hostTypes.entries) { + expect( + actual[member], + (interfaces[type]!.keys.toList()..sort()), + reason: 'fmp.$member vs $type', + ); + } + }); + + test('the capability union matches PluginCapability', () { + expect(_union(_dts, 'FmpCapability'), { + for (final capability in PluginCapability.values) capability.wireName, + }); + }); + + test('every error name maps to the AppError of that name', () { + final names = _union(_dts, 'FmpErrorName'); + + expect(names, hasLength(10)); + for (final name in names) { + expect( + structuredScriptError( + pluginId: 'p', + fmpError: name, + reason: 'region', + ).typeName, + name, + ); + } + }); + + test('the Unavailable reasons match UnavailableReason', () { + expect(_union(_dts, 'FmpUnavailableReason'), { + for (final reason in UnavailableReason.values) + unavailableReasonWireName(reason), + }); + }); +} diff --git a/app/test/support/quickjs.dart b/app/test/support/quickjs.dart new file mode 100644 index 00000000..cfd2a828 --- /dev/null +++ b/app/test/support/quickjs.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; + +/// 讓裸 `flutter test` 載入得到 QuickJS 的原生庫。 +/// +/// flutter_js 0.8.7 在測試裡(`FLUTTER_TEST=true`)以檔名開原生庫:Windows 開 +/// `quickjs_c_bridge.dll`,Linux 開 `LIBQUICKJSC_TEST_PATH` 或 +/// `libquickjs_c_bridge_plugin.so`(`lib/quickjs/ffi.dart` 的 `_qjsLib`)。 +/// flutter_tester 的搜尋路徑裡沒有它們,README 的做法是先建置桌面版再改 +/// `PATH`/設環境變數。這裡改成先以絕對路徑載入套件內附的預先編譯檔 +/// (`windows/shared/`、`linux/shared/`),之後以檔名開就拿到同一份: +/// +/// - Windows:同名的 DLL 已經載入時,LoadLibrary 直接用它(Dynamic-Link +/// Library Search Order); +/// - Linux:dlopen 會比對已載入物件的 SONAME,那個檔的 SONAME 就是 +/// `libquickjs_c_bridge_plugin.so`。 +/// +/// 兩個平台找不到或載入失敗就拋錯,不跳過:CI(Linux)要真的跑到 QuickJS。 +/// macOS 的 flutter_js 走 JavaScriptCore,App 在 macOS 也還沒支援,不處理。 +void loadQuickJsForTests() { + final String relative; + if (Platform.isWindows) { + relative = 'windows/shared/quickjs_c_bridge.dll'; + } else if (Platform.isLinux) { + relative = 'linux/shared/libquickjs_c_bridge_plugin.so'; + } else { + return; + } + final library = File.fromUri(_flutterJsRoot().resolve(relative)); + if (!library.existsSync()) { + throw StateError('QuickJS library not found: ${library.path}'); + } + DynamicLibrary.open(library.path); +} + +/// flutter_js 在 pub cache 裡的根目錄,從 `.dart_tool/package_config.json` +/// 讀(`flutter test` 的工作目錄是 app/,也就是 workspace 根)。 +Uri _flutterJsRoot() { + final configFile = File('.dart_tool/package_config.json'); + final config = jsonDecode(configFile.readAsStringSync()) as Map; + final package = (config['packages'] as List).cast().firstWhere( + (package) => package['name'] == 'flutter_js', + ); + final root = configFile.absolute.uri.resolve(package['rootUri'] as String); + return root.path.endsWith('/') ? root : Uri.parse('$root/'); +} diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc index 8b6d4680..474a97c7 100644 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterJsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterJsPlugin")); } diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake index 3ad69c61..460d8f0a 100644 --- a/app/windows/flutter/generated_plugins.cmake +++ b/app/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_js ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/docs/adr/0014-script-source-plugins.md b/docs/adr/0014-script-source-plugins.md index c42eaa33..b4ccfc1c 100644 --- a/docs/adr/0014-script-source-plugins.md +++ b/docs/adr/0014-script-source-plugins.md @@ -58,6 +58,10 @@ manifest 與腳本承擔。 採用的慣例:MusicFree、LX Music 的 JS 腳本插件與插件訂閱(App 本體不附音源);Spotube 的 manifest(能力、API 版本)與 iOS 側載發佈; Flutter federated plugin 的「一套介面、多種實作」;Spotube 的匹配結果快取與使用者改選。只參考設計,不複製程式碼(MusicFree 為 AGPL-3.0)。 +補充(2026-09-30,M1):從檔案或網址安裝的單位是**單一 `.js` 檔**,開頭以 `/* ==FMP Plugin==` 與 `==/FMP Plugin== */` 包一段 JSON manifest;宿主不執行腳本即可讀出 manifest,先檢查網域與能力再載入(使用者腳本 metadata block 的慣例)。index 的 SHA-256 針對這個檔。擁有者 2026-09-30 選定。 + +補充(2026-09-30,M1):每個插件的 JS 執行環境在自己的背景 isolate 執行。`flutter_js` 0.8.7 的 QuickJS 沒有中斷機制,同步無窮迴圈會卡住所在的執行緒;放在背景 isolate 後 UI 不受影響。呼叫逾時時宿主先送存活探測:有回應代表只是在等網路,這次呼叫以 `NetworkError` 失敗、插件照常;沒有回應或 isolate 已結束,插件標為「沒有回應」並停用到 App 重啟(卡住的執行緒無法回收)。宿主 API 的安全檢查仍在主 isolate。擁有者 2026-09-30 選定;可中斷的替代套件(`flutter_qjs_next`、`quickjs_engine`)因單一作者、低採用而未採用。 + ## 後果 - 好的:新增音源=在插件庫新增一個目錄或自行安裝腳本;修音源不必發 App;ADR 0011–0013 的保護自動套用到所有腳本;