DuckTerm Field Notes

A 21h 54m goal-driven engineering story

把 iOS 獨生子,養成 Android 三好生

一次近乎全自動的跨平台適配實驗:從Ghostty 黑屏、Mosh 失語,到Chrome 權限偵探、Google Play 上架、RevenueCat 商品閉環,以及三台設備互相「作證」。

  • Goal-driven
  • iOS → Android
  • Ghostty + Mosh
  • Play + RevenueCat
  • Evidence first
從 iOS 終端跨橋到 Android 終端 左側手機與右側平板透過一座由程式碼、測試和證據構成的橋連接。 iOS / Ghostty $ ssh duck@host PARITY_READY scroll · ime · tui Android / Ghostty $ mosh duck@host SAME_SESSION_OK pixelcopy · pip · pad GOAL fail-closed first 403 ≠ 世界末日 evidence or it didn’t happen
21h54m邏輯Goal,用時不是單一進程uptime
4,441工具呼叫形態,不是獨立測試數
3API35、Pixel API30、Xiaomi Pad
30/30最終connected suite 快照,不與舊快照相加
259Goal 視窗內的視覺檢查調用

事情起於一個看起來很輕的要求:iOS 已經有的那些好東西,Android 也該有吧?如果把這句話交給傳統專案管理,它會長成一排Jira;交給這次Goal,它長成了Ghostty 原生終端、Mosh 真UDP、Pad 大屏幕、PiP、Widget、通知、Google Play 與RevenueCat——外加一隻逐漸學會看截圖、開瀏覽器、查403、還會懷疑自己的證據的鴨子。

01 / THE CONTRACT

Android 不是iOS 穿了一件綠色外套

所謂“從iOS 適配到Android”,最容易做錯的方式,就是照著頁面截圖逐像素復刻。真正需要遷移的是產品契約:使用者輸入一個中文字元只能到遠端一次;renderer 失敗不能斷SSH;後台回來要看到新frame;訂閱恢復要認得當前plan;Pad 不是一台被橫著放大的手機。

於是每項能力都被翻譯成三層:共享語意、平台實現、證據邊界。iOS 可以用自己的viewport primitive,Android 可以通過Ghostty ABI 3;但「手指向下瀏覽更舊歷史、點擊回到live」必須一致。

flowchart LR
  I["iOS 已有能力"] --> C{"共享產品契約"}
  C --> A["Android 原生實作"]
  A --> D["平台差異策略"]
  D --> V["三設備與真實服務驗證"]
  V --> R{"證據通過?"}
  R -- "否" --> F["定位根因並縮小聲明"]
  F --> V
  R -- "是" --> S["小批複審與原子提交"]
            
圖1:不是複製實現,而是遷移契約。失敗會回到驗證環,而不是用「看起來差不多」放行。
⌨️

輸入契約

IME composition 留在本地,commit exactly once;物理鍵、Ctrl、Alt、sticky modifier 都不能串台。

🖼️

渲染契約

wide cell、selection、snapshot、scrollback 與cursor 必須在真實frame 上連貫。

🛟

失敗契約

GL 失敗切xterm,但SSH transport、ring history 和使用者輸入繼續活著。

💳

商業契約

每月付款、年付、終身在Play 和RevenueCat 中映射一致,缺配置時寧可不賣也不猜SKU。

跨平台不是“長得一樣”,而是“出事時也守同一份承諾”。

02 / THE NATIVE LINE

先讓Ghostty 失敗,再允許它成功

Android Ghostty 的第一原則有點反直覺:一開始絕對不樂觀。同步常數永遠先給`ok=false`,非同步自測必須證明native core、ABI、VT、EGL、shader、texture upload、draw 和readback 都成立,才從xterm 切到Ghostty。

這避免了首幀“先黑一下再說”的經典移動端玄學。健康證明還有2.5 秒上限:GPU 真要進入沉思,用戶最多看2.5 秒spinner,然後得到一台能工作的xterm,而不是一塊禪意十足的永久空白。

stateDiagram-v2
  [*] --> Pending
  Pending --> Ghostty: health proof succeeds before timeout
  Pending --> Xterm: timeout or proof fails
  Ghostty --> Xterm: runtime GL failure
  Xterm --> StableSession: same transport and ring replay
  Ghostty --> StableSession: native renderer active
  StableSession --> [*]
  note right of Ghostty
    Native VT plus full GL path
  end note
            
圖2:renderer 是可替換表面,不是transport。即便GL 在運作中失敗,session 仍繼續。
GHOSTTY_HEALTH
core.ok=true
gl.maxRgbDelta=2

$ printf '中文 😀 é'
中文 😀 é

same session
FORCED_GL_FAIL
renderer unhealthy
→ xterm fallback

$ echo STILL_ALIVE
STILL_ALIVE

connect count: 1
MOSH_ROAM
Wi-Fi off
DURING_OK
Wi-Fi on
AFTER_OK

same PID · same UDP

終端膠片為文章復原示意,不是營運後台原圖;marker、結果與邊界來自真實設備證據。

