Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
133 changes: 133 additions & 0 deletions .trellis/spec/app/plugins/index.md
Original file line number Diff line number Diff line change
@@ -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` § 插件若規則變了也改。
52 changes: 48 additions & 4 deletions .trellis/tasks/09-28-m1-skeleton-tracer/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug> --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`)
Expand Down Expand Up @@ -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`;
Expand All @@ -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。
Expand Down
3 changes: 3 additions & 0 deletions .trellis/tasks/09-28-m1-skeleton-tracer/prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的事實。
Expand Down
3 changes: 2 additions & 1 deletion .trellis/tasks/09-28-m1-skeleton-tracer/task.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
7 changes: 7 additions & 0 deletions .trellis/tasks/archive/2026-09/09-30-js-runtime/check.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
Original file line number Diff line number Diff line change
@@ -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"}
Loading
Loading