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
92 changes: 92 additions & 0 deletions .claude/skills/verify-on-device/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
name: verify-on-device
description: >-
在 Android 模擬器與 Windows 桌面版實機驗證 `app/`(新 App)的改動:啟動、操作、讀畫面
與 log、收尾。`app/` 的使用者看得到的改動(UI、播放、下載、歌詞、設定、原生身分、
平台能力)在回報前都要用它驗,Android 與 Windows 各一次;也用在被要求在裝置上跑、
截圖、點、觀察新 App 時。根目錄舊專案(`lib/`)的緊急修正改用 `verify-legacy-on-device`。
---

# 實機驗證 `app/`

閉環:**啟動 → 執行 → 觀察 → 操作 → 收尾。** 規則來源是 ADR 0027 與 `app/AGENTS.md` §
驗證;做不到哪一步就具名回報 blocker,不要略過不提。

| 何時讀 | 檔案 |
|---|---|
| Android 模擬器、安裝、帶參數啟動、log、音訊焦點、截圖 | `references/android.md` |
| Windows 建置、單一實例、MSAA 讀畫面與點按鈕、視窗截圖、SMTC | `references/windows.md` |
| 資料目錄、資料庫、log 格式、清成乾淨狀態、開發入口的參數 | `references/runtime-state.md` |

## 模式與平台

- **預設是重播**:dev flavor 加內附測試插件(`--fmp-dev-playback`,三首 2 秒的本機音檔、
不連網)。App 還沒有每插件的重播開關(ADR 0015 §決定 5 只做到契約測試),目前重播就是
測試插件。
- **真實連線只在**:改動本身是插件、網路層、登入,或正在錄 fixture。只做最少的操作(搜尋
一次、播一首),不批次、不迴圈。上游改版交給 Debug 頁的健康檢查,不靠每個 PR 的實機驗證。
- **平台**:Android 模擬器與 Windows 每個使用者看得到的 PR 都驗。Linux、macOS、iOS 在各自的
平台任務加進 `references/`,現在不驗。
- UI 還沒有入口的功能,用開發入口(`references/runtime-state.md` § 開發入口)。

## 前置

- `adb` 在 `PATH`;`emulator.exe` 不在,用 `$ANDROID_HOME/emulator/emulator.exe`。
- 只用 dev flavor(`com.personal.fmp.dev`、視窗標題 `FMP Dev`)。**不要動模擬器上的舊版
`com.personal.fmp`,也不要把 prod APK 裝上去**:它放著舊版的測試資料。
- `app/` 沒有 slang,drift 的 `*.g.dart` 已提交:建置前不必跑 codegen(改了 table 才跑
`dart run build_runner build`)。
- Git Bash 會改寫 `/data/...` 這類路徑:`adb shell` 前加 `MSYS_NO_PATHCONV=1`。Python 單行指令前加
`PYTHONIOENCODING=utf-8`。
- 有 Orca 時,`orca skills get computer-use`/`orca-cli` 取得與版本相符的指令參考;不靠記憶。

## 1. 啟動

- Android:模擬器開機、`flutter build apk --flavor dev --debug`、`adb install -r`、`am start`。
細節與陷阱見 `references/android.md`。模擬器要**分離**啟動,放在會結束的背景工作裡會被
連帶關掉。
- Windows:`flutter build windows --flavor dev --debug`,直接跑產物 `fmp.exe`。Dev 是單一實例,
先確認沒有別的 FMP Dev(包括別的 worktree)在跑。

要讓行程跨越多輪對話、並收得到熱重載按鍵時,`flutter run -d <裝置>` 放進 Orca terminal
(`orca terminal create --worktree active --command "cd app && flutter run -d <裝置>" --json`;
terminal 從 repo 根目錄開始)。

## 2. 觀察

| 訊號 | 做法 |
|---|---|
| 畫面文字與座標(Android) | `scripts/ax_flatten.py`(經 `orca emulator ax`;沒有 Orca 時加 `--adb`) |
| 畫面文字與座標(Windows) | `scripts/msaa_tree.ps1`(MSAA) |
| 截圖(Android) | `adb exec-out screencap -p > <檔>` |
| 截圖(Windows) | `scripts/window_shot.ps1`:只截 App 視窗 |
| App 的 log | 資料目錄的 `logs/fmp.jsonl`;Android debug build 也可 `adb logcat -s flutter` |
| 播放焦點、媒體工作階段 | `dumpsys audio`(Android)、`scripts/smtc_probe.ps1`(Windows,M2 起才有東西) |