那些「順手」一起補齊的硬骨頭

輸入法composition、xterm 鍵序、觸摸selection、ActionMode copy、native scrollback、background compositor、PixelCopy probe、CPU snapshot、Pad keyboard focus mode……每一項單看都像P3,疊在一起才決定「這是一台終端,還是一張會閃的黑色矩形」。

03 / THE BROWSER DETECTIVE

瀏覽器裡有權限,但沒有一把現成的鑰匙

Play Console 的權限躺在已經登入的Chrome profile 裡;內建Playwright 是乾淨profile,什麼也看不到;CDP 還沒開。最省事的回答是「請使用者手動操作」。Goal 選擇了另一條路:先找出哪個瀏覽器上下文有權限,再把使用者參與壓縮成一次必要consent。

瀏覽器權限偵探示意圖 Agent 透過截圖辨識 Chrome consent,使用者授權後 CDP 接管頁面,隨後連接 gcloud、Play API 和 RevenueCat。 chrome:// remote debugging 允許遠端調試? 用戶允許 CONTROL PLANE ✓ CDP snapshot + click ✓ gcloud API enablement ✓ Play publisher SA ✓ RevenueCat v2 secrets stay outside repo snapshot then click
圖3:CDP 控頁面,不能控制自己的授權彈跳窗。截圖與OS 自動化負責到達consent,使用者負責信任。

沒有現成橋,就在臨時目錄裡產生一個:MCP SDK + `chrome-devtools-mcp` + 薄stdio client,再用持久PTY 連續發`snapshot → click → snapshot`。它沒有被包裝成“平台能力”,完成後被清理;真正可復用的結論是專用profile、origin allowlist、固定版本和默認脫敏。

一句實用笑話: 403 不一定是Google 不愛你。它可能是OAuth scope、GCP IAM、Play App 權限、Billing permission,或者權限還在路上。先分層,再重試;別拿刷新按鈕當念珠。
sequenceDiagram
  actor U as 使用者
  participant A as Goal Agent
  participant B as Chrome
  participant G as gcloud
  participant P as Play Publisher API
  participant R as RevenueCat API
  A->>B: 發現已有登錄態和目標帳號上下文
  U->>B: 明確允許 remote debugging
  B-->>A: CDP 頁面控制可用
  A->>G: 啟用 API 並建立兩套最小權限身份
  A->>B: 在 Play Console 綁定應用程式權限
  A->>P: 冪等建立 catalog 並上傳 internal AAB
  A->>R: 建立 Android app 與商品映射
  P-->>A: track 與 artifact 狀態
  R-->>A: entitlement 與 offering 狀態
  A-->>U: 只請求業務事實與不可逆發布決策
            
圖4:瀏覽器、Cloud IAM、Play 權限和RevenueCat 是四個控制面。把它們混成一個「登入成功」只會收穫更多403。
04 / THE EVIDENCE LAB

截圖黑了,先別給GPU 寫悼詞

Android 真機驗證最危險的時刻,是第一張截圖看起來非常有說服力。SurfaceView 可能在系統截圖裡全黑;Xiaomi screencap 可能是0 位元組;screenrecord 可能只錄到沒有硬體層的世界。反過來,遠端tmux 看到了marker,也只證明transport 活著,不能證明本地compositor 畫出來了。

三設備證據實驗室 API35 模擬器、Pixel 4 和 Xiaomi Pad 分別承擔新系統、舊系統真機和 OEM 大螢幕驗證。 API35 emulator 新 API · contract Pixel 4 API30 physical IME · FCM · SQLite Xiaomi Pad API33 · MIUI master detail Pad · Surface · rotation
圖5:三台裝置不是同一測試乘三,而是三個互補假設:新API、舊真機、OEM 大螢幕。

於是證據設計成多oracle:唯一marker、UI tree、PixelCopy、CPU raster、PID、dumpsys、logcat、遠端pane。它們必須互相證偽,而不是排隊鼓掌。

flowchart TB
  M["注入唯一 marker"] --> U["UI tree 與控制項狀態"]
  M --> X["Screenshot 或 PixelCopy"]
  M --> S["PID · dumpsys · logcat"]
  M --> T["遠端 SSH/Mosh/tmux 狀態"]
  U --> C{"多個 oracle 一致?"}
  X --> C
  S --> C
  T --> C
  C -- "否" --> A["分類測試工件"]
  A --> N["更換 nonce 與 oracle 重跑"]
  N --> M
  C -- "是" --> B["寫下有邊界的結論"]
            
圖6:證據不是越多越好,而是來源越獨立越好。衝突時先懷疑採集鏈,再懷疑產品,也懷疑自己的第一次判斷。
看見的現象差點得出的錯結論最後使用的正交證據
Pad screenshot 全黑Ghostty compositor 又掛了PixelCopy + CPU bitmap + generation
marker 出現兩次輸入exactly-once 失效新nonce 證明是超時批次與重跑重疊
Wi-Fi 斷開再恢復Mosh 完成跨NAT roam只聲明同session 恢復;真正roam 看網絡identity
Metro 冷啟動ANR2.5 秒renderer defer 卡死embedded/cached bundle 冷啟動無復現
05 / THE LONG GOAL

