diff --git a/.claude/skills/verify-on-device/SKILL.md b/.claude/skills/verify-on-device/SKILL.md new file mode 100644 index 00000000..858e3827 --- /dev/null +++ b/.claude/skills/verify-on-device/SKILL.md @@ -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 `(`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` 判斷。 +- 每個略過或被擋下的步驟,與擋下它的原因。 diff --git a/.claude/skills/verify-on-device/references/android.md b/.claude/skills/verify-on-device/references/android.md new file mode 100644 index 00000000..d7564a4d --- /dev/null +++ b/.claude/skills/verify-on-device/references/android.md @@ -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","" -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: +``` + +用 `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 `,`center=` 餵 `adb shell input tap `。 +- **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`(驗完開回來)。 diff --git a/.claude/skills/verify-on-device/references/runtime-state.md b/.claude/skills/verify-on-device/references/runtime-state.md new file mode 100644 index 00000000..e78720b9 --- /dev/null +++ b/.claude/skills/verify-on-device/references/runtime-state.md @@ -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:`);重複參數播多首。插件要已安裝,或同時帶 `--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: <狀態> <第幾首>/<總數>` 是播放入口的狀態。 diff --git a/.claude/skills/verify-on-device/references/windows.md b/.claude/skills/verify-on-device/references/windows.md new file mode 100644 index 00000000..166f70f6 --- /dev/null +++ b/.claude/skills/verify-on-device/references/windows.md @@ -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=`。身分頁約 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 [-ArgLine '--fmp-dev-playback'] -Out [-Wait 8] [-KeepRunning] +powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/window_shot.ps1 -Attach -Out +``` + +- 以 `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`,不代表出錯。 diff --git a/.claude/skills/verify-on-device/scripts/ax_flatten.py b/.claude/skills/verify-on-device/scripts/ax_flatten.py new file mode 100644 index 00000000..dcc426ea --- /dev/null +++ b/.claude/skills/verify-on-device/scripts/ax_flatten.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Flatten the Android accessibility tree into one line per actionable/labelled node. + +Prints the node class, resource id, label, clickability, and both pixel and +normalized (0..1) centers. The normalized pair feeds `orca emulator tap `; +the pixel pair feeds `adb shell input tap `. + +The tree comes from `orca emulator ax` by default. With `--adb` it comes from +`adb exec-out uiautomator dump /dev/tty` instead, so Orca is not needed and no +dump file is left on the device. + +Usage: + PYTHONIOENCODING=utf-8 python ax_flatten.py [--adb] [--device emulator-5554] + [--limit 40] [--grep TEXT] +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import xml.etree.ElementTree as ET + + +def dump(orca: str, device: str) -> dict: + proc = subprocess.run( + [orca, "emulator", "ax", "--device", device, "--json"], + capture_output=True, + ) + payload = json.loads(proc.stdout.decode("utf-8", errors="replace")) + if not payload.get("ok"): + sys.exit(f"orca emulator ax failed: {payload.get('error')}") + return payload["result"] + + +_BOUNDS = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]") + + +def dump_adb(device: str) -> dict: + """Read the tree with uiautomator and convert it to the shape `orca emulator ax` returns.""" + proc = subprocess.run( + ["adb", "-s", device, "exec-out", "uiautomator", "dump", "/dev/tty"], + capture_output=True, + ) + text = proc.stdout.decode("utf-8", errors="replace") + end = text.rfind("") + if end < 0: + sys.exit(f"uiautomator dump failed: {text.strip() or proc.stderr.decode(errors='replace').strip()}") + root = ET.fromstring(text[text.find("<"):end + len("")]) + + def convert(el: ET.Element) -> dict: + m = _BOUNDS.fullmatch(el.get("bounds", "")) + l, t, r, b = (int(v) for v in m.groups()) if m else (0, 0, 0, 0) + return { + "text": el.get("text", ""), + "contentDesc": el.get("content-desc", ""), + "clickable": el.get("clickable") == "true", + "className": el.get("class", ""), + "resourceId": el.get("resource-id", ""), + "bounds": {"left": l, "top": t, "right": r, "bottom": b}, + "children": [convert(child) for child in el.findall("node")], + } + + return {"children": [convert(child) for child in root.findall("node")]} + + +def walk(node: dict, width: int, height: int, depth: int = 0, out: list | None = None) -> list: + if out is None: + out = [] + bounds = node.get("bounds") or {} + label = node.get("text") or node.get("contentDesc") or "" + if label or node.get("clickable"): + cx = (bounds.get("left", 0) + bounds.get("right", 0)) / 2 + cy = (bounds.get("top", 0) + bounds.get("bottom", 0)) / 2 + cls = (node.get("className") or "").rsplit(".", 1)[-1] + rid = (node.get("resourceId") or "").rsplit("/", 1)[-1] + out.append( + "{indent}{cls} id={rid} txt={label!r} click={click} " + "center=({cx:.0f},{cy:.0f}) norm=({nx:.3f},{ny:.3f})".format( + indent=" " * depth, + cls=cls, + rid=rid, + label=label, + click=node.get("clickable"), + cx=cx, + cy=cy, + nx=cx / width if width else 0, + ny=cy / height if height else 0, + ) + ) + for child in node.get("children") or []: + walk(child, width, height, depth + 1, out) + return out + + +def screen_size(device: str) -> tuple[int, int]: + """Read the device's physical resolution; ax bounds are in those pixels.""" + proc = subprocess.run(["adb", "-s", device, "shell", "wm", "size"], capture_output=True) + text = proc.stdout.decode("utf-8", errors="replace") + for token in text.split(): + if "x" in token and token.replace("x", "").isdigit(): + w, h = token.split("x") + return int(w), int(h) + return 1080, 2400 + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--device", default="emulator-5554") + parser.add_argument("--orca", default="orca", help="Orca executable to use") + parser.add_argument("--adb", action="store_true", help="read the tree with adb uiautomator, without Orca") + parser.add_argument("--limit", type=int, default=40) + parser.add_argument("--grep", default=None, help="only print lines containing this text") + args = parser.parse_args() + + width, height = screen_size(args.device) + tree = dump_adb(args.device) if args.adb else dump(args.orca, args.device) + lines = walk(tree, width, height) + if args.grep: + lines = [line for line in lines if args.grep in line] + print(f"nodes={len(lines)} screen={width}x{height}") + print("\n".join(lines[: args.limit])) + if len(lines) > args.limit: + print(f"... {len(lines) - args.limit} more (raise --limit)") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/verify-on-device/scripts/msaa_tree.ps1 b/.claude/skills/verify-on-device/scripts/msaa_tree.ps1 new file mode 100644 index 00000000..60f1cfd7 --- /dev/null +++ b/.claude/skills/verify-on-device/scripts/msaa_tree.ps1 @@ -0,0 +1,179 @@ +# Reads, and clicks, what a screen reader can reach in the Windows build, via MSAA. +# +# UI Automation (and so `orca computer get-app-state`) shows nothing under +# `pane FLUTTERVIEW`, walked or hit-tested, even with a healthy tree and +# Narrator running: flutter_windows.dll answers WM_GETOBJECT through MSAA +# (`LresultFromObject`) and carries no UIA provider (`UiaReturnRawElementProvider` +# is absent; checked on Flutter 3.47.1). oleacc reaches the real tree. +# Semantics are on from startup, so Narrator does not need to be running. +# +# Dump, with each element's screen rectangle in physical pixels: +# +# powershell.exe -NoProfile -ExecutionPolicy Bypass ` +# -File .claude/skills/verify-on-device/scripts/msaa_tree.ps1 [-Filter button] +# +# The legacy app, the new prod build and FMP Dev are all `fmp.exe`, so the +# window is picked by process name AND title (-Title, default 'FMP Dev'): a +# -Click must never land in the user's real app. +# +# Click an element by its exact accessible name (after raising the window): +# +# ... msaa_tree.ps1 -Click '關閉' [-Role 'push button'] [-Index 0] +# +# The engine exposes no MSAA default action, so -Click moves the mouse to the +# element's centre and clicks; the element must be on screen. It then parks the +# cursor on the window's top-left corner: a tooltip left under the pointer +# freezes the tree for the rest of the run (docs/troubleshooting.md). A name +# matches when it equals -Click or when its first line does: rail tabs and list +# rows carry multi-line names such as "設定" + "第 6 個分頁 (共 6 個)". +# +# First output line is `nodes=`. Compare it with the framework's tree +# (`ext.flutter.debugDumpSemanticsTreeInInverseHitTestOrder`): a handful of +# nodes against a full framework tree means the accessibility bridge stopped +# taking updates (see docs/troubleshooting.md), and -Click cannot see past it. +# +# Exit codes: 0 = ok, 1 = no window or no FLUTTERVIEW, 2 = -Click matched +# nothing, 3 = the window could not be brought to the foreground. + +[CmdletBinding()] +param( + [string]$Proc = 'fmp', + [string]$Title = 'FMP Dev', + [string]$Filter = '', + [string]$Click = '', + [string]$Role = '', + [int]$Index = 0, + [int]$MaxDepth = 40, + [int]$Limit = 3000 +) +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# 下面的 C# 區塊只能寫 ASCII:Windows PowerShell 5.1 用系統代碼頁讀 Add-Type 的 +# 暫存 .cs,中文註解會解碼壞掉,把下一行吞進註解。 +# Raise:SetForegroundWindow 單獨呼叫時常被前景鎖擋掉而且不報錯,所以先掛上目前 +# 前景的輸入執行緒,再做一次置頂來回,最後確認前景真的換了。 +Add-Type -ReferencedAssemblies Accessibility -TypeDefinition @" +using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; using Accessibility; +public class MsaaNode { + public int Depth; public string Role; public string Name; public int X, Y, W, H; public bool HasRect; + public override string ToString() { + return new string(' ', Depth * 2) + "[" + Role + "] '" + Name + "'" + (HasRect ? " @(" + X + "," + Y + " " + W + "x" + H + ")" : ""); + } +} +public static class MsaaTree { + delegate bool EnumProc(IntPtr h, IntPtr l); + [DllImport("user32.dll")] static extern bool EnumChildWindows(IntPtr p, EnumProc cb, IntPtr l); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr h, StringBuilder s, int n); + [DllImport("oleacc.dll")] static extern int AccessibleObjectFromWindow(IntPtr h, uint id, ref Guid iid, [MarshalAs(UnmanagedType.Interface)] out object o); + [DllImport("oleacc.dll")] static extern int AccessibleChildren(IAccessible p, int start, int n, [Out] object[] kids, out int got); + [DllImport("oleacc.dll", CharSet = CharSet.Unicode)] static extern uint GetRoleText(uint role, StringBuilder s, uint n); + + [DllImport("user32.dll")] public static extern IntPtr SetProcessDpiAwarenessContext(IntPtr v); + [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint f); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll")] static extern bool AttachThreadInput(uint a, uint b, bool attach); + [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId(); + [DllImport("user32.dll")] static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] static extern void mouse_event(uint f, int x, int y, uint d, UIntPtr e); + + public static IntPtr FindChild(IntPtr parent, string cls) { + IntPtr found = IntPtr.Zero; + EnumChildWindows(parent, (h, l) => { + var sb = new StringBuilder(256); GetClassName(h, sb, 256); + if (sb.ToString() == cls) { found = h; return false; } + return true; + }, IntPtr.Zero); + return found; + } + + public static List Dump(IntPtr hwnd, int maxDepth, int limit) { + var nodes = new List(); + Guid iid = new Guid("618736E0-3C3D-11CF-810C-00AA00389B71"); object o; + AccessibleObjectFromWindow(hwnd, 0xFFFFFFFC, ref iid, out o); // OBJID_CLIENT + Walk((IAccessible)o, 0, maxDepth, limit, nodes); + return nodes; + } + + static void Walk(IAccessible a, int depth, int maxDepth, int limit, List nodes) { + if (depth > maxDepth || nodes.Count >= limit) return; + var node = new MsaaNode { Depth = depth, Role = "", Name = "" }; + try { node.Name = a.get_accName(0) ?? ""; } catch {} + try { + object r = a.get_accRole(0); + if (r is int) { var sb = new StringBuilder(64); GetRoleText((uint)(int)r, sb, 64); node.Role = sb.ToString(); } else { node.Role = "" + r; } + } catch {} + try { a.accLocation(out node.X, out node.Y, out node.W, out node.H, 0); node.HasRect = true; } catch {} + nodes.Add(node); + int n = 0; try { n = a.accChildCount; } catch {} + if (n == 0) return; + var kids = new object[n]; int got; + AccessibleChildren(a, 0, n, kids, out got); + for (int i = 0; i < got; i++) { var k = kids[i] as IAccessible; if (k != null) Walk(k, depth + 1, maxDepth, limit, nodes); } + } + + public static bool Raise(IntPtr h) { + IntPtr fg = GetForegroundWindow(); uint pid; + uint fgThread = GetWindowThreadProcessId(fg, out pid); + uint me = GetCurrentThreadId(); + AttachThreadInput(me, fgThread, true); + SetWindowPos(h, new IntPtr(-1), 0, 0, 0, 0, 3); + SetWindowPos(h, new IntPtr(-2), 0, 0, 0, 0, 3); + SetForegroundWindow(h); + AttachThreadInput(me, fgThread, false); + System.Threading.Thread.Sleep(250); + return GetForegroundWindow() == h; + } + + public static void ClickAt(int x, int y, int parkX, int parkY) { + SetCursorPos(x, y); + System.Threading.Thread.Sleep(80); + mouse_event(2, 0, 0, 0, UIntPtr.Zero); + mouse_event(4, 0, 0, 0, UIntPtr.Zero); + System.Threading.Thread.Sleep(150); + SetCursorPos(parkX, parkY); + } + + [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr h, out RECT r); + public struct RECT { public int L, T, R, B; } + public static int[] TopLeft(IntPtr h) { RECT r; GetWindowRect(h, out r); return new int[] { r.L, r.T }; } +} +"@ + +# accLocation 回傳實體像素;不宣告 DPI 感知的話,SetCursorPos 用的是縮放後的座標。 +[MsaaTree]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null + +$window = Get-Process $Proc -ErrorAction SilentlyContinue | + Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -eq $Title } | Select-Object -First 1 +if (-not $window) { Write-Output "no visible '$Title' window for process '$Proc'"; exit 1 } +$view = [MsaaTree]::FindChild($window.MainWindowHandle, 'FLUTTERVIEW') +if ($view -eq [IntPtr]::Zero) { Write-Output 'no FLUTTERVIEW child window'; exit 1 } + +$nodes = [MsaaTree]::Dump($view, $MaxDepth, $Limit) +Write-Output "nodes=$($nodes.Count)" + +if (-not $Click) { + $nodes | ForEach-Object { $_.ToString() } | Where-Object { -not $Filter -or $_ -match $Filter } + exit 0 +} + +# 名稱整段相符,或第一行相符:導覽分頁與列表項目的名稱常是多行(「設定」換行接 +# 「第 6 個分頁 (共 6 個)」)。 +$found = @($nodes | Where-Object { + ($_.Name -eq $Click -or ($_.Name -split "`n")[0] -eq $Click) -and $_.HasRect -and + (-not $Role -or $_.Role -eq $Role) +}) +if ($found.Count -le $Index) { + Write-Output "no element named '$Click'$(if ($Role) { " with role '$Role'" }) at index $Index ($($found.Count) found)" + exit 2 +} +$target = $found[$Index] +if (-not [MsaaTree]::Raise($window.MainWindowHandle)) { Write-Output 'could not bring the window to the foreground'; exit 3 } +$cx = $target.X + [int]($target.W / 2) +$cy = $target.Y + [int]($target.H / 2) +# 點完把游標移到視窗左上角(標題列):停在有 tooltip 的按鈕上會讓無障礙樹停住, +# 下一次讀到的就是舊樹。 +$corner = [MsaaTree]::TopLeft($window.MainWindowHandle) +[MsaaTree]::ClickAt($cx, $cy, [Math]::Max(0, $corner[0] + 20), [Math]::Max(0, $corner[1] + 5)) +Write-Output "clicked [$($target.Role)] '$($target.Name)' at ($cx,$cy), match $($Index + 1) of $($found.Count)" diff --git a/.claude/skills/verify-on-device/scripts/smtc_probe.ps1 b/.claude/skills/verify-on-device/scripts/smtc_probe.ps1 new file mode 100644 index 00000000..f8ad3478 --- /dev/null +++ b/.claude/skills/verify-on-device/scripts/smtc_probe.ps1 @@ -0,0 +1,81 @@ +# Reads what Windows actually believes about each media session's transport +# controls, straight from WinRT. +# +# Use this instead of screenshotting the media flyout: the popup dismisses on +# focus change, and FMP does not reliably hold the foreground on this host. The +# session manager is readable from any process, so the app does not need to be +# focused -- or even visible -- for this to work. +# +# MUST run under Windows PowerShell 5.1 (`powershell.exe`). PowerShell 7 +# (`pwsh`) has no WinRT projection and `Add-Type -AssemblyName +# System.Runtime.WindowsRuntime` fails there. +# +# powershell.exe -NoProfile -ExecutionPolicy Bypass ` +# -File .claude/skills/verify-on-device/scripts/smtc_probe.ps1 [-AppFilter fmp] +# +# Exit codes: 0 = at least one session found, 1 = no sessions, 2 = WinRT failed. + +[CmdletBinding()] +param( + # Substring match against SourceAppUserModelId. Omit to list every session. + [string]$AppFilter = '' +) + +$ErrorActionPreference = 'Stop' + +try { + Add-Type -AssemblyName System.Runtime.WindowsRuntime + + $asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | + Where-Object { + $_.Name -eq 'AsTask' -and + $_.GetParameters().Count -eq 1 -and + $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' + })[0] + + function Await($operation, $resultType) { + $task = $asTaskGeneric.MakeGenericMethod($resultType).Invoke($null, @($operation)) + if (-not $task.Wait(5000)) { throw 'WinRT call timed out after 5s' } + $task.Result + } + + $managerType = [Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager, Windows.Media.Control, ContentType=WindowsRuntime] + $manager = Await ($managerType::RequestAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]) +} +catch { + Write-Output "WINRT_FAILED $($_.Exception.Message)" + exit 2 +} + +$sessions = @($manager.GetSessions()) +if ($AppFilter) { + $sessions = @($sessions | Where-Object { $_.SourceAppUserModelId -like "*$AppFilter*" }) +} + +Write-Output ("PROBE_AT={0} SESSIONS={1}" -f (Get-Date -Format 'o'), $sessions.Count) +if ($sessions.Count -eq 0) { + Write-Output 'NO_SESSION (is playback actually running?)' + exit 1 +} + +foreach ($session in $sessions) { + $info = $session.GetPlaybackInfo() + $controls = $info.Controls + + $title = '' + try { $title = (Await ($session.TryGetMediaPropertiesAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionMediaProperties])).Title } + catch { $title = '' } + + Write-Output ("APP={0}" -f $session.SourceAppUserModelId) + Write-Output (" TITLE={0}" -f $title) + Write-Output (" STATUS={0}" -f $info.PlaybackStatus) + Write-Output (" IsNextEnabled={0}" -f $controls.IsNextEnabled) + Write-Output (" IsPreviousEnabled={0}" -f $controls.IsPreviousEnabled) + Write-Output (" IsPlaybackPositionEnabled={0}" -f $controls.IsPlaybackPositionEnabled) + Write-Output (" IsShuffleEnabled={0}" -f $controls.IsShuffleEnabled) + Write-Output (" IsRepeatEnabled={0}" -f $controls.IsRepeatEnabled) + Write-Output (" IsPlayEnabled={0} IsPauseEnabled={1} IsStopEnabled={2}" -f ` + $controls.IsPlayEnabled, $controls.IsPauseEnabled, $controls.IsStopEnabled) +} + +exit 0 diff --git a/.claude/skills/verify-on-device/scripts/window_shot.ps1 b/.claude/skills/verify-on-device/scripts/window_shot.ps1 new file mode 100644 index 00000000..41d93cb8 --- /dev/null +++ b/.claude/skills/verify-on-device/scripts/window_shot.ps1 @@ -0,0 +1,78 @@ +# 只截 App 視窗(不含桌面與其他視窗,截圖不會帶到個人資訊),存成 PNG。 +# +# 兩種用法: +# 啟動並截圖: ... -Exe [-ArgLine '--fmp-dev-playback'] -Out [-Wait 8] [-KeepRunning] +# 接上已開的: ... -Attach -Out (找行程 -Proc、視窗標題 -Title 的, +# 預設 fmp 與 FMP Dev;舊版與 prod 也叫 fmp.exe) +# +# 預設截完就結束自己啟動的行程;加 -KeepRunning 讓它繼續開著(之後要跑 +# msaa_tree.ps1 或繼續操作時)。-Attach 永遠不結束行程。 +# 以 PrintWindow(PW_RENDERFULLCONTENT)截圖,視窗被別的視窗蓋住、不在最上層也 +# 截得到;視窗最小化時截不到,會以 exit 1 結束。 +# +# powershell.exe -NoProfile -ExecutionPolicy Bypass ` +# -File .claude/skills/verify-on-device/scripts/window_shot.ps1 -Exe <路徑> -Out +# +# 輸出檔放在 session 的暫存目錄,不要放進 repo。 +# Exit codes: 0 = ok, 1 = 沒有視窗或視窗最小化, 2 = 參數錯誤。 + +[CmdletBinding()] +param( + [string]$Exe = '', + [string]$ArgLine = '', + [Parameter(Mandatory)][string]$Out, + [int]$Wait = 8, + [switch]$KeepRunning, + [switch]$Attach, + [string]$Proc = 'fmp', + [string]$Title = 'FMP Dev' +) + +# 底下的 C# 區塊只能寫 ASCII(Windows PowerShell 5.1 以系統代碼頁讀暫存 .cs)。 +Add-Type -AssemblyName System.Drawing +Add-Type -TypeDefinition @" +using System; using System.Runtime.InteropServices; +public class WinShot { + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint f); + [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); + [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr v); + public struct RECT { public int L, T, R, B; } +} +"@ +# 物理像素:不設的話 GetWindowRect 回傳被縮放過的座標,截圖會被裁掉。 +[WinShot]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null + +$started = $false +if ($Attach) { + $p = Get-Process -Name $Proc -ErrorAction SilentlyContinue | + Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -eq $Title } | + Select-Object -First 1 + if (-not $p) { Write-Error "no '$Title' window for process '$Proc'"; exit 1 } +} else { + if (-not $Exe) { Write-Error '-Exe or -Attach is required'; exit 2 } + $startArgs = @{ FilePath = $Exe; PassThru = $true } + if ($ArgLine) { $startArgs.ArgumentList = $ArgLine } + $p = Start-Process @startArgs + $started = $true + Start-Sleep -Seconds $Wait +} + +try { + $p.Refresh() + $h = $p.MainWindowHandle + if ($h -eq [IntPtr]::Zero -or [WinShot]::IsIconic($h)) { + Write-Error 'no visible main window'; exit 1 + } + $r = New-Object WinShot+RECT + [WinShot]::GetWindowRect($h, [ref]$r) | Out-Null + $bmp = New-Object System.Drawing.Bitmap ($r.R - $r.L), ($r.B - $r.T) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $dc = $g.GetHdc() + [WinShot]::PrintWindow($h, $dc, 2) | Out-Null + $g.ReleaseHdc($dc) + $bmp.Save($Out, [System.Drawing.Imaging.ImageFormat]::Png) + "saved $Out ($($bmp.Width)x$($bmp.Height)) pid=$($p.Id)" +} finally { + if ($started -and -not $KeepRunning) { Stop-Process -Id $p.Id -ErrorAction SilentlyContinue } +} diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md index 8e27f3d6..e28a4ccc 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md @@ -26,7 +26,7 @@ ## 進度與交接(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 網路層)、#183(PR 9a JS 執行環境)、#184(PR 9b 契約執行器)、#185(PR 9c 紀錄)、#186(遮蔽修正)、#187(PR 10 播放核心)。#176 是 CI 路徑探測,已關閉。 +- **已合併進 `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 網路層)、#183(PR 9a JS 執行環境)、#184(PR 9b 契約執行器)、#185(PR 9c 紀錄)、#186(遮蔽修正)、#187(PR 10 播放核心)、#188(YouTube.js 探針結論)。#176 是 CI 路徑探測,已關閉。 - **isar/sqlite3 共存探針**:已完成,兩平台共存、全部 16KB 對齊(`research/isar-sqlite3-coexistence.md`,ADR 0010 已補)。 - **PR 9a 完成**(#183,子任務已 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`,有變異驗證)。 @@ -37,7 +37,8 @@ - **遮蔽修正**:`hdnts`/`buvid` 進內建名單、同 host 的規則合併套用;fmp-plugins 的 B 站 fixture 同步重新遮蔽。 - **PR 10 完成**(#187,子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-playback-core/`):播放核心、兩個後端、前瞻交接。dev 入口 `--fmp-dev-playback`(可加 `=<曲目鍵>`);實機數字在該子任務與 #187 描述。 - **YouTube.js 探針完成**:通過(VISIONOS client),M3 的 YouTube 走插件;程式碼在分支 `probe/youtubejs`(已 push,不合併),結論在 `.trellis/tasks/archive/2026-09/09-30-youtubejs-probe/`,ADR 0014 §決定 10 已補。Android 只驗到音訊系統層(模擬器 `-no-audio`)。 -- **下一步**:11 verify-on-device → 12 UI → 13 五平台建置與發版 workflow → 里程碑驗收。 +- **PR 11 完成**(子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-verify-on-device/`):`.claude/skills/verify-on-device/`;實機驗證一律照它做,回報含平台與模式。Windows 腳本以視窗標題 `FMP Dev` 比對,避免點到同名 `fmp.exe` 的舊版。 +- **下一步**:12 UI → 13 五平台建置與發版 workflow → 里程碑驗收。 - **擁有者決定**:1–8 都在父任務 `prd.md`「擁有者的決定」。9a、9b 期間新增了三項: - 決定 6:插件安裝檔是單一 `.js`,開頭帶 `==FMP Plugin==` manifest; - 決定 7:插件在背景 isolate 執行;逾時先送存活探測,沒回應才停用到重啟; @@ -48,7 +49,7 @@ 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`); + 6. 使用者看得到的改動,由主對話照 `verify-on-device` skill 實機驗證(Android 與 Windows,回報含平台與模式); 7. 派 opus `trellis-check`,要它試著攻破安全相關的部分; 8. 把後續待辦寫進本檔; 9. `git checkout --` 還原只有換行差異的產生檔(`generated_plugin*`、`app_database.g.dart`、`GeneratedPluginRegistrant.swift`)——**逐檔**用 `git diff --ignore-all-space --ignore-cr-at-eol` 確認是空的才還原;加了原生插件的 PR 有真正新增的註冊,整批還原會把它們弄丟(PR 10 踩過,`flutter pub get` 可重新產生); @@ -210,6 +211,7 @@ PR 10 留下的後續: - [ ] 開不起來的串流在換過候選後對應 `Unsupported`,ADR 0013 會顯示成「視為 bug」的通用訊息;CDN 403 落到這裡不貼切,PR 12 做提示時再看。 - [ ] 前瞻開不起來時兩個後端的行為沒有契約案例(Android 會被當成目前這首中斷;Windows 可能卡在 Playing);前瞻解析比目前這首播完還慢時會多解析一次。M2 補契約案例。 - [ ] 被取代的 `resolveStream` 只丟結果、不取消網路工作(`SourcePlugin` 沒有取消參數)。 +- [ ] `.trellis/spec/app/playback/index.md` 的「實機驗證」段與 `verify-on-device` skill 的建置、安裝、`am start` 步驟重複;改成指向 skill(PR 12 動到播放時順手)。 - [ ] 媒體 CDN 的簽名參數目前是整個拿掉;內建名單每變嚴格一次,既有 fixture 就過不了「再遮蔽一次不變」的掃描(遮蔽修正 PR 時手動改了 fmp-plugins 的 24 個網址)。審查建議改成「值換成 `***`」:已遮過的不再誤紅、明文照樣紅。改動是 `_redactMediaUrl` 一行加既有測試期望,M3 插件庫 CI 上線前做。 - [ ] 插件每重新載入一次,`Redactor._mediaCdns` 就多一份相同規則(輸出不受影響,只是多掃);M3 插件頁的重新載入出現時一併去重。 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json index 7652adca..30e8e242 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json @@ -32,7 +32,8 @@ "09-30-bilibili-plugin", "09-30-redaction-cdn-rules", "09-30-playback-core", - "09-30-youtubejs-probe" + "09-30-youtubejs-probe", + "09-30-verify-on-device" ], "parent": "09-26-fmp-rewrite", "relatedFiles": [], diff --git a/.trellis/tasks/archive/2026-09/09-30-verify-on-device/check.jsonl b/.trellis/tasks/archive/2026-09/09-30-verify-on-device/check.jsonl new file mode 100644 index 00000000..1fb2f5a8 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-verify-on-device/check.jsonl @@ -0,0 +1,7 @@ +{"file": "docs/adr/0027-on-device-verification.md", "reason": "On-device verification decisions"} +{"file": ".claude/skills/verify-legacy-on-device/SKILL.md", "reason": "Legacy skill structure to adapt"} +{"file": ".claude/skills/verify-legacy-on-device/references/android.md", "reason": "Legacy Android reference"} +{"file": ".claude/skills/verify-legacy-on-device/references/windows.md", "reason": "Legacy Windows reference"} +{"file": ".claude/skills/verify-legacy-on-device/references/runtime-state.md", "reason": "Legacy runtime state reference"} +{"file": ".trellis/spec/app/platform/index.md", "reason": "Data directories and identity"} +{"file": ".trellis/spec/app/playback/index.md", "reason": "Dev playback entry"} diff --git a/.trellis/tasks/archive/2026-09/09-30-verify-on-device/implement.jsonl b/.trellis/tasks/archive/2026-09/09-30-verify-on-device/implement.jsonl new file mode 100644 index 00000000..1fb2f5a8 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-verify-on-device/implement.jsonl @@ -0,0 +1,7 @@ +{"file": "docs/adr/0027-on-device-verification.md", "reason": "On-device verification decisions"} +{"file": ".claude/skills/verify-legacy-on-device/SKILL.md", "reason": "Legacy skill structure to adapt"} +{"file": ".claude/skills/verify-legacy-on-device/references/android.md", "reason": "Legacy Android reference"} +{"file": ".claude/skills/verify-legacy-on-device/references/windows.md", "reason": "Legacy Windows reference"} +{"file": ".claude/skills/verify-legacy-on-device/references/runtime-state.md", "reason": "Legacy runtime state reference"} +{"file": ".trellis/spec/app/platform/index.md", "reason": "Data directories and identity"} +{"file": ".trellis/spec/app/playback/index.md", "reason": "Dev playback entry"} diff --git a/.trellis/tasks/archive/2026-09/09-30-verify-on-device/prd.md b/.trellis/tasks/archive/2026-09/09-30-verify-on-device/prd.md new file mode 100644 index 00000000..853f20e0 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-verify-on-device/prd.md @@ -0,0 +1,53 @@ +# app 的 verify-on-device skill(M1 PR 11) + +父任務:`../09-28-m1-skeleton-tracer`(implement「11.」、design §6)。依據 ADR 0027(決定 1–3、如何確認)與父任務 prd 的決定 1(舊 skill 改名 `verify-legacy-on-device`,`verify-on-device` 這個名字給 `app/`)。 + +## 做什麼 + +1. **新 skill**:`.claude/skills/verify-on-device/`。 + - `SKILL.md` 是閉環:啟動 → 執行 → 觀察 → 操作 → 收尾;做不到就具名回報 blocker。 + - 預設模式:dev flavor 加測試插件;真實連線只在 ADR 0027 §決定 2 的條件下使用。 + - 平台分工照 ADR 0027 §決定 3:每個 PR 都驗 Android 與 Windows。 + - 回報格式必含「平台」與「模式:重播/真實」,真實時列出做了哪些請求。 + - `references/android.md`: + - 模擬器; + - 安裝 dev APK; + - `run-as` 把插件複製進 `files/`; + - 以 `am start ... --esal dart_entrypoint_args` 帶開發入口的參數,並說明逗號會切陣列; + - Git Bash 要設 `MSYS_NO_PATHCONV=1`; + - `logcat -s flutter`; + - `dumpsys audio` 的焦點紀錄; + - `screencap`。 + - 絕對不能動模擬器上的舊版 `com.personal.fmp`,prod APK 不要裝上去。 + - 模擬器若是 `-no-audio` 啟動的,要寫進回報。 + - `references/windows.md`: + - `flutter build windows --flavor dev --debug` 與產物路徑; + - dev 是單一實例,其他 worktree 的 FMP Dev 也算; + - log 在 `userdata-dev\logs\fmp.jsonl`; + - 用 MSAA 讀畫面文字、點按鈕; + - 只截 App 視窗,截圖不得含個人資訊; + - 用 SMTC 探針(M2 才用得到,先寫明)。 + - `references/runtime-state.md`:改寫成 `app/` 的現況: + - drift 的 `fmp.db`; + - dev 與 prod 的資料目錄:Windows 免安裝版是 `userdata-dev/`、`userdata/`,安裝版在 `%APPDATA%`;Android 是 `files/`; + - 插件資料表; + - log 檔的格式; + - 怎麼清成乾淨狀態(先看再刪,只刪 dev 的資料); + - 開發入口 `--fmp-dev-plugin=`、`--fmp-dev-playback[=<曲目鍵>]` 與環境變數 `FMP_DEV_PLUGIN`。 + - `scripts/`: + - 從舊 skill 複製 `ax_flatten.py`、`msaa_tree.ps1`、`smtc_probe.ps1`,舊 skill 原樣保留、不共用(design §6); + - 腳本內寫死的舊路徑、程序名稱,改成 `app/` 的; + - 另外加視窗截圖腳本,草稿在本任務目錄的 `window_shot_draft.ps1`:以 `PrintWindow` 只截 App 視窗。 +2. **`app/AGENTS.md` 的驗證段**(ADR 0027 §如何確認): + - 寫明預設重播或測試插件、真實連線的條件、每個 PR 都驗 Android 與 Windows; + - 回報格式要有「平台」與「模式」; + - 指向這個 skill; + - 已有的內容整合進去,不重複。 +3. **根目錄 `AGENTS.md` 的地圖**:`.claude/skills/` 那一列補上 `verify-on-device`。 + +## 驗收 + +- [ ] 用新 skill 照步驟,在 Windows 與 Android 模擬器各把 dev 版啟動到首頁,也就是目前的身分頁。回報附平台與模式。這一步由主對話執行。 +- [ ] 三支複製過來的腳本在 `app/` 的 dev 版上實際跑過至少一次;SMTC 目前沒有東西可讀,寫明。 +- [ ] skill 內容沒有舊專案的路徑或名稱殘留;個人路徑一律以佔位符表示。 +- [ ] 文件用繁中,指令、路徑、識別符保留原文。 diff --git a/.trellis/tasks/archive/2026-09/09-30-verify-on-device/task.json b/.trellis/tasks/archive/2026-09/09-30-verify-on-device/task.json new file mode 100644 index 00000000..26b16c0d --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-verify-on-device/task.json @@ -0,0 +1,26 @@ +{ + "id": "verify-on-device", + "name": "verify-on-device", + "title": "app 的 verify-on-device skill", + "description": "M1 PR 11: verify-on-device skill for app/ (Android, Windows, runtime state) and the app/AGENTS.md verification section", + "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/verify-on-device", + "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/AGENTS.md b/AGENTS.md index 0bb83efc..886987af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ ADR 0026). Human-facing docs live in `docs/`; `docs/README.md` is the map. | `app/` | The new app — its own Flutter project and pub workspace root | `app/AGENTS.md` | | `docs/adr/` | Decisions. 0008 onward is the rewrite; 0001–0007 describe the old app only | — | | `.github/workflows/` | `ci.yml` splits by changed path — `app/**` and `.github/**` run the `app` job, anything outside `app/` runs the old app's jobs — and `CI Result` fails if any of them did; `release.yml` releases the old app | — | -| `.claude/skills/` | `verify-legacy-on-device` for old-app hotfixes; `trellis-*` come with Trellis | — | +| `.claude/skills/` | `verify-on-device` for `app/`, `verify-legacy-on-device` for old-app hotfixes; `trellis-*` come with Trellis | — | | `.trellis/` | Tasks and specs: `spec/legacy/` for the old app, `spec/app/` for the new one, `spec/guides/` shared | — | Claude Code loads a subdirectory's `AGENTS.md` only when it reads a file there, diff --git a/app/AGENTS.md b/app/AGENTS.md index 755b326c..922b6e3d 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -46,12 +46,17 @@ ### 實機驗證 -- 預設用 dev flavor 加測試插件,或把插件切成重播模式(ADR 0027 §決定 1)。 -- 只有改動本身是插件、網路層、登入或正在錄 fixture 時才用真實連線,只做最少的 - 操作,回報寫明「模式:真實」與做了哪些請求(ADR 0027 §決定 2)。 -- 每個使用者看得到的 PR 都要在 Android 模擬器與 Windows 驗(ADR 0027 §決定 3)。 -- `app/` 版的 `verify-on-device` skill 在 M1 PR 11 才建立;在那之前照上面三條手動 - 驗,回報寫明平台與模式。根目錄的 `verify-legacy-on-device` 只給舊專案用。 +操作步驟在 skill `.claude/skills/verify-on-device/`;根目錄的 `verify-legacy-on-device` 只給舊專案。 +規則是 ADR 0027,實機驗證無法寫成測試,守它的是 review: + +- **預設重播**:dev flavor 加內附測試插件(`--fmp-dev-playback`);App 有每插件的重播開關 + 之後也可以用它(§決定 1)。 +- **真實連線的條件**:改動本身是插件、網路層、登入,或正在錄 fixture;只做最少的操作, + 不批次、不迴圈(§決定 2)。 +- **每個使用者看得到的 PR 都要在 Android 模擬器與 Windows 各驗一次**(§決定 3)。 +- **回報要有「平台」與「模式:重播/真實」**,真實時列出做了哪些請求;缺任一項 review 退回。 + 截圖與回報不得含個人資訊(Windows 的身分頁會印出含使用者名稱的資料目錄路徑)。 +- 驗證只用 dev flavor;模擬器上的舊版 `com.personal.fmp` 不碰,prod APK 不安裝。 ## App 身分