優先讀文字(語意樹、log),畫面問題(版面、溢出、主題)才截圖。**截圖與貼進回報的內容不得含
個人資訊**:Windows 的身分頁會印出資料目錄的完整路徑,含使用者名稱;這種截圖不要貼出,只回報文字觀察。
只截 App 視窗,不截整個桌面。截圖一律存到 session 暫存目錄,不進 repo。

## 3. 操作

Android 用 `orca emulator tap/type/button`(座標是 0..1 的正規化值),沒有 Orca 時用
`adb shell input tap <px> <py>`(`ax_flatten.py` 的 `center=` 像素座標);Windows 用
`msaa_tree.ps1 -Click '<名稱>'`(它是真的滑鼠點擊,先用 `-Filter` 確認名稱;只對標題是
`FMP Dev` 的視窗)。畫面變動後座標與索引都失效,下一步前重新讀一次。

## 4. 收尾

除非被要求留著:關掉自己開的 `fmp.exe`(先確認是 FMP Dev)與 run terminal,`adb emu kill`
關掉你開的模擬器。還原為驗證改過的東西(安裝的 APK、推到裝置的檔案、`userdata-dev` 的資料),
用 `adb devices`、`tasklist` 確認。

## 回報格式

每一份實機驗證回報都要有:

- **平台**:Android 模擬器(AVD 名稱、SDK)或 Windows;兩個平台分開寫。
- **模式**:重播/測試插件,或真實。真實時列出做了哪些請求(哪個插件、哪個操作、幾次)。
- 觀察:引用 log 行或語意樹節點,或給截圖路徑(截圖需已確認不含個人資訊)。本機路徑以
佔位符寫(`<資料目錄>`、`<暫存目錄>`),不寫出使用者名稱。
- 模擬器若以 `-no-audio` 啟動,註明聽不到聲音,播放只能以 log 與 `dumpsys` 判斷。
- 每個略過或被擋下的步驟,與擋下它的原因。
89 changes: 89 additions & 0 deletions .claude/skills/verify-on-device/references/android.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Android 模擬器

驗證 dev flavor:applicationId `com.personal.fmp.dev`、activity `com.personal.fmp.MainActivity`。
**不要碰 `com.personal.fmp`**(舊版,模擬器上有它的測試資料),也不要安裝 prod APK。

## 模擬器

```bash
"$ANDROID_HOME/emulator/emulator.exe" -list-avds
```

以分離的程序啟動(放在會結束的背景工作裡,模擬器會被一起關掉):

```powershell
Start-Process -FilePath "$env:ANDROID_HOME\emulator\emulator.exe" -ArgumentList "-avd","<AVD>" -PassThru
```

```bash
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done; echo BOOTED'
```

`-no-audio` 啟動的模擬器不輸出聲音,回報要寫明;看播放改用 log 與 `dumpsys`。模擬器的音訊跑得
比實際時間快(約 1.7 倍),不要拿它的數字當播放時長或交接間隙的實測。

## 建置、安裝、啟動

在 `app/`:

```bash
flutter build apk --flavor dev --debug
adb install -r build/app/outputs/flutter-apk/app-dev-debug.apk
adb shell am start -n com.personal.fmp.dev/com.personal.fmp.MainActivity
```

### 帶開發入口的參數

dev 入口(`--fmp-dev-plugin=`、`--fmp-dev-playback`,見 `runtime-state.md`)以 intent extra
`dart_entrypoint_args` 傳進來。`--esal` 的值是**以逗號分隔的陣列**,所以多個參數用逗號接起來,
參數值本身不能含逗號:

```bash
MSYS_NO_PATHCONV=1 adb shell am start -n com.personal.fmp.dev/com.personal.fmp.MainActivity \
--esal dart_entrypoint_args --fmp-dev-playback
```

用 `--fmp-dev-plugin=` 時,App 要讀得到插件檔:先推到 `/data/local/tmp`(App 讀不到),再以
`run-as` 複製進 App 的私有目錄:

```bash
MSYS_NO_PATHCONV=1 adb push <插件.js 的本機路徑> /data/local/tmp/x.js
MSYS_NO_PATHCONV=1 adb shell "run-as com.personal.fmp.dev sh -c 'mkdir -p files && cp /data/local/tmp/x.js files/x.js'"
MSYS_NO_PATHCONV=1 adb shell rm /data/local/tmp/x.js
MSYS_NO_PATHCONV=1 adb shell am start -n com.personal.fmp.dev/com.personal.fmp.MainActivity \
--esal dart_entrypoint_args --fmp-dev-plugin=/data/data/com.personal.fmp.dev/files/x.js,--fmp-dev-playback=bilibili:<BV 號>
```