「全自動」的真相:不是沒人,而是人只出現在值錢的地方

這次Goal 的邏輯長度是21 小時54 分42 秒,但機器uptime 更短。它不是一個神秘進程堅持不睡覺,而是靠Git、tmux、可輪詢工具、計畫狀態、review handoff 和外部系統狀態持續恢復上下文。

本輪使用者實際參與很少:登入RevenueCat,在Chrome/Google 開發者控制台完成少量必須由帳號持有人確認的點擊,並在commit/push 等不可逆動作上給出明確授權。已有登入態發現、絕大多數Play 表單填寫與事實核對、gcloud/權限打通、API 生成、設備驗證、購買/恢復測試、截圖錄影、錯誤恢復和整理,均由Goal 自己閉環。

flowchart TB
  O["穩定 Goal 目標"] --> P["階段計劃與證據門檻"]
  P --> G["Git 原子提交"]
  P --> T["tmux 與可輪詢工具"]
  P --> E["設備和外部服務狀態"]
  P --> R["獨立 reviewer verdict"]
  G --> C["進程或開機變更後繼續"]
  T --> C
  E --> C
  R --> C
  C --> P
            
圖7:長Goal 的持久性來自於可恢復狀態,不來自某個永不退出的進程。

先讀機器,再寫程式碼

瀏覽器、CLI、設備、登入態、已有iOS 契約和倉庫dirty state 都先被識別。

先保證退路

Ghostty、Mosh、Billing、OTA/native skew 都先定義失敗方向,再開啟新能力。

讓真實設備和服務說話

connected tests 之後還有真實SSH、UDP、FCM、Play、RevenueCat 和像素層。

每條高風險線單獨過哨兵

P1/P2 不積壓到最後;reviewer 接受代碼,再接受設備證據。

精確白名單提交

並行工作樹不夾帶,臨時profile、媒體、fixture 和secret 及時清理。

06 / THE SURPRISE LEDGER

最後diff 沒告訴你的十件事

🕵️

自己找到權限入口

從現有Chrome 上下文判斷哪個帳號能操作,而不是要求使用者重新交代環境。

📸

沒有CDP 就先截圖

OS 層到達consent,使用者只做一次必要授權,然後切回DOM 自動化。

🧰

缺工具就造最小工具

現場產生MCP、Play catalog、AAB upload 和RevenueCat reconcile 用戶端。

🔐

兩個SA,不造萬能鑰匙

Billing 與publisher 分離,GCP IAM 與Play App 權限分層。

🧯

強制失敗也不丟session

renderer 崩了換表面,transport 和history 繼續工作。

🎞️

影片黑了會換證據

不用壞掉的採集鏈給產品下結論。

🧪

DEV seam 不偽造產品成功

調試注入仍走產品manager/native gate,DEV Pro 不當購買證據。

🧭

主動縮小聲明

Wi-Fi 恢復就是恢復,不冒充不同NAT 的roam。

🧹

清理也是交付

截圖、影片、profile、fixture、DB、旋轉和輸入法狀態都會恢復。

🪶

把複雜寫成可復用

JSONL 取證、CDP、設備證據、Play/RC bootstrap 都沉澱成手冊。

07 / THE REUSABLE PLAYBOOK

下一個App,可以直接抄走什麼?

Goal-driven Porting, v1

  1. 寫契約,不寫「照iOS 做」。明確成功、失敗、回退和使用者可見語意。
  2. 先找authority。瀏覽器登入態、Cloud IAM、商店權限、裝置和服務各是誰說了算。
  3. 先fail-closed。新native、billing、OTA skew 都要有能工作的舊路徑。
  4. 缺工具就做薄橋。只解決最後一公里,預設臨時、可觀察、可銷毀。
  5. 測試按假設分設備。emulator、舊真機、OEM 大螢幕各有任務,不是數量遊戲。
  6. 唯一marker + 多oracle。像素、結構、系統、transport 和遠端至少兩路互證。
  7. 把4xx 當地圖。scope、IAM、App permission、Billing permission 分層定位。
  8. 每個危險邊界單獨review。小批關閉P1/P2,再跑真實服務。
  9. 需要真人時才出現。本輪實際只有登入、少量consent/console 點擊和git side-effect 授權;其他項目若觸及MFA、法律事實或production,再把這些不可替代的邊界留給人。
  10. 清理並寫下邊界。不留secret、fixture、誇大的「通過」和不可復現的英雄故事。

最後一個反直覺結論

真正有傳播力的AI 工程故事,不是“它連續乾了22 小時”,而是“它知道什麼時候自己幹,什麼時候找證據,什麼時候承認截圖不可信,什麼時候必須讓人點一下”。自動化的上限,不由點擊次數決定,而由邊界感決定。

最好的Agent,不是替你做所有決定;而是把你的參與,壓縮到真正需要拍板的那幾次。