用 `adb shell mkdir` 建的目錄屬於 `shell`,App 寫不進去(`errno = 13`);一律用 `run-as`。
要重新帶參數,先 `adb shell am force-stop com.personal.fmp.dev`(只停 dev)。

## 觀察

- **語意樹**:`PYTHONIOENCODING=utf-8 python .claude/skills/verify-on-device/scripts/ax_flatten.py --limit 30`。
預設經 `orca emulator ax`;沒有 Orca 時加 `--adb`,改讀
`adb exec-out uiautomator dump /dev/tty`(直接輸出到 stdout,裝置上不留檔)。Flutter 的語意經
uiautomator 變成節點:多數文字在 `content-desc`,少數(例如身分頁的資料目錄)在 `text`,
腳本兩個都讀。`norm=` 餵 `orca emulator tap <x> <y>`,`center=` 餵 `adb shell input tap <x> <y>`。
- **log**:`adb logcat -s flutter`(debug build 的 console log)。結構化的 log 在 App 的
`files/logs/fmp.jsonl`:`adb shell run-as com.personal.fmp.dev cat files/logs/fmp.jsonl`。
- **截圖**:`adb exec-out screencap -p > <檔>`,存到 session 暫存目錄,不進 repo;貼進回報前確認不含
個人資訊。`orca screenshot` 回傳內嵌 base64,很耗 context。
- **音訊焦點**(改播放或後端時):播放中與交接前後重複下面這條,最新一筆一直是
`requestAudioFocus`、沒有 abandon 後又重新 request;`dumpsys audio` 的 `Audio Focus stack`
最上面是 `com.personal.fmp.dev`(`AUDIOFOCUS_GAIN`)。播完之後焦點仍在(just_audio 不主動放):

```bash
PID=$(adb shell pidof com.personal.fmp.dev)
adb shell dumpsys audio | grep -E "(request|abandon)AudioFocus\(\) from uid/pid [0-9]+/$PID "
```

## 陷阱

- 首次點進文字欄位時 Gboard 的教學可能搶走 `adb shell input text`,預設語言也可能是注音而不提交字元;
截圖確認後切英文再打。
- `adb shell input text` 不能輸入中日韓文字;中文輸入用 `integration_test` 的 `enterText`。
- 根路由按 Back 會結束 App;要放背景用 `adb shell input keyevent 3`(HOME)。
- `force-stop` 會讓 `flutter run` 斷線(之後熱重載按鍵全部無聲無效);要冷啟動就關掉那個 run 重開。
- 模擬器卡死(畫面不動、`ax` 是 0 個節點)時,以 `-no-snapshot-load` 重開。
- 模擬器的 DNS 不快取,第一次連線多約 1 秒;測錯誤路徑關網路:
`adb shell svc wifi disable && adb shell svc data disable`(驗完開回來)。
66 changes: 66 additions & 0 deletions .claude/skills/verify-on-device/references/runtime-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# App 的狀態、資料與開發入口

## 資料目錄

解析邏輯在 `app/lib/platform/app_data_directory/`(ADR 0009 §決定 7)。下面的 `<資料目錄>` 指:

| 平台 | dev | prod |
|---|---|---|
| Windows 免安裝版(程式目錄沒有 `unins000.exe`;`flutter run`、直接跑建置產物都是這種) | 執行檔旁的 `userdata-dev\` | `userdata\` |
| Windows 安裝版(有 `unins000.exe`) | `%APPDATA%\com.personal\fmp-dev` | `%APPDATA%\com.personal\fmp` |
| Android | `/data/data/com.personal.fmp.dev/files`(`run-as com.personal.fmp.dev`) | `com.personal.fmp` 的 `files`:**不要碰** |

dev 解析到舊版正式資料的位置(Windows 的 `Documents\FMP`、`%APPDATA%\com.personal\fmp`、Android 的
`com.personal.fmp` 沙盒)會在啟動時拋 `LegacyDataLocationException`:視窗開了但內容空白。

內容:

- `fmp.db`:drift 的 SQLite(沒開 WAL,只有這一個檔)。表 `appearance_settings`、
`installed_plugins`(`id`、`version`、`manifest_json`、`script`、`installed_at` 是 UTC epoch 毫秒)、
`plugin_storage`(`plugin_id`、`key`、`value`,外鍵 cascade)。SQL 的欄位名是 snake_case,
不是 Dart 的 getter 名。之後的里程碑會加表,以 `lib/data/database/tables.dart` 為準。
- `logs/fmp.jsonl`(與輪替的 `fmp.1.jsonl`、`fmp.2.jsonl`):JSON Lines,單檔 2MB,已經過遮蔽。
每行 `{"time","level","tag","message","fields"}`,例如
`{"level":"info","tag":"app","message":"App started","fields":{"flavor":"dev","buildMode":"debug"}}`。
驗證時用 `tag` 與 `message` 找(`Installed a plugin from the development entry`、
`Development playback started`、`Look-ahead handover`、`Track audible`)。

## 讀狀態

優先讀 log 與資料庫,而不是畫面。資料庫要在 App 關掉(或至少沒在寫)時讀;有 `sqlite3` 命令列時:

```bash
sqlite3 <資料目錄>/fmp.db "select id, version, installed_at from installed_plugins"
```

Android 把 `fmp.db` 拉回來讀,要用 `exec-out`(`adb shell` 會改掉二進位內容,檔案變大、打不開):
`adb exec-out run-as com.personal.fmp.dev cat files/fmp.db > <暫存目錄>/fmp.db`。
Dart VM Service(`flutter run` 印的 URI)可讀活的物件;URI 是本機除錯憑證,回報只寫埠與用途。

## 清成乾淨狀態

**先看再刪,只刪 dev 的。**

1. 先確認路徑:Windows 是 `build\windows\x64\dev\runner\<模式>\userdata-dev`(路徑裡要有 `dev`),
Android 是 `com.personal.fmp.dev`。`ls` 一次,內容是 `fmp.db`、`logs`(Android 另有
`profileInstalled`)才刪。
2. 關掉 App,再刪:Windows 刪 `userdata-dev\` 整個目錄(或只刪 `fmp.db` 保留 log);
Android `adb shell pm clear com.personal.fmp.dev`(只清 dev 這個 package)。執行前再看一次
package 名稱以 `.dev` 結尾:少了 `.dev` 清掉的是舊版的資料,無法復原。
3. 不要對 `userdata\`、`%APPDATA%\com.personal\fmp`、`Documents\FMP`、`com.personal.fmp` 做任何刪除。

## 開發入口(只在 dev flavor;prod 一律忽略)

原始碼:`lib/plugins/install/dev_plugin_entry.dart`、`lib/playback/dev_playback_entry.dart`。

| 入口 | 作用 |
|---|---|
| `--fmp-dev-plugin=<路徑>` 或環境變數 `FMP_DEV_PLUGIN` | 啟動時安裝該路徑的插件安裝檔(`.js`);兩者都有時參數優先 |
| `--fmp-dev-playback` | 安裝內附的測試插件(`fmp-test`),依序播它的三首(`tone-220`、`tone-440`、`tone-880`,每首是同一個 2 秒的本機 wav,不連網) |
| `--fmp-dev-playback=<曲目鍵>` | 播指定曲目(例如 `bilibili:<BV 號>`);重複參數播多首。插件要已安裝,或同時帶 `--fmp-dev-plugin`。要連網,屬於「真實」模式 |

- 參數不用逗號分隔(Android 的 `--esal` 會切陣列,見 `android.md`)。
- Windows 的環境變數只對該行程有效:PowerShell 用
`$env:FMP_DEV_PLUGIN='<路徑>'; Start-Process ...; Remove-Item Env:FMP_DEV_PLUGIN`。
- 這兩個入口在 PR 12 的 UI 取代之前是驗證播放與插件的入口;之後以原始碼為準。
- 身分頁的 `Dev playback: <狀態> <第幾首>/<總數>` 是播放入口的狀態。
79 changes: 79 additions & 0 deletions .claude/skills/verify-on-device/references/windows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Windows 桌面版

## 建置與啟動

在 `app/`:

```bash
flutter build windows --flavor dev --debug
```

產物:`build\windows\x64\dev\runner\Debug\fmp.exe`(舊版、prod、dev 的執行檔都叫 `fmp.exe`,
只有 dev 的視窗標題是 `FMP Dev`)。直接執行,參數照 `runtime-state.md` 的開發入口:

```powershell
Start-Process build\windows\x64\dev\runner\Debug\fmp.exe -ArgumentList '--fmp-dev-playback'
```

- **dev 是單一實例**(mutex `Local\FMP_MainInstance-dev`):別的 worktree 開著的 FMP Dev 也算。
第二個實例只會把第一個帶到前景就結束,看起來像「新版沒起來」。先 `tasklist | findstr fmp.exe`,
確認是 FMP Dev 才關。
- 資料與 log 在執行檔旁的 `userdata-dev\`:`userdata-dev\logs\fmp.jsonl`。`flutter clean` 會一起清掉。
格式與清法見 `runtime-state.md`。
- `flutter run -d windows` 時熱重載 `r` 可以;熱重啟 `R` 會讓行程結束,改用 `q` 離開再重開。

## 讀畫面文字、點按鈕(MSAA)

Flutter 的 Windows 視窗只經 MSAA 暴露語意,UI Automation 與 `orca computer get-app-state` 看不到
裡面(只有 `pane FLUTTERVIEW`)。用 `scripts/msaa_tree.ps1`(要 `powershell.exe`):

```bash
S=.claude/skills/verify-on-device/scripts
powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/msaa_tree.ps1 [-Filter text]
powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/msaa_tree.ps1 -Click '<名稱>' [-Role 'push button'] [-Index 0]
```

- 第一行 `nodes=<n>`。身分頁約 11 個;之後頁面變多卻只剩個位數,代表無障礙橋卡住
(`docs/troubleshooting.md` 的 `Failed to update ui::AXTree`)。
- 輸出的矩形是螢幕上的物理像素。`-Click` 是**真的滑鼠點擊**,先用 `-Filter` 確認名稱;它會把游標
停回視窗左上角(游標下有 tooltip 會讓樹卡住)。
- 身分頁上資料目錄那一行的語意名稱目前是空字串(畫面上看得到);要確認資料目錄就看
`userdata-dev\` 是否存在,或截圖。
- 腳本只找行程 `fmp` 而且視窗標題是 `FMP Dev` 的(`-Proc`、`-Title` 改),不會點到舊版或 prod;
exit 1 是沒有視窗,2 是 `-Click` 沒對到,3 是視窗拉不到前景。

## 截圖

```bash
powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/window_shot.ps1 \
-Exe <fmp.exe 路徑> [-ArgLine '--fmp-dev-playback'] -Out <png> [-Wait 8] [-KeepRunning]
powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/window_shot.ps1 -Attach -Out <png>
```

- 以 `PrintWindow` 只截 App 視窗,不含桌面與其他視窗;視窗被蓋住也截得到,最小化的不行(exit 1)。
- 預設截完就結束它啟動的行程,要接著操作加 `-KeepRunning`;`-Attach` 對已開的 `FMP Dev` 視窗
截圖、不結束它。
- 舊專案觀察過 `PrintWindow` 回傳一張不再更新的畫面:兩張截圖逐位元相同時,先別斷定操作沒生效,
用 MSAA 或 log 對照。
- 輸出放 session 暫存目錄。身分頁顯示資料目錄的完整路徑(含使用者名稱),這種截圖不要貼進
PR 或回報;其他頁面確認沒有個人資訊才附。
- 不要改用整螢幕截圖(`CopyFromScreen`):會截到蓋在上面的其他視窗。

## 操作的陷阱

- 合成輸入(`orca computer click`)要先把視窗拉到前景;`SetForegroundWindow` 單獨呼叫常被前景鎖
靜默擋掉。`msaa_tree.ps1 -Click` 已經處理。
- 腳本要量座標或截圖時先設 `SetProcessDpiAwarenessContext(-4)`(`window_shot.ps1` 已設),否則
DPI 縮放下座標與截圖對不上。
- 對主視窗送 `WM_CLOSE` 才是「使用者按 X」;`Stop-Process` 是強殺,測不到關閉流程。

## SMTC(M2 起才有東西可讀)

```bash
powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/smtc_probe.ps1 -AppFilter com.personal.fmp.dev
```

從 WinRT 讀每個媒體工作階段的 `IsNextEnabled`/`IsPreviousEnabled` 等,App 不必在前景。`-AppFilter`
是 AppUserModelID 的子字串比對:只寫 `fmp` 會連舊版與 prod(`com.personal.fmp`)一起列出。必須是
`powershell.exe`(5.1);`pwsh` 沒有 WinRT 投影。M1 的 App 還沒有註冊媒體工作階段,目前的輸出是
`SESSIONS=0` 與 `NO_SESSION`,不代表出錯。
Loading
Loading