Compare commits
No commits in common. "dd35b561cf4ab6d3dc022b5fe41e3a6f42571af8" and "a6b71ea908677cc89a8a496bf9380049cead769e" have entirely different histories.
dd35b561cf
...
a6b71ea908
9
.gitignore
vendored
9
.gitignore
vendored
@ -15,16 +15,9 @@ Thumbs.db
|
|||||||
# ─────────────────────────────────────
|
# ─────────────────────────────────────
|
||||||
|
|
||||||
# 不進 git 的依賴與產物(決策 R4-D2)
|
# 不進 git 的依賴與產物(決策 R4-D2)
|
||||||
# 注意:使用 /** 而非 /,否則後面的 ! 例外無法 re-include(git 規則:
|
local-tool/vendor/
|
||||||
# 父目錄被排除時無法 re-include 檔案)— R5-6b 需要 macOS ffmpeg binary 進 git
|
|
||||||
local-tool/vendor/**
|
|
||||||
local-tool/dist/
|
local-tool/dist/
|
||||||
local-tool/payload/
|
local-tool/payload/
|
||||||
# R5-6b:macOS LGPL ffmpeg binary 進 git(沒有現成 LGPL binary 來源,自 build 成本高,
|
|
||||||
# commit 後開發者 clone 即可用,不必每次重 build ~15 分鐘)
|
|
||||||
!local-tool/vendor/ffmpeg/
|
|
||||||
!local-tool/vendor/ffmpeg/macos/
|
|
||||||
!local-tool/vendor/ffmpeg/macos/**
|
|
||||||
|
|
||||||
# Go server 的 embed 與 build 產物
|
# Go server 的 embed 與 build 產物
|
||||||
local-tool/server/web/out/
|
local-tool/server/web/out/
|
||||||
|
|||||||
@ -1,419 +0,0 @@
|
|||||||
# PM 第一輪分析 — visionA-local 方向變更(2026-04-14)
|
|
||||||
|
|
||||||
> 作者:PM Agent
|
|
||||||
> 性質:**第一輪分析筆記,非正式 PRD 修訂**
|
|
||||||
> 觸發事件:使用者在 M7 收尾階段提出三項重大方向變更
|
|
||||||
> 對應使用者訊息:「推論只需包含 camera / image / video upload」「模型只預設 + 只能上傳」「介面希望是網頁不是包在應用程式中」「Wails 負責顯示 server log / 啟停重啟 / 打開 localhost 網頁」
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 摘要(3 行)
|
|
||||||
|
|
||||||
1. **這是一個 L 級變更,不是 S 級**:三項變更疊加起來等於把 visionA-local 從「Wails 內嵌式桌面 app」重新定位為「Ollama / LM Studio / Stable Diffusion WebUI 式的 server 管理器 + 獨立 web UI」,PRD 第 1 章(定位)、第 2 章(使用情境)、第 4 章(功能清單)、第 6 章(非功能需求)、第 8 章(風險)全部要改,競品類比也要換。
|
|
||||||
2. **最大的哲學衝突**:原 PRD 的 北極星指標是「內部 FAE 在客戶現場 5 分鐘內跑出第一次推論 ≥ 95%」,現在多一步「要使用者先在桌面 app 按 Start Server → 再點 Open Browser → 瀏覽器才看到 UI」,這對「零摩擦 3 分鐘 demo」體驗是**退步而非進步**,PM 必須先釐清使用者動機,否則會做出一個「技術上更乾淨、但使用體驗更差」的產品。
|
|
||||||
3. **目前開發階段是最糟的時機點**:M1-M6 已經做完,dmg 220MB 可跑,M7 Windows build 在收尾。此時做這種方向轉彎代價很高——前端 Sidebar / 4 主導航 / Workspace / Settings 全部要重新思考「誰住在 Wails 裡、誰住在瀏覽器裡」。但如果使用者的動機夠強(見 C1),延後改比現在改更貴。**必須先釐清動機再做決定,不要為了「使用者說了就做」而跳過第一輪分析**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 變更解讀
|
|
||||||
|
|
||||||
### A1. 影片來源變更(砍 URL / yt-dlp)
|
|
||||||
|
|
||||||
**新要求**:推論只包含 `camera / image / upload 影片(avi, mpeg, mp4 瀏覽器能吃的格式)`。
|
|
||||||
|
|
||||||
**PM 解讀**:
|
|
||||||
- 使用者明確砍掉 `/api/media/url`(YouTube / yt-dlp 相關)。
|
|
||||||
- 「瀏覽器能吃的格式」是一個**關鍵措辭**——暗示使用者的 mental model 從「我用桌面 app 解影片」轉向「我用瀏覽器播影片」。這跟 A3 介面變更是同一件事的兩面。
|
|
||||||
- 但這裡有個**語意陷阱**:瀏覽器能播的 `<video>` 格式(主要是 MP4 H.264/AAC、WebM VP9/Opus、Ogg)**不等於** Kneron 推論管線能吃的格式。推論仍然需要 ffmpeg decode 成 frame 餵給模型。使用者可能沒意識到這一點。**這題必須回去澄清**(見 C1)。
|
|
||||||
- 原本 `/api/media/upload/video` 已經在保留清單,所以「上傳影片」不是新功能,**真正被砍的只是 URL 模式**。
|
|
||||||
|
|
||||||
**影響範圍**(具體):
|
|
||||||
- 後端:`POST /api/media/url` 整個 endpoint 拔掉;`server/internal/media/url_handler.go` 或類似檔案整包刪。
|
|
||||||
- Bundle:**yt-dlp 35MB 可以拔掉**,dmg 從 220MB 降到約 185MB,符合 6.2 節 macOS ≤ 220MB 的原目標值(目前是壓著上限走)。**這是個體積紅利**。
|
|
||||||
- 前端:`媒體上傳`頁面的「URL 貼上」輸入框、「從 YouTube 下載」tab、相關 Loading 狀態(對應 recent commit `a6b71ea fix(local-tool): URL 影片載入提示「正在解析,請耐心等待」`)全部拔掉。
|
|
||||||
- 授權宣告:`4.8 第三方授權宣告`的 yt-dlp 條目可以移除,LICENSE-yt-dlp.txt 不用打包。
|
|
||||||
- Risk:P2(FAE 不知道有新版)不受影響;但原 R11(發佈通路)如果本來要放 yt-dlp 需要高流量下載則壓力降低。
|
|
||||||
|
|
||||||
**還沒確認的疑問**:
|
|
||||||
- 「camera / image / upload 影片」這個措辭漏掉了**批次影像** `POST /api/media/upload/batch`。使用者是忘了、還是也要砍?(見 C2)
|
|
||||||
- 砍 URL 模式是否連帶砍 `US-8`(User Story)?我傾向砍,但要使用者確認。
|
|
||||||
|
|
||||||
### A2. 模型管理變更(只預設 + 只能上傳)
|
|
||||||
|
|
||||||
**新要求**:模型除了預設的幾種只能用上傳的。
|
|
||||||
|
|
||||||
**PM 解讀**:
|
|
||||||
- 現況:預設 8 個 .nef(73MB 內嵌)+ 支援使用者上傳自己的 .nef。
|
|
||||||
- 新要求:跟現況幾乎一樣,**重點是明確否定「從 URL / Model Zoo 下載」這類未來可能性**。
|
|
||||||
- 這有兩個可能讀法:
|
|
||||||
- **讀法 A(保守)**:使用者只是確認「不要做 Model Zoo / 遠端下載功能」,目前的預設 + 上傳就是最終狀態。
|
|
||||||
- **讀法 B(更激進)**:使用者可能想重新檢視預設清單——是要維持 8 個?還是想精簡到 3-4 個最有代表性的?
|
|
||||||
- 我**傾向讀法 A**,因為原 PRD 本來就沒規劃 Model Zoo,這句話更像是「再次確認非目標」而非新增要求。但要回去跟使用者確認(見 C3)。
|
|
||||||
|
|
||||||
**還沒確認的疑問**:
|
|
||||||
- 使用者上傳 .nef 後,是否需要命名、分類、加說明文字、刪除?原 feature-inventory `model-upload-dialog` 應該已經涵蓋,要請前端 lead 回看現況。
|
|
||||||
- 預設模型的「重置」按鈕是否需要?(使用者不小心刪掉預設模型後能不能還原)
|
|
||||||
|
|
||||||
**影響範圍**:
|
|
||||||
- 若讀法 A 正確,**這項變更幾乎不影響程式碼**,PRD 只需在 4.1 / 4.2 / 4.5 章節明確寫「只預設 + 只上傳,不支援任何形式的遠端模型下載」,並把它列入第 3 章非目標。
|
|
||||||
- 若讀法 B 正確,需要重新評估預設模型清單,但這是 Architect / KneronPLUS 範疇的事。
|
|
||||||
|
|
||||||
### A3. 介面架構巨變(Wails 內嵌 → Wails server 控制台 + 瀏覽器 UI)
|
|
||||||
|
|
||||||
**這是最大的、最需要深挖的變更。**
|
|
||||||
|
|
||||||
**新要求**(逐字擷取):
|
|
||||||
> 我想像中的是 visionA local 安裝完 啟動後 應用程式介面會有可以顯示 local server log 的地方
|
|
||||||
> 有可以啟動/停止 重啟 local server 的介面 有打開 localhost 網頁的介面
|
|
||||||
> 網頁上會有 scan/connect device 的介面 選模型/上傳模型 推論的介面
|
|
||||||
|
|
||||||
**PM 解讀**:
|
|
||||||
這實質上是把 visionA-local 從「Wails webview 直接顯示 Next.js UI」退化成**「兩個 app」**:
|
|
||||||
|
|
||||||
1. **Wails 桌面 app(server 控制台)**:只負責
|
|
||||||
- 顯示 Go server 的 stdout / log(類似 Docker Desktop 的 Logs 面板)
|
|
||||||
- 啟動 / 停止 / 重啟 local server 的按鈕
|
|
||||||
- 「Open in Browser」按鈕(點了開瀏覽器到 `http://localhost:{port}`)
|
|
||||||
- 可能還要顯示:server 狀態(running / stopped / error)、port 號、資料目錄路徑、版本號
|
|
||||||
- **不顯示推論 UI**
|
|
||||||
2. **瀏覽器網頁(真正的 UI)**:跟現在的 Next.js 前端幾乎一樣(但要適應沒有 Wails bridge 的情況)
|
|
||||||
- Scan / Connect Device
|
|
||||||
- 選模型 / 上傳模型
|
|
||||||
- 推論(camera / image / video upload)
|
|
||||||
|
|
||||||
**這是什麼定位?競品類比換人**:
|
|
||||||
- 原本的類比:**Photo Booth、Kneron 官方 demo app**——桌面 app,打開就用。
|
|
||||||
- 新的類比:**Ollama(server 跑在背景 + 獨立 web UI / CLI)、LM Studio(有桌面 app 也有 server 模式)、Stable Diffusion WebUI(點 bat 啟動 + 瀏覽器打開 127.0.0.1:7860)、Docker Desktop(桌面 app 只管 daemon,真正的操作在 CLI / Dashboard)**。
|
|
||||||
- 這個類比轉換會影響**所有對外文案**:
|
|
||||||
- 原本「裝起來就能跑」→ 現在更像「一鍵起 local server,用瀏覽器操作」。
|
|
||||||
- 原本「像一般桌面 app」→ 現在更像「像 Ollama 一樣輕量伺服器」。
|
|
||||||
|
|
||||||
**為什麼使用者會這樣想?PM 的猜測(必須回去問,見 C1)**:
|
|
||||||
我沒有資料支持,只能猜。列出可能動機讓使用者確認:
|
|
||||||
| # | 可能動機 | 我的評估 |
|
|
||||||
|---|---------|---------|
|
|
||||||
| a | **想要多視窗 / 多分頁**,一邊看推論結果一邊看裝置管理 | 合理。Wails 單視窗架構確實限制了這件事 |
|
|
||||||
| b | **想要瀏覽器的開發者工具**(inspect / network / console),除錯方便 | 合理。Wails webview 也能開 devtools 但比瀏覽器難找 |
|
|
||||||
| c | **想要分享 localhost 連結給同網段其他人**(例如客戶的手機開 `http://192.168.1.10:port` 看 demo) | **高度懷疑這一點**——原 PRD 6.6 明確寫「絕對不 bind 0.0.0.0」。若這是動機,等於安全模型也要改 |
|
|
||||||
| d | **不想被 Wails 視窗鎖住**——想讓 server 在背景跑、瀏覽器關掉也沒差,下次要用再開瀏覽器 | 合理。這是 Docker Desktop / Ollama 的體驗 |
|
|
||||||
| e | **熟悉瀏覽器操作習慣** | 合理但薄弱。單一動機不足以支撐這種大改 |
|
|
||||||
| f | **想要能用手機 / 平板當第二螢幕看推論**(同上 c) | 同 c,安全模型風險 |
|
|
||||||
| g | **看不慣 Wails webview 的某些 bug**(例如 splash regression,見 progress.md M7)| 合理。但這是解 bug 不是改架構 |
|
|
||||||
| h | **想把 web UI 獨立出來**,未來可能部署到別處(不綁 Wails) | 合理長期考量,但目前內部工具用不到 |
|
|
||||||
| i | **單純覺得「server + web UI」架構比較乾淨** | 工程師審美考量,PM 角度會挑戰 |
|
|
||||||
|
|
||||||
**不同動機導出不同產品方向**——a, b, d, g 導向「架構輕度重整,還是桌面 app 為主」;c, f 導向「server 要能被同網段連線,安全模型大改」;h 導向「web UI 要獨立可部署,跟 Wails 解耦」。**必須先知道是哪一個,不能一把梭**。
|
|
||||||
|
|
||||||
**PM 的擔憂(一個句子)**:
|
|
||||||
> 這個變更方向在技術上更乾淨,但在使用者體驗上可能是**退步**——因為它把原本「雙擊 app → 看到 UI」的 1 步流程變成「雙擊 app → 按 Start → 按 Open Browser → 看到 UI」的 3 步流程,違反 P1 Persona Arthur 的核心願望「不要為了環境浪費客戶時間」。
|
|
||||||
|
|
||||||
### A3.5 多使用者情境:其他使用者是 FAE 還是外部客戶?
|
|
||||||
|
|
||||||
第四輪決策是「內部工具」,所以沒處理「分享給外部」。但 A3 如果動機是 c(分享 localhost 給同網段),會把產品定位從「內部 FAE 個人工具」推向「內部 FAE 向客戶 demo 的 server」,**這會改變威脅模型**:
|
|
||||||
- 內部 FAE 個人工具:威脅模型 = 本機(127.0.0.1 + 無認證即可)
|
|
||||||
- 向客戶 demo 的 server:威脅模型 = 區網(可能需要密碼 / token / 限制連線 IP / HTTPS 自簽)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 影響範圍
|
|
||||||
|
|
||||||
### B1. PRD 要改的章節(按影響嚴重度排序)
|
|
||||||
|
|
||||||
| 章節 | 子檔案 | 變更性質 | 嚴重度 |
|
|
||||||
|------|--------|---------|--------|
|
|
||||||
| 1. 產品策略與定位 | `strategy.md` | **重寫**。競品類比從 Photo Booth 換成 Ollama / LM Studio;價值主張要重述(「本機 server + 瀏覽器 UI」而非「桌面 app」);北極星指標的 5 分鐘門檻是否還合理需要重評 | 🔴 高 |
|
|
||||||
| 2. 目標使用者與使用情境 | `user-research.md` | **修訂 + 重寫 US**。US-1(第一次安裝)驗收標準 AC-1.3 要加「App 啟動後顯示 server 控制台」;US-2 / US-3 / US-4 / US-5 全部要重寫流程(因為真正操作在瀏覽器而非 Wails 視窗);US-6 原本已砍;US-7(上傳影片)要保留但砍掉 URL;US-8 砍掉;User Journey Map「第一次成功」階段要重畫 | 🔴 高 |
|
|
||||||
| 3. 產品願景與非目標 | `vision-and-non-goals.md` | **補充非目標**。明確寫「不支援遠端模型下載 / Model Zoo」「不支援影片 URL 推論 / yt-dlp」「不 bind 0.0.0.0(除非使用者確認 A3 動機 c/f)」 | 🟡 中 |
|
|
||||||
| 4. 功能清單與 API 對照 | `features/feature-inventory.md` | **大幅修訂**。4.1 API:刪 `/api/media/url`;4.2 前端路由:重新切分「Wails shell 顯示什麼 / 瀏覽器顯示什麼」;4.3 元件:把 cluster UI、relay-token-sync 一樣明確刪,同時新增 server 控制台元件(start/stop 按鈕、log viewer、open browser 按鈕);4.5 新增功能表:加「server 生命週期控制面板」;4.6 要刪的檔案:加 yt-dlp 相關;4.8 授權宣告:刪 yt-dlp 條目 | 🔴 高 |
|
|
||||||
| 5. 使用者流程 | `features/user-flows.md` | **重寫**。First-Run / 日常使用 / 離開的流程都要重寫(Wails app 變成 server 管理器,使用者第一次啟動要引導「點 Open Browser」) | 🔴 高 |
|
|
||||||
| 6. 非功能需求 | `nonfunctional.md` | **修訂量化值**。6.1 首次推論時間要拆得更細(Wails shell 啟動 / server 啟動 / 瀏覽器開啟 / 第一幀);6.2 安裝檔大小目標可以降低(yt-dlp 35MB 拔掉);6.6 安全性需求要根據 A3 動機(c/f)決定是否改 bind IP;6.8 主題跟隨系統這一條在瀏覽器中是「跟隨使用者 OS prefers-color-scheme」而非「Wails webview 繼承 OS」 | 🔴 高 |
|
|
||||||
| 7. 發佈與交付策略 | `release-strategy.md` | 輕微影響。dmg 變小、發佈說明要寫「啟動後點 Open Browser」 | 🟢 低 |
|
|
||||||
| 8. 風險與相依性 | `risks.md` | **新增風險**。見 D 章 | 🟡 中 |
|
|
||||||
|
|
||||||
### B2. 要砍的功能(具體清單)
|
|
||||||
|
|
||||||
| # | 項目 | 出處 | 動作 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| 1 | `POST /api/media/url` endpoint | `feature-inventory.md` 4.1 | 刪除 |
|
|
||||||
| 2 | yt-dlp 內嵌(35MB) | `feature-inventory.md` 4.8 / `progress.md` M6-2 | 從 vendor / payload / installer 全部拔掉 |
|
|
||||||
| 3 | 前端「URL 貼上」UI | `frontend/src/app/media/` 或類似 | 刪除 |
|
|
||||||
| 4 | US-8(YouTube / URL 推論) | `user-research.md` 2.3 | 刪除 |
|
|
||||||
| 5 | 授權宣告中的 yt-dlp 條目 | `feature-inventory.md` 4.8 | 刪除 |
|
|
||||||
| 6 | 「模型從 URL 下載」類功能(若存在)| 需確認 | 確認無後標為非目標 |
|
|
||||||
| 7 | **(待確認)** Wails webview 內嵌 Next.js 前端的架構 | `server/main.go` + Wails config | 改為只 serve server 控制台介面 |
|
|
||||||
| 8 | **(待確認)** Wails 首頁 iframe / navigate 到 Next.js build | 同上 | 改為 server 控制台 UI |
|
|
||||||
|
|
||||||
### B3. 要重寫的 user story
|
|
||||||
|
|
||||||
| US | 原標題 | 變更性質 |
|
|
||||||
|----|--------|---------|
|
|
||||||
| US-1 | 第一次安裝 | **大幅重寫**。AC-1.3「安裝完成後系統中出現 app icon」不變,但要加 AC-1.7「第一次開啟 app 看到 server 控制台,不是 Dashboard」;AC-1.8「控制台有明確引導『按這裡在瀏覽器打開 UI』」 |
|
|
||||||
| US-2 | Mock 模式試玩 | **重寫**。進入 Mock 模式的入口從「First-Run 選擇 Mock」變成「先在控制台啟動 server(選 Mock 模式) → 開瀏覽器 → 看到 Mock UI」 |
|
|
||||||
| US-3 | 連實體 Kneron 裝置 | **中度重寫**。偵測流程不變,但「看到裝置」的 UI 在瀏覽器,不在 Wails 視窗。邊緣情況:若瀏覽器沒開,USB 插入事件要不要 push 到 Wails 控制台 toast? |
|
|
||||||
| US-4 | 切換 / 上傳模型 | **中度重寫**。「拖放 .nef 到視窗任何地方」—— **哪個視窗**?Wails 控制台?瀏覽器?兩邊都要?這是體驗面 Design 要回答的關鍵問題(見 E 章) |
|
|
||||||
| US-5 | 跑即時攝影機推論 | **中度重寫**。Workspace 頁在瀏覽器。這裡有個**瀏覽器特有的新限制**:瀏覽器 `navigator.mediaDevices.getUserMedia()` 取 webcam 需要 HTTPS 或 localhost(目前 localhost 勉強可過但 Chrome 可能提示 permission),跟原 Wails 直接走 Go + AVFoundation / DirectShow 的模型不同。**這是關鍵技術風險**(見 E 章)|
|
|
||||||
| US-7 | 上傳影片 / 圖片做離線推論 | 輕微修訂,格式限定「瀏覽器能吃的」 |
|
|
||||||
| US-8 | YouTube / URL 推論 | **砍掉** |
|
|
||||||
| US-9 | 查看 server 日誌 | **升級**。原本是 Settings 進階分頁的次要功能,現在變成 **Wails 控制台的一級功能**,要獨立一個 AC |
|
|
||||||
|
|
||||||
### B4. 原本架構中「為了 Wails 內嵌」做的設計(請 Architect 補完整清單)
|
|
||||||
|
|
||||||
PM 只能從 PRD / progress.md 看到的表層:
|
|
||||||
|
|
||||||
1. **server/web/out 內嵌**(M1 收尾的 `build-embed` target):把 Next.js `frontend/out` 同步到 `server/web/out`,再 embed 進 Go binary。**這個設計在新架構下仍然有效**——但要考慮 Wails 控制台是用什麼技術?是 Wails 自己的 UI(Go template / Wails 原生 webview 顯示一個極簡 HTML)?還是另一份 Next.js build?
|
|
||||||
2. **Wails `/ipc/raise` endpoint + wails-ipc-port 檔案**(M7 L-3):原本用來讓 server 崩潰後可以叫回 Wails 視窗。新架構下這個還有意義嗎?server 崩潰時需要通知的是**誰**——Wails 控制台?還是瀏覽器?兩邊?
|
|
||||||
3. **Single-instance 保護**(PRD 5.5,第四輪決策):使用者第二次雙擊 app 時 raise 既有視窗。新架構下 single-instance 還是要做,但 raise 什麼——Wails 控制台?還是 focus 既有的瀏覽器 tab?(瀏覽器 tab focus 跨瀏覽器很難做)
|
|
||||||
4. **⌘Shift+R 重啟服務**(第四輪 R4-6):這個快捷鍵是給 Wails webview 用的。新架構下快捷鍵要住在哪——Wails 控制台用 Wails menu bar,瀏覽器網頁如果要也要的話得自己用 JS 監聽(還會被瀏覽器預設快捷鍵衝突)。
|
|
||||||
5. **Mock 模式的 sidebar 標記 / 主視窗標題列標記**(4.5):現在「主視窗」有兩個——Wails 控制台、瀏覽器 tab。兩邊都要標嗎?
|
|
||||||
6. **Wails tray 已砍、改用主視窗 menu bar**(第三輪 Q-A):新架構下 menu bar 屬於 Wails 控制台。那「上傳模型」這種動作,從 Wails menu bar 按下去是要直接處理,還是要切到瀏覽器?
|
|
||||||
7. **Fatal 原生對話框**(L-2):這個維持有效。
|
|
||||||
8. **First-Run 歡迎流程在哪**:原本是 Wails webview 載入 Next.js 前端的第一個畫面。新架構下——是 Wails 控制台放一個「首次啟動引導」(講解控制台怎麼用),還是瀏覽器打開後才看到 First-Run(跟以前一樣)?還是兩邊都要(Wails 講控制台、瀏覽器講功能)?
|
|
||||||
|
|
||||||
**請 Architect 補充:** 現有 `local-tool/server/main.go`、Wails `wails.json`、前端 `next.config.js` 裡面哪些設定是為了內嵌架構而存在、新架構下哪些要改、哪些可以直接沿用。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. 待使用者決策的問題
|
|
||||||
|
|
||||||
> 每題都是「必須先問才能繼續」——不是可以延後的。
|
|
||||||
|
|
||||||
### C1. 🔴【最關鍵】介面架構變更的動機是什麼?
|
|
||||||
|
|
||||||
**為什麼問**:A3 章列出 9 種可能動機(a ~ i),每一種導向不同的產品方向。不問清楚就做會做錯。
|
|
||||||
|
|
||||||
**選項**:
|
|
||||||
- **A**:多視窗 / 多分頁的便利性(可以一邊看推論、一邊看裝置)→ 方向是「架構輕度重整,Wails 只是 server 生命週期管理器,UI 主體仍在本機瀏覽器,**127.0.0.1 不變**」
|
|
||||||
- **B**:想要瀏覽器開發者工具 / 熟悉瀏覽器操作 → 同 A,方向相同
|
|
||||||
- **C**:要分享 localhost 給同網段的其他裝置(客戶的手機、平板)→ 方向是「架構重整 + 安全模型大改 + 要加認證 / token / IP 限制」**這會是另一個 L 級工作**
|
|
||||||
- **D**:想讓 server 在背景跑、瀏覽器關掉也沒差,下次要用再開瀏覽器 → 同 A,方向相同但要加「視窗關閉不終止 server」
|
|
||||||
- **E**:單純看不慣 Wails webview 的某些 bug(例如 splash 問題)→ **建議不要為此改架構**,先解 bug
|
|
||||||
- **F**:想讓 web UI 可獨立部署(未來不綁 Wails)→ 方向是「把 web UI 做得跟 Wails 完全解耦,未來可以 deploy 到別處」
|
|
||||||
- **其他 / 混合**:請使用者直接說
|
|
||||||
|
|
||||||
**PM 建議**:
|
|
||||||
> 我強烈建議使用者選 A / B / D(或混合),理由是這三種動機都能在**不改安全模型**的情況下達成,改動範圍可控。
|
|
||||||
>
|
|
||||||
> 如果選 C 或 F,**我會要求暫停 M7 Windows build**,重新開一輪三方聯合討論,因為這相當於改變產品的核心定位。
|
|
||||||
>
|
|
||||||
> 如果選 E,**我會直接挑戰使用者**:解 bug 比改架構便宜一個數量級,先把 bug 修掉再說。
|
|
||||||
|
|
||||||
### C2. 🟡「camera / image / 上傳影片」有涵蓋批次影像上傳嗎?
|
|
||||||
|
|
||||||
**為什麼問**:原 `/api/media/upload/batch`(批次多張圖片上傳)在 feature-inventory 是保留的,新要求措辭漏掉。
|
|
||||||
|
|
||||||
**選項**:
|
|
||||||
- **A**:保留批次上傳(一次處理多張圖片)
|
|
||||||
- **B**:砍掉,只保留單張圖片 + 單支影片
|
|
||||||
- **C**:保留但降級為 Phase 2
|
|
||||||
|
|
||||||
**PM 建議**:
|
|
||||||
> 保留(選 A)。批次上傳是既有功能,砍掉無理由,對 FAE 用批次跑回歸測試有價值。
|
|
||||||
|
|
||||||
### C3. 🟡 模型管理:「只預設 + 只能上傳」的解讀是 A 還是 B?
|
|
||||||
|
|
||||||
**為什麼問**:見 A2 章節。
|
|
||||||
|
|
||||||
**選項**:
|
|
||||||
- **A**:維持現況的 8 個預設 .nef + 使用者上傳,只是確認「不要做 Model Zoo / 遠端下載」
|
|
||||||
- **B**:重新評估預設清單,可能精簡到 3-4 個
|
|
||||||
- **C**:把預設也砍掉,只留上傳
|
|
||||||
|
|
||||||
**PM 建議**:
|
|
||||||
> 選 A。原 PRD 本來就沒規劃 Model Zoo,這句話更像是再次確認非目標。選 B 會增加決策成本(「要留哪 3 個?」),選 C 會破壞 Mock / 即時體驗(Persona P3 Solution Architect 的 demo 場景沒模型就完蛋)。
|
|
||||||
|
|
||||||
### C4. 🔴 Wails 控制台的 scope 究竟有多小?
|
|
||||||
|
|
||||||
**為什麼問**:使用者說「顯示 log」「啟動/停止/重啟」「打開 localhost」,這是**最小集合**。但還有很多「邊緣功能」不知道要不要也住在 Wails 控制台:
|
|
||||||
|
|
||||||
**選項**(每項都要獨立回答):
|
|
||||||
| 功能 | 要住在 Wails 控制台嗎? | PM 預設建議 |
|
|
||||||
|------|----------------------|------------|
|
|
||||||
| 顯示目前 server port | 是 ✅ | 是(使用者需要知道 URL 是 127.0.0.1:XXXX)|
|
|
||||||
| 顯示資料目錄路徑(macOS `~/Library/Application Support/visiona-local/`)| 是 ✅ | 是(除錯時常用)|
|
|
||||||
| 顯示版本號 / 建置資訊 | 是 ✅ | 是 |
|
|
||||||
| 顯示 server 狀態(running / stopped / error)| 是 ✅ | 是 |
|
|
||||||
| Mock 模式切換按鈕 | **待決定** ⚠️ | 是(Mock 是 server 模式,不是業務模式,住控制台合理)|
|
|
||||||
| 硬體偵測結果(已接上幾台 Kneron)| **待決定** ⚠️ | **不要**(屬於業務資訊,住瀏覽器 UI)|
|
|
||||||
| 上傳模型 | **待決定** ⚠️ | **不要**(業務功能)|
|
|
||||||
| 匯入 / 匯出設定 | **待決定** ⚠️ | 是(屬於「工具維護」)|
|
|
||||||
| 清除 log / 查看歷史 log 檔 | **待決定** ⚠️ | 是 |
|
|
||||||
| 第三方授權宣告 About | **待決定** ⚠️ | 是(Wails About 對話框)|
|
|
||||||
| Settings > 一般 / 語言 | **待決定** ⚠️ | **不要**(屬於瀏覽器 UI 的 Settings 頁)|
|
|
||||||
| 崩潰後的原生對話框通知 | 是 ✅ | 是 |
|
|
||||||
|
|
||||||
請使用者逐項確認,或直接接受 PM 建議。
|
|
||||||
|
|
||||||
### C5. 🔴 Wails 視窗關閉行為
|
|
||||||
|
|
||||||
**為什麼問**:原 PRD Q7 決定「關閉視窗 = 結束程式」(所以砍 tray)。新架構下這個語義要重新定義,因為現在視窗關閉影響兩個東西:server、瀏覽器 UI。
|
|
||||||
|
|
||||||
**選項**:
|
|
||||||
- **A**:關閉 Wails 控制台 = 停 server = 瀏覽器 UI 失效。**最簡單**。但使用者若關了控制台忘了 server 還在跑,不會發生這種狀況。
|
|
||||||
- **B**:關閉 Wails 控制台 ≠ 停 server。關閉後 server 仍在背景跑,下次重開 Wails 控制台 attach 回去。**像 Docker Desktop**。但在 macOS / Windows / Linux 實作有差異,且 single-instance 保護要重寫。
|
|
||||||
- **C**:關閉 Wails 控制台時彈確認對話框問使用者「要不要一起停 server」。
|
|
||||||
|
|
||||||
**PM 建議**:
|
|
||||||
> 初步偏 **A(最簡單)**,除非使用者在 C1 選了動機 D(想讓 server 在背景跑)。若選 D 則必須是 B。
|
|
||||||
>
|
|
||||||
> 這題的決定直接影響 B4 第 3 項 single-instance 設計。
|
|
||||||
|
|
||||||
### C6. 🟡 使用者要不要有機會直接雙擊開瀏覽器(不經過 Wails 控制台)?
|
|
||||||
|
|
||||||
**為什麼問**:既然 UI 在瀏覽器,使用者其實不需要每次都打開 Wails 控制台——他可能想要像 Ollama 那樣「server 背景跑著,直接打開 `http://localhost:XXXX`」。但這跟 C5 的 B 方案強相關。
|
|
||||||
|
|
||||||
**選項**:
|
|
||||||
- **A**:必須先開 Wails 控制台,Wails 控制台負責起 server,然後才能用瀏覽器
|
|
||||||
- **B**:提供 macOS 的 LaunchAgent / Windows 的啟動項 / Linux systemd user unit,讓 server 可以背景常駐(類似 Docker Desktop / Ollama)
|
|
||||||
- **C**:不背景常駐,但提供 CLI 啟動模式(使用者可以自己 `open -a visiona-local` 或 `visiona-local serve`)
|
|
||||||
|
|
||||||
**PM 建議**:
|
|
||||||
> 選 **A** + 未來考慮 C。這是 MVP,先把最小路徑做對。背景常駐會引入 autostart / tray / 開機啟動等一整套複雜度(而 tray 才剛被砍)。
|
|
||||||
|
|
||||||
### C7. 🟡 瀏覽器選擇:使用者預設瀏覽器 or 指定 Chromium-based?
|
|
||||||
|
|
||||||
**為什麼問**:webcam(US-5)在 Safari / Firefox / Chrome 的權限流程和支援度不同。攝影機 overlay 效能也不同。
|
|
||||||
|
|
||||||
**選項**:
|
|
||||||
- **A**:用使用者的 OS 預設瀏覽器(Safari on macOS / Edge on Win / Firefox / Chrome 依系統設定)
|
|
||||||
- **B**:強制 Chrome / Edge 系列,其他瀏覽器警告
|
|
||||||
- **C**:內嵌 Chromium(如 Electron,但整包會變很大 100MB+)
|
|
||||||
|
|
||||||
**PM 建議**:
|
|
||||||
> 選 **A** 但在 README / First-Run 控制台提示「推薦 Chrome / Edge,Safari 可能有攝影機權限限制」。選 C 違反整個 MVP 的瘦身原則(dmg 會從 220MB 變 400MB+)。
|
|
||||||
|
|
||||||
### C8. 🔴 Wails 控制台的首次啟動引導如何設計?
|
|
||||||
|
|
||||||
**為什麼問**:原 PRD 的 First-Run 流程(歡迎 → 選擇模式 → 硬體偵測)是長在 Next.js 前端裡的。新架構下這個流程要**搬家**——是搬去 Wails 控制台、還是維持在瀏覽器但等「使用者第一次開瀏覽器」才看到?
|
|
||||||
|
|
||||||
**選項**:
|
|
||||||
- **A**:整套 First-Run 仍在瀏覽器端,Wails 控制台的首次啟動只是提示「按 Start Server 然後按 Open Browser」
|
|
||||||
- **B**:First-Run 拆成兩段:Wails 控制台引導「怎麼用這個控制台」,瀏覽器端引導「怎麼用 visionA-local 的功能」
|
|
||||||
- **C**:First-Run 整套搬到 Wails 控制台,瀏覽器打開後直接進 Dashboard
|
|
||||||
|
|
||||||
**PM 建議**:
|
|
||||||
> 選 **A**。Wails 控制台是次要視窗,不應該搶 First-Run 戲份。但要確保「按 Open Browser 後」瀏覽器會自動打開到正確的 `http://localhost:XXXX/onboarding` 路由。
|
|
||||||
|
|
||||||
### C9. 🟢 這次變更是否要重做 `progress.md` M7 的 Windows build?
|
|
||||||
|
|
||||||
**為什麼問**:M7 正在收尾,若這輪變更砍了 yt-dlp + 改前端架構,Windows build 可能要等改完再跑。
|
|
||||||
|
|
||||||
**選項**:
|
|
||||||
- **A**:先把 M7 Windows 收完(拿到 Windows 能跑的 baseline),再基於 baseline 套這輪變更
|
|
||||||
- **B**:M7 停擺,本輪變更優先
|
|
||||||
- **C**:M7 和本輪變更平行做(高風險)
|
|
||||||
|
|
||||||
**PM 建議**:
|
|
||||||
> 選 **A**。理由:Windows 能跑的 baseline 是品質保險,即使這輪變更改掉 70% 前端,「Windows 能跑 server + 可以打包」這件事本身不會白費。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. 風險觀察
|
|
||||||
|
|
||||||
### D1. 【新】「零摩擦安裝」承諾被打破
|
|
||||||
|
|
||||||
- **描述**:原 PRD 北極星指標是「5 分鐘內跑第一次推論 ≥ 95%」,P1 Persona Arthur 的一句話是「雙擊 app、插 USB,就能 demo」。新架構多一步「點 Open Browser」,對 Arthur 的心智負擔是 +30%。
|
|
||||||
- **可能性**:高(結構性變更,不是實作品質問題)
|
|
||||||
- **影響**:中-高(可能違反北極星承諾)
|
|
||||||
- **緩解**:(1) Wails 控制台啟動時自動 `Start Server`(不用手動按),(2) Server 就緒後自動 `Open Browser`(不用手動按),(3) Wails 控制台第一次啟動後可以「隱藏到背景」但不終止 server。**這樣 UX 上仍是「雙擊 → 看到 UI」但底層是新架構**。
|
|
||||||
- **等級**:**P0 阻斷項**——若沒做到這三點緩解,我會建議使用者回到原架構或放棄變更。
|
|
||||||
|
|
||||||
### D2. 【新】Safari / Firefox webcam 相容性
|
|
||||||
|
|
||||||
- **描述**:瀏覽器 `getUserMedia` 在 Safari 17+ 雖然支援 localhost HTTP 的 camera permission,但跟使用者的直覺不同;Firefox 也有類似 permission flow 問題。原 Wails 內嵌架構下,直接走 Go + AVFoundation / DirectShow / V4L2,使用者感知不到 permission。
|
|
||||||
- **可能性**:高(每個 macOS 預設 Safari 使用者都會碰到)
|
|
||||||
- **影響**:中(US-5 攝影機推論的起步體驗變差)
|
|
||||||
- **緩解**:(1) README / 首次引導推薦 Chrome / Edge,(2) 在 Wails 控制台的「Open Browser」按鈕旁邊標示「如使用 Safari 需額外允許攝影機權限」,(3) 攝影機啟動失敗時提供具體錯誤訊息。
|
|
||||||
- **等級**:**P1 追蹤項**。
|
|
||||||
|
|
||||||
### D3. 【新】Server 只聽 127.0.0.1 vs 同網段需求的衝突
|
|
||||||
|
|
||||||
- **描述**:原 PRD 6.6 明寫「絕對不 bind 0.0.0.0」。若使用者在 C1 選動機 C / F,就得 bind 0.0.0.0 並加認證,這是另一個 L 級工作。
|
|
||||||
- **可能性**:未知,取決於 C1 答案
|
|
||||||
- **影響**:大(安全模型改寫)
|
|
||||||
- **緩解**:C1 先問清楚,動機若非 C / F 則維持 127.0.0.1
|
|
||||||
- **等級**:待 C1 回答後定級
|
|
||||||
|
|
||||||
### D4. 【新】Wails 控制台的跨平台 UI 一致性風險
|
|
||||||
|
|
||||||
- **描述**:現在的架構下,Wails 只是 webview,UI 本身是 Next.js 跨三平台一致。若控制台改用 Wails 原生 menu + 原生 button,會碰到三平台原生元件差異,重回「跨平台原生 UI」的泥沼。若控制台還是用 webview + HTML,等於又多一份前端 build。
|
|
||||||
- **可能性**:高
|
|
||||||
- **影響**:中(開發成本)
|
|
||||||
- **緩解**:Architect 要在 TDD 裡明確說明「控制台 UI 是用什麼技術」——我建議仍然用 webview + 極簡 HTML 而非 Wails 原生 UI(避免三平台 UI 分岔)。
|
|
||||||
- **等級**:P1
|
|
||||||
|
|
||||||
### D5. 【新】使用者關閉瀏覽器後 server 是否仍在跑的心智模型不清
|
|
||||||
|
|
||||||
- **描述**:跟 C5 相關。Docker Desktop / Ollama 的體驗是「server 是常駐的」,使用者不會覺得怪;但 visionA-local 若採 C5-A(關 Wails 等於關 server),使用者關了瀏覽器再開瀏覽器發現 UI 404 會困惑。
|
|
||||||
- **可能性**:中
|
|
||||||
- **影響**:中
|
|
||||||
- **緩解**:瀏覽器端檢測 server 不可達時顯示「server 已停止,請回到 visionA-local 控制台重新啟動」的友善錯誤頁
|
|
||||||
- **等級**:P2
|
|
||||||
|
|
||||||
### D6. 【新】「單一動機不足,但 sunk cost 足」的心理陷阱
|
|
||||||
|
|
||||||
- **描述**:這是 PM 自己的 meta 風險提醒。使用者花了大量時間做 M1-M6,現在提出巨變。若動機薄弱(例如 E:「看 Wails splash 不順眼」),**改架構的成本遠大於解 bug 的成本**。PM 有責任挑戰這個決定,而不是順從。
|
|
||||||
- **緩解**:C1 是保護機制。若 C1 答案是「沒特別理由」或 E,PM 應該建議**不要改**。
|
|
||||||
- **等級**:管理風險
|
|
||||||
|
|
||||||
### D7. 【既有】P2「FAE 不知道有新版」依然成立
|
|
||||||
|
|
||||||
- **描述**:不受這輪變更影響,但在新架構下「版本號顯示」變得更重要,因為 Wails 控制台是版本資訊的第一個 touchpoint。
|
|
||||||
- **緩解**:Wails 控制台顯眼處(例如視窗標題列)顯示版本號。
|
|
||||||
- **等級**:P2
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. 給 Design 和 Architect 的議題
|
|
||||||
|
|
||||||
### E1. 給 Design 的議題(體驗面)
|
|
||||||
|
|
||||||
1. **Wails 控制台的資訊架構**(對應 C4):要放哪些資訊?排版?色調?這是一個全新頁面,不能從既有 Next.js sidebar 直接搬。建議 Design Agent 畫 wireframe。
|
|
||||||
2. **「Open Browser」按鈕的視覺層級**:這是新架構下**最重要的單一 CTA**。是不是該放在 Wails 控制台的正中央、像 Photo Booth 按拍照鈕那樣顯眼?還是像 Docker Desktop 那樣比較低調?
|
|
||||||
3. **First-Run 的分段**(對應 C8):若選 A(整套 First-Run 在瀏覽器),Wails 控制台的首次引導要怎麼設計才不會打擾到「使用者只是想趕快開 UI」的急迫感。
|
|
||||||
4. **拖放 .nef 的目標視窗**(US-4):使用者從 Finder 拖 .nef 檔,應該拖到 Wails 控制台?還是瀏覽器?兩邊都要支援?Design 要決定。
|
|
||||||
5. **Mock 模式的視覺標記**(4.5):現在「主視窗」有兩個。兩邊都要打 Mock 標?還是只有其中一邊?
|
|
||||||
6. **Server 啟動中的 loading 體驗**:原本 Wails webview 直接載 Next.js,有個 splash 就結束了。新架構下有兩段等待:(a) Wails 啟動後到 server ready、(b) 瀏覽器打開後到頁面渲染完。兩段都要設計。
|
|
||||||
7. **Wails 控制台的視窗大小**:原本 Wails 視窗是 1280x800(主 UI)。新架構下控制台要小得多(可能 600x400 就夠),而且可能不需要 resize。
|
|
||||||
8. **錯誤狀態的分佈**:server 崩潰時要通知誰——Wails 控制台?瀏覽器?兩邊?通知形式(toast / modal / native dialog)如何分?
|
|
||||||
|
|
||||||
### E2. 給 Architect 的議題(技術面)
|
|
||||||
|
|
||||||
1. **Wails 控制台是用什麼實作**(對應 D4):最務實的選擇是 Wails webview + 極簡 HTML(不用 Next.js 那份 build);另一個選擇是用 Wails 原生 menu / button(三平台原生 UI 不一致);還有第三個選擇是 Next.js 產出兩份 build(主 UI + 控制台)。Architect 要提方案並分析利弊。
|
|
||||||
2. **Next.js build 是否要解耦 Wails**:原本 build 會 embed 進 Go binary,新架構下如果 UI 主體跑在瀏覽器,還要 embed 嗎?embed 的意義是什麼?可以改成 server 啟動時直接 `http.Handler` serve `static/` 目錄。
|
|
||||||
3. **Server 啟停的 IPC**:Wails 控制台按下 Start 要叫 server 起來;按 Stop 要 graceful shutdown。目前 server 是透過 Wails 生命週期管理(M7 L-1 的 watchServer),新架構下要重新設計 IPC 協定。
|
|
||||||
4. **Single-instance 保護**(對應 C5 + B4-3):新架構下「second instance」的定義變了。需要重新設計。
|
|
||||||
5. **Server port 的分配**:目前是隨機 port + wails-ipc-port 檔案(L-3)。新架構下要讓使用者在瀏覽器打開「固定 URL」還是「每次不同 port」?固定 port 方便但有衝突風險,隨機 port 要從 Wails 控制台讀取。建議是「固定 port 優先,衝突時降級為隨機並顯示」。
|
|
||||||
6. **Wails App 的 Dock icon / taskbar 行為**:macOS 的 Dock icon 一直亮著、使用者一定會發現有個 app 在跑。跟 Ollama(背景 tray + 沒有 Dock icon)不同。Architect 要決定要不要做 NSApplicationActivationPolicyAccessory(macOS)、hidden from taskbar(Windows)等。
|
|
||||||
7. **瀏覽器打開方式**:`open http://...`(macOS)/ `start`(Win)/ `xdg-open`(Linux)。Architect 要寫跨平台的 OS helper,注意防呆(例如使用者沒設預設瀏覽器時的 fallback)。
|
|
||||||
8. **攝影機推論的新通路**(對應 D2):原本 Go server 直接用 AVFoundation / DirectShow / V4L2 抓 frame 做推論。新架構下如果 UI 跑在瀏覽器,是**維持原路徑**(server 抓 frame、MJPEG 推給瀏覽器)還是**改走瀏覽器路徑**(瀏覽器 getUserMedia → WebRTC / ImageCapture → POST 給 server)?**強烈建議維持原路徑**(伺服器抓 frame + MJPEG),效能和權限都簡單。但 Architect 要確認。
|
|
||||||
9. **server 被同網段連線的防護**:即使使用者選 C1-A/B/D,也要確認新架構下 server 真的只聽 127.0.0.1 沒意外 bind 0.0.0.0。這點要在 TDD 加驗收。
|
|
||||||
10. **CORS / CSRF**:瀏覽器發 request 到 `localhost:XXXX` 是同 origin(瀏覽器也在 localhost 開),理論上不需要 CORS。但如果使用者把 URL 複製到別台機器的瀏覽器(跟 C1-C/F 相關),會跨 origin。這題跟 D3 綁在一起。
|
|
||||||
11. **現有 L-3(Wails /ipc/raise)是否仍需要**:原本 server 崩潰後 raise Wails 視窗是為了顯示 UI。新架構下 server 崩潰後要 raise Wails 控制台(顯示錯誤),瀏覽器端只會顯示 offline 頁面。IPC 協定仍有效但意義變了。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 結尾:PM 的立場聲明
|
|
||||||
|
|
||||||
作為 PM,我對這輪變更有三個**明確的立場**:
|
|
||||||
|
|
||||||
1. **C1(動機釐清)是這次變更能不能做的前提**。我不建議在沒有 C1 答案之前啟動任何實作。
|
|
||||||
2. **D1(零摩擦承諾)是不能妥協的紅線**。若新架構無法透過「自動 Start Server + 自動 Open Browser」達成「雙擊就看到 UI」,我會建議使用者回到原架構。
|
|
||||||
3. **我不建議現在就跳去改前端**。正確順序是:(1) 使用者回答 C1-C9;(2) PM 根據答案更新 PRD 相關章節;(3) 三方(PM / Design / Architect)重新聯合審閱;(4) 才進實作。這整個循環我估大約要 1-2 個工作日。
|
|
||||||
|
|
||||||
**下一步(交給 Orchestrator)**:
|
|
||||||
- 將本分析文件呈現給使用者
|
|
||||||
- 請使用者回答 C1-C9 九個問題(C1、C4、C5、C8 為必答;其他可接受 PM 建議)
|
|
||||||
- 答案到位後,請 Design Agent 和 Architect Agent 同步做第一輪分析(對應 E1、E2)
|
|
||||||
- 三方第一輪分析完成後再召開聯合討論,決定是否要做、怎麼做、對 M7 的影響
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 附錄:本分析文件的資料來源
|
|
||||||
|
|
||||||
- `/Users/jimchen/visionA/local-tool/.autoflow/02-prd/PRD.md`(索引)
|
|
||||||
- `/Users/jimchen/visionA/local-tool/.autoflow/02-prd/features/feature-inventory.md`
|
|
||||||
- `/Users/jimchen/visionA/local-tool/.autoflow/02-prd/user-research.md`
|
|
||||||
- `/Users/jimchen/visionA/local-tool/.autoflow/02-prd/nonfunctional.md`
|
|
||||||
- `/Users/jimchen/visionA/local-tool/.autoflow/02-prd/risks.md`
|
|
||||||
- `/Users/jimchen/visionA/local-tool/.autoflow/progress.md`(M1-M7 進度)
|
|
||||||
- 使用者第四輪變更訊息(2026-04-14)
|
|
||||||
@ -1,500 +0,0 @@
|
|||||||
# visionA-local — 產品需求文件(PRD v2)
|
|
||||||
|
|
||||||
> 版本:**v2.1(2026-04-14)**
|
|
||||||
> 作者:PM Agent
|
|
||||||
> 任務等級:L 級(重構方向變更)
|
|
||||||
> 狀態:**v2.1 補丁:吸收 R5-D1/D2/D3 + R5-E + Design 交叉審閱 4 Major / 4 Minor**
|
|
||||||
> 前版:`PRD-v2.md` v2.0(2026-04-14)/ [`PRD.md`](./PRD.md) v1.2(2026-04-11)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. v2 變更摘要(和 v1.2 的差異)
|
|
||||||
|
|
||||||
本版本因應 2026-04-14 使用者提出的三項重大方向變更(介面架構、影片來源、模型管理),基於 R5 第五輪決策重新定位產品。本文件採「只更新受影響章節」策略,未影響的章節以「同 v1.2」標註並指向原子檔。
|
|
||||||
|
|
||||||
**最關鍵 5 點差異:**
|
|
||||||
|
|
||||||
1. **產品定位轉向**:從「Wails 桌面 app 內嵌 Next.js UI」改為「本機服務(Wails 控制台)+ 網頁控制台(瀏覽器 Web UI)」,競品類比從 Photo Booth 換成 **Ollama / LM Studio / Docker Desktop / Stable Diffusion WebUI**(R5-1)。
|
|
||||||
2. **砍 Mock 模式**:完全刪除,沒插硬體就讓 UI 空白(R5-5a)。Persona P3 Solution Architect 的使用情境被迫降級,US-2 整條砍掉。
|
|
||||||
3. **砍 yt-dlp 全套 + URL 影片推論**:媒體上傳只剩 camera / image / upload 影片(`.mp4 / .avi / .mov / .mpeg / .mpg`)。砍 35MB vendor binary。
|
|
||||||
4. **ffmpeg 授權從 GPL blocker 升級為 LGPL 方案 B 混合**:Windows/Linux 用 BtbN LGPL binary,macOS 自 build decoder-only(~20MB)commit 到 repo(R5-6 / R5-6a / R5-6b / R5-6c)。
|
|
||||||
5. **啟動時自動開瀏覽器(每次啟動)**:Wails 控制台 server 就緒後**每次啟動**都自動開系統預設瀏覽器,Settings 可關(R5-4 / R5-D3)。平台預設:**macOS / Windows ON;Linux OFF**(`xdg-open` 在極簡 WM 行為不穩,R5-D2)。用於抵消「多一步摩擦」對北極星指標的傷害。
|
|
||||||
|
|
||||||
### v2.1 補丁摘要(相對 v2.0)
|
|
||||||
|
|
||||||
v2.1 為 Design 交叉審閱發回的小版本修正(Major 1–3 + 4 個 Minor)+ R5-D / R5-E 決策落地,**未改動產品定位與核心決策**,只補齊語意與驗收標準。
|
|
||||||
|
|
||||||
| 變更 | 內容 | 依據 |
|
|
||||||
|------|------|------|
|
|
||||||
| M1 | 「首次啟動自動開」→「每次啟動自動開」(全文 replace) | R5-D3 |
|
|
||||||
| M2 | auto-open 平台預設差異(macOS / Windows ON;Linux OFF) | R5-D2 |
|
|
||||||
| M3 | US-7 Server 崩潰時 **除控制台 banner 外仍發 OS 原生通知** | R5-D1 |
|
|
||||||
| M4 | auto-open toggle 住 Wails 控制台(Design 仲裁採 PRD 原案) | Design review §G Major 4 |
|
|
||||||
| **R5-E 大改** | **AC-1.3 從「10 秒硬指標」翻轉為「60 秒 + 階段化進度 + perceived performance」** | R5-E1~E6 |
|
|
||||||
| Minor 1 | US-2 明確:日常啟動(非首次)同樣 auto-open | Design review Minor 1 |
|
|
||||||
| Minor 2 | Offline Overlay 硬阻斷(`role="alertdialog"` + focus trap + 不可關閉 + 純色卡片) | Design Spec v2 `server-offline-overlay.md` |
|
|
||||||
| Minor 3 | Wails 控制台 Footer **持久警示**取代單次 confirm / Error state **三動作按鈕**(Restart / View log / Report) | Design Spec v2 `control-panel.md` |
|
|
||||||
| Minor 4 | First-Run wizard **從 3 步縮為 2 步**(砍模式選擇),無硬體可按「稍後再設定」 | Design Spec v2 `first-run-update.md` / R5-5a |
|
|
||||||
|
|
||||||
**行數目標**:v2.1 新增約 40 行、精簡約 20 行,總量仍 ≤ 500 行。
|
|
||||||
|
|
||||||
**保留 vs 重寫對照表:**
|
|
||||||
|
|
||||||
| 章節 | 子檔 | v2 處理 |
|
|
||||||
|------|------|---------|
|
|
||||||
| 1. 產品策略與定位 | `strategy.md` | 🔴 重寫(見本檔 §1) |
|
|
||||||
| 2. 目標使用者與情境 | `user-research.md` | 🔴 重寫 US(見本檔 §2 / §5) |
|
|
||||||
| 3. 產品願景與非目標 | `vision-and-non-goals.md` | 🟡 修訂(見本檔 §3) |
|
|
||||||
| 4. 功能清單與 API 對照 | `features/feature-inventory.md` | 🔴 大幅修訂(見本檔 §4) |
|
|
||||||
| 5. 使用者流程 | `features/user-flows.md` | 🔴 重寫(見本檔 §5) |
|
|
||||||
| 6. 非功能需求與驗收 | `nonfunctional.md` | 🟡 修訂(見本檔 §6) |
|
|
||||||
| 7. 發佈與交付策略 | `release-strategy.md` | 🟢 同 v1.2(安裝檔數字需更新見 §6) |
|
|
||||||
| 8. 風險與相依性 | `risks.md` | 🔴 大幅修訂(見本檔 §8) |
|
|
||||||
| 4.8 第三方授權 | `feature-inventory.md §4.8` | 🔴 改寫(見本檔 §7) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 產品策略與定位(重寫)
|
|
||||||
|
|
||||||
### 1.1 新定位
|
|
||||||
|
|
||||||
visionA-local 是一個**本機服務 + 網頁控制台**架構的 Kneron AI 邊緣推論工具:
|
|
||||||
|
|
||||||
- **Wails 桌面 app = 本機 server 控制台**(類比:Docker Desktop 的 Dashboard、Ollama 的 tray app),只負責 server 生命週期、log、版本資訊。
|
|
||||||
- **瀏覽器 Web UI = 真正的工作區**(類比:Stable Diffusion WebUI、LM Studio 的 web mode),所有「選裝置、選模型、推論」在瀏覽器完成。
|
|
||||||
- 兩者只在本機、只綁 `127.0.0.1`、不做 LAN、不做背景 daemon(R5-1 / R5-2)。
|
|
||||||
|
|
||||||
### 1.2 一句話價值主張
|
|
||||||
|
|
||||||
> **「雙擊 app → 自動開瀏覽器 → 插 USB → 5 分鐘內跑第一次推論。熟悉的瀏覽器操作、熟悉的 devtools,離線可用、零依賴。」**
|
|
||||||
|
|
||||||
### 1.3 競品類比更新
|
|
||||||
|
|
||||||
| v1.2 類比 | v2.0 類比 |
|
|
||||||
|-----------|-----------|
|
|
||||||
| Photo Booth、Kneron 官方 demo app | **Ollama**(server + web UI / CLI)、**Docker Desktop**(桌面 app 管 daemon、Dashboard 管操作)、**LM Studio**(桌面 app + server 模式)、**Stable Diffusion WebUI**(點 bat → 瀏覽器打開 127.0.0.1) |
|
|
||||||
|
|
||||||
### 1.4 北極星指標(更新)
|
|
||||||
|
|
||||||
**v1.2 原指標**:內部 FAE 在客戶現場首次安裝後 **5 分鐘內**順利跑出第一次推論的比例 ≥ 95%。
|
|
||||||
|
|
||||||
**v2.0 新指標**(處理「多一步開瀏覽器」紅線):
|
|
||||||
|
|
||||||
> **內部 FAE 在客戶現場首次安裝後 5 分鐘內,瀏覽器 Web UI 跑出第一次推論的比例 ≥ 95%。**
|
|
||||||
> 起點雙擊安裝檔,終點瀏覽器顯示第一幀推論結果。自動開瀏覽器(R5-4 / R5-D3)是必要條件 — 使用者不需手動點「Open in Browser」,除非關過 Settings。比 v1.2 更嚴格(把瀏覽器冷啟動也納入 5 分鐘)。
|
|
||||||
|
|
||||||
### 1.5 策略取捨紀錄
|
|
||||||
|
|
||||||
- **砍 Mock 模式的代價**(R5-5a):P3 Persona Sam 的「零摩擦 demo」情境無法再被支援,使用者明確接受此取捨,P3 實質降級為非目標(§2)。
|
|
||||||
- **雙視窗的代價**:Wails 控制台 + 瀏覽器比 v1.2 單視窗多一層「誰是主角」的心智負擔。緩解:控制台 UI 極簡(4 按鈕 + 1 log panel),所有業務操作(裝置、模型、Settings > 語言)強制住瀏覽器。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 目標使用者(修訂)
|
|
||||||
|
|
||||||
### 2.1 Persona 變動
|
|
||||||
|
|
||||||
- **P1 — Arthur(內部 FAE)**:同 v1.2。核心使用情境「帶筆電到客戶現場跑 demo」在 v2.0 下**多一步自動開瀏覽器**,但 R5-4 / R5-D3 決定每次啟動都自動開,對 Arthur 幾乎無感。
|
|
||||||
- **P2 — Dora(外部開發者)**:同 v1.2。她會欣賞「瀏覽器 devtools」(R5-1 動機 B),這是 v2.0 相對於 v1.2 的潛在加分項。
|
|
||||||
- **P3 — Sam(Solution Architect / PM)**:**實質降級為非目標使用者**。R5-5a 砍 Mock 模式後,Sam「沒硬體也能 demo」的訴求無法被滿足。保留 Persona 定義只是為了文件完整性,不再為他優化任何 MVP 功能。若未來要重新支援 Sam,應提 L 級功能重新討論。
|
|
||||||
|
|
||||||
### 2.2 Persona 對新架構的反應(PM 預測)
|
|
||||||
|
|
||||||
| Persona | 對「Wails 控制台 + 瀏覽器」的反應 | 對「砍 Mock」的反應 |
|
|
||||||
|---------|---------------------------------|--------------------|
|
|
||||||
| P1 Arthur | 🟡 中性偏好(第一次會愣一下,但每次啟動自動開瀏覽器大幅緩解) | ✅ 無感(他本來就帶硬體) |
|
|
||||||
| P2 Dora | ✅ 正面(瀏覽器 devtools 正中下懷) | ✅ 無感(她本來就在評估自家硬體) |
|
|
||||||
| P3 Sam | 🟡 中性(多一步開瀏覽器) | 🔴 **負面**(他本來就沒硬體) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 非目標補充(修訂)
|
|
||||||
|
|
||||||
相對於 v1.2 `vision-and-non-goals.md`,v2.0 明確**新增以下非目標**:
|
|
||||||
|
|
||||||
- ❌ **Mock 模式 / 假資料 / 假裝置**(R5-5a)。沒插硬體就讓 UI 空白。
|
|
||||||
- ❌ **影片 URL 推論 / yt-dlp / YouTube / Vimeo / Dailymotion 整合**(R5 共識 2)。
|
|
||||||
- ❌ **Model Zoo / 遠端模型下載**(R5 共識 9,同 v1.2 但再次確認)。
|
|
||||||
- ❌ **LAN / 區網分享 localhost**(R5-1,使用者明確放棄動機 C/F)。
|
|
||||||
- ❌ **背景 daemon / tray 常駐**(R5-3 維持砍 tray,R5-2 維持視窗關閉=結束 server)。
|
|
||||||
- ❌ **雙 Next.js build**(共識 5,Wails 控制台用 vanilla HTML/JS/CSS,不搞兩份前端)。
|
|
||||||
|
|
||||||
**保留的非目標**(同 v1.2):cluster、relay、tunnel、auto-update、程式碼簽章、telemetry、韌體燒錄、多架構、商店上架、license 啟用機制、WCAG 2.2 AA 正式合規。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 功能清單與 API 對照(大幅修訂)
|
|
||||||
|
|
||||||
本節列出**相對於 v1.2 的變動**。v2.0 完整的功能清單後續由 PM Agent 同步更新到 `features/feature-inventory.md` 子檔。
|
|
||||||
|
|
||||||
### 4.1 保留(沿用 v1.2)
|
|
||||||
|
|
||||||
- Device scan / connect(`GET /api/devices`、`POST /api/devices/scan`、`POST /api/devices/:id/connect|disconnect`、`POST /api/devices/:id/inference/start|stop`)
|
|
||||||
- 模型管理:8 個預設 .nef + 使用者上傳(`GET /api/models`、`POST /api/models/upload`、`DELETE /api/models/:id`、`GET /api/models/:id/download`)
|
|
||||||
- 批次影像上傳(`POST /api/media/upload/batch`,R5 共識 10)
|
|
||||||
- 推論引擎:camera / image / 上傳影片
|
|
||||||
- MJPEG 串流(`GET /api/camera/:id/stream`)
|
|
||||||
- WebSocket 推論結果 stream(`/ws/devices/*`、`/ws/server-logs`)
|
|
||||||
- 系統 API:`GET /api/system/health|info|deps|metrics`、`POST /api/system/restart`
|
|
||||||
- i18n 中英雙語(住瀏覽器 Web UI)
|
|
||||||
- Dark Mode 跟隨系統(住瀏覽器 Web UI,靠 `prefers-color-scheme`)
|
|
||||||
- Log 檔案持久化(macOS:`~/Library/Application Support/visiona-local/logs/`)
|
|
||||||
|
|
||||||
### 4.2 砍除(v2.0 明確刪除)
|
|
||||||
|
|
||||||
| # | 項目 | 理由 | R5 依據 |
|
|
||||||
|---|------|------|---------|
|
|
||||||
| 1 | `POST /api/media/url` endpoint | 砍 URL 推論 | R5 共識 2 |
|
|
||||||
| 2 | `server/internal/media/url_handler.go`(或類似檔案) | 同上 | R5 共識 2 |
|
|
||||||
| 3 | yt-dlp 內嵌(35MB binary + `vendor/ytdlp/` + `installer/payload-*` 相關 stage) | 砍 URL 推論 | R5 共識 2 |
|
|
||||||
| 4 | 前端「URL 貼上」tab、source-selector 中的 URL 選項 | 砍 URL 推論 | R5 共識 2 / R5 共識 4 |
|
|
||||||
| 5 | **Mock 模式全部**:Mock 裝置資料、Mock 推論結果 mock、Mock mode 切換按鈕、Mock 視覺標記、`server/internal/mock/`(若存在)、前端 Mock 入口 | 使用者明確砍 | **R5-5a** |
|
|
||||||
| 6 | Wails webview 載入 Next.js 前端的路由(原 `server/web/out` 不再被 Wails 直接 serve 作為主要 UI) | 轉為 server 控制台 | R5-1 |
|
|
||||||
| 7 | Wails tray 相關(維持 v1.2 Q-A 決策) | 沒意義 | R5-3 |
|
|
||||||
| 8 | First-Run 流程中的「模式選擇(真實 / Mock)」畫面 | Mock 砍了 | R5-5a |
|
|
||||||
| 9 | v1.2 US-2(Mock 模式試玩)整條 user story | Mock 砍了 | R5-5a |
|
|
||||||
|
|
||||||
### 4.3 新增(v2.0 明確增加)
|
|
||||||
|
|
||||||
| # | 項目 | 描述 | R5 依據 |
|
|
||||||
|---|------|------|---------|
|
|
||||||
| N1 | **Wails 控制台 UI** | vanilla HTML/JS/CSS 的獨立桌面視窗。包含:Start / Stop / Restart Server 按鈕、Open in Browser 按鈕、Server 狀態指示(running / stopped / error)、Server port 號、資料目錄路徑、版本號、Clear log 按鈕、Log panel(自動捲動 + 手動捲動解鎖)、Settings menu(含 auto-open toggle,見 N2)。**Footer 持久警示**:右下角固定顯示 `⚠ 關閉此視窗會停止 Local Server`(12 px muted,隨時可見,取代單次 confirm dialog 作為主要提醒)。**Error state 面板**(對應 N6):log panel 上方浮動 banner 含三個動作按鈕 `Restart Server` / `View log` / `Report issue`(見 Design Spec v2 `v2/control-panel.md §5`)。**不含**裝置管理、推論、模型、Settings > 語言等任何業務操作。 | R5-1 / R5-5 / Design review Minor 3 |
|
|
||||||
| N2 | **啟動時自動開瀏覽器(每次啟動)** | Wails 控制台啟動、server 就緒後,**每次啟動**都自動開系統預設瀏覽器到 `http://127.0.0.1:{port}`(不是只在首次,R5-D3)。**auto-open toggle 住 Wails 控制台** 的 Settings menu(讀取時機在 Wails `main.go` 啟動階段,Web UI 尚未 load;已與 Design 取得共識採此位置)。**平台預設**:macOS / Windows `autoOpenBrowser = true`;**Linux `autoOpenBrowser = false`**(因 `xdg-open` 在極簡 WM 行為不穩定,R5-D2)。使用者可隨時切換。 | R5-4 / R5-D2 / R5-D3 |
|
|
||||||
| N3 | **Web UI Server Offline Overlay** | 瀏覽器 Web UI 偵測 server 斷線(heartbeat / WebSocket 斷)時,顯示**全螢幕半透明 backdrop(`rgba(0,0,0,0.4-0.5)`)+ 置中純色卡片**(卡片本體**不是**半透明,以確保 WCAG 4.5:1 對比)。卡片內訊息:「Local Server 已離線」+「請回到 visionA-local 桌面視窗重新啟動 Server」+「重試連線」Primary 按鈕 +「了解更多 ↓」Ghost 按鈕。**A11y 硬阻斷特性**:`role="alertdialog"` + `aria-modal="true"` + focus trap + **不可由使用者手動關閉**(無 ✕ 按鈕,只能 retry 成功或使用者自行 ⌘W / Ctrl+W 關 tab / 重開 app 後自動恢復)。覆蓋層下方 UI 凍結不可操作。 | R5-2 / Design review Minor 2 |
|
|
||||||
| N4 | **server boot-id 機制** | Go server 啟動時產生 UUID boot-id,放進 `/api/system/info` 回應。瀏覽器 Web UI 連上後記下第一個 boot-id;後續 poll 發現 boot-id 變了就代表 server 重啟,前端自動 reload state。**Restart Server 按鈕**(住 Wails 控制台)依賴此機制讓瀏覽器 tab 不用手動重載。 | R5 共識 14 |
|
|
||||||
| N5 | **CORS 中介層** | Go server 加 CORS middleware,`Access-Control-Allow-Origin` 只允許 `http://127.0.0.1:*` / `http://localhost:*`,其他 origin 一律拒絕。 | R5 共識 6 |
|
|
||||||
| N6 | **watchServer() 改為 Error state** | v1.2 watchServer 連續 3 次健檢失敗會 `os.Exit(1)`,v2.0 改為把 Wails 控制台的 Server 狀態切為「Error」並在 log panel 顯示錯誤,不強制退出 app。Error state 面板同時顯示三個動作按鈕(見 N1)。 | R5 共識 8 |
|
|
||||||
| N7 | **Server 崩潰 OS 原生通知** | 當 `watchServer()` 偵測連續 3 次健檢失敗進入 Error state 時,**除了**控制台 Error banner 外,**同步發一次 OS 原生通知**(macOS Notification Center / Windows Toast / Linux `libnotify`)。使用者可能正在使用瀏覽器 tab,靠 OS 通知把他拉回控制台。去重策略(避免 Error 狀態期間重複發通知)由 Architect 在 TDD v2 決定。 | R5-D1 |
|
|
||||||
| N8 | **啟動階段化進度顯示(6 階段)** | Wails 控制台在啟動過程全程顯示階段化進度:`1. 初始化控制台 → 2. 檢查 Python → 3. 啟動 server → 4. 偵測裝置 → 5. 開瀏覽器 → 6. 瀏覽器就緒`。每階段有編號 + 使用者可理解的動作描述 + 視覺回饋(spinner / 進度條)+ 中英雙語文案。第 6 階段「ready」的偵測訊號 = Web UI 透過 WebSocket hub 建立第一個 client 連線(不做新 endpoint,R5-E6)。階段文案由 Design Agent 最終決定(R5-E5)。啟動總預算見 §6.1 AC-1.3 系列。 | R5-E1~E6 |
|
|
||||||
|
|
||||||
### 4.4 功能對 Persona 對照表(更新)
|
|
||||||
|
|
||||||
| 功能 | 對應 Persona | 住哪裡 |
|
|
||||||
|------|-------------|--------|
|
|
||||||
| Wails 控制台(Start/Stop/Restart/Log)| P1, P2 | Wails 桌面視窗 |
|
|
||||||
| Device scan / connect | P1, P2 | 瀏覽器 Web UI |
|
|
||||||
| 模型管理 | P1, P2 | 瀏覽器 Web UI |
|
|
||||||
| Workspace(推論)| P1, P2 | 瀏覽器 Web UI |
|
|
||||||
| Settings > 語言 / 深色模式 | P1, P2 | 瀏覽器 Web UI |
|
|
||||||
| Settings > 自動開瀏覽器 | P1, P2 | **Wails 控制台**(因為它控制的是 Wails 啟動行為)|
|
|
||||||
| Server Offline Overlay | 全體 | 瀏覽器 Web UI |
|
|
||||||
| ~~Mock 模式~~ | ~~P3~~ | ~~砍~~ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. User Stories(重寫受影響的)
|
|
||||||
|
|
||||||
### US-1:第一次安裝 → 首次看到推論 UI(**重寫**,取代 v1.2 US-1 + 部分 US-5)
|
|
||||||
|
|
||||||
**身份**:任意目標 Persona(主力 P1 Arthur)
|
|
||||||
**情境**:剛拿到 `.dmg` / `.exe` / `.AppImage`,全新機器、客戶現場
|
|
||||||
**敘述**:
|
|
||||||
> 身為 FAE,我希望雙擊安裝檔、按幾下「下一步」、等進度條跑完,**視窗自動打開**(Wails 控制台)、**瀏覽器自動打開**(Web UI),插上 Kneron USB 後瀏覽器立刻顯示裝置,我按一下「Start」就看到第一幀推論結果。整個流程不超過 5 分鐘。
|
|
||||||
|
|
||||||
**驗收標準**:
|
|
||||||
- AC-1.1:下載安裝檔,無 terminal 操作可完成安裝(同 v1.2)
|
|
||||||
- AC-1.2:整個安裝過程 ≤ 3 分鐘(目標)/ ≤ 5 分鐘(上限)(同 v1.2,R4-4)
|
|
||||||
- **AC-1.3(R5-E 改寫)**:安裝完成後首次啟動,從 Wails 控制台視窗出現到瀏覽器 Web UI 顯示第一幀,**全程 ≤ 60 秒**(R5-E1,原 10 秒硬指標取消)。啟動過程全程可見、不可出現白畫面或無回饋狀態 — 採 Nielsen Norman perceived performance 原則,使用者感覺進度在推進比硬時間指標重要
|
|
||||||
- **AC-1.3a(新)**:啟動全程顯示階段化進度(6 階段,見 §4 N8),每階段有編號 + 使用者可理解的動作描述 + 視覺回饋(spinner 或進度條)+ 中英雙語文案。階段文案由 Design Agent 決定(R5-E2 / R5-E5)
|
|
||||||
- **AC-1.3b(新)**:任一啟動階段卡超過 **20 秒**,UI 必須顯示「正在重試」或「這步比預期久」類提示,**不可停留在白畫面或無變化狀態**(R5-E3)
|
|
||||||
- **AC-1.3c(新)**:總啟動時間超過 **60 秒仍未進入「就緒」狀態**,Wails 控制台進入 Error state,顯示「Restart Server」「View log」「Report issue」三個動作按鈕(對應 §4 N1 / N6,與 watchServer 3 次失敗後行為一致,R5-E4)
|
|
||||||
- **AC-1.3d(新)**:「瀏覽器就緒」的定義 = **Web UI 建立 WebSocket 連線到 server**(hub 收到第一個 client),不是瀏覽器視窗出現時。不做新 endpoint、不做固定延遲(R5-E6)
|
|
||||||
- AC-1.4:瀏覽器 Web UI 首次顯示時,若**沒插硬體**,Dashboard 顯示空白狀態 + 「請插入 Kneron 裝置」提示文字(R5-5a,不是 Mock 資料)。**First-Run wizard 從 v1.2 的 3 步縮為 2 步**(砍模式選擇,R5-5a):Step 1 歡迎 → Step 2 硬體偵測。Step 2 在無硬體情況下可按 `稍後再設定` 略過,直接進 Dashboard 空白狀態(Design review Minor 4)
|
|
||||||
- AC-1.5:Mac 第一次開啟若出現 Gatekeeper 警告,Wails 控制台和安裝說明頁提供「右鍵 → 開啟」引導(同 v1.2)
|
|
||||||
- AC-1.6:auto-open 可在 **Wails 控制台的 Settings menu** 切換(不在 Web UI Settings,見 §4 N2)。**平台預設**:macOS / Windows ON;**Linux OFF**(R5-D2)。關閉後下次啟動只開 Wails 控制台、不開瀏覽器,使用者可手動按「Open in Browser」
|
|
||||||
|
|
||||||
### US-2:日常啟動(**新增**,取代 v1.2 US-2 Mock)
|
|
||||||
|
|
||||||
**身份**:P1, P2
|
|
||||||
**情境**:第二次以後的日常啟動(非首次安裝,但「啟動時自動開」對首次 / 日常**同樣生效**,R5-D3)
|
|
||||||
**敘述**:
|
|
||||||
> 身為日常使用者,我希望雙擊 app icon 後,Wails 控制台和瀏覽器都自動開啟(除非我關過 auto-open),server 狀態顯示 running,瀏覽器顯示上次的 Dashboard 狀態。
|
|
||||||
|
|
||||||
**驗收標準**:
|
|
||||||
- AC-2.1:日常啟動時,若 Wails 控制台 Settings `autoOpenBrowser === true`,**仍會自動開瀏覽器**(和首次啟動行為一致,R5-D3 / Design review Minor 1)。AC-1.3 系列的 60 秒預算與階段化進度在日常啟動同樣生效(通常遠低於 60 秒)
|
|
||||||
- AC-2.2:瀏覽器 Web UI 保留上次的 i18n 語言偏好、深色模式偏好
|
|
||||||
- AC-2.3:若使用者關過 auto-open(或 Linux 平台預設 OFF 且使用者未手動開),啟動時只開 Wails 控制台,使用者可點「Open in Browser」手動開
|
|
||||||
|
|
||||||
### US-3:連實體 Kneron 裝置(**微調**)
|
|
||||||
|
|
||||||
**身份**:P1, P2
|
|
||||||
**敘述**:同 v1.2,但 UI 住瀏覽器
|
|
||||||
**驗收標準**:
|
|
||||||
- AC-3.1 ~ AC-3.6:同 v1.2(插 USB → 3 秒內瀏覽器 Devices 頁顯示裝置)
|
|
||||||
- AC-3.7(**新**):若使用者關閉瀏覽器但 Wails 控制台還在(server 仍跑),插入 USB 的事件不會通知使用者(不做 toast),下次使用者打開瀏覽器會看到裝置。不發原生 OS 通知(維持 v1.2 R4-8 OS 通知策略)。
|
|
||||||
|
|
||||||
### US-4:切換 / 上傳模型(**微調**)
|
|
||||||
|
|
||||||
**身份**:P1, P2
|
|
||||||
**敘述**:同 v1.2,但 UI 住瀏覽器
|
|
||||||
**驗收標準**:
|
|
||||||
- AC-4.1 ~ AC-4.4:同 v1.2
|
|
||||||
- AC-4.5(**新**):拖放 .nef **只在瀏覽器視窗內有效**,拖到 Wails 控制台視窗無反應(不處理跨視窗拖放,避免混淆)
|
|
||||||
- AC-4.6(**新**):預設模型清單**維持 8 個 .nef**,使用者可上傳自己的 .nef,但**沒有**任何 Model Zoo / URL download 入口(R5 共識 9)
|
|
||||||
|
|
||||||
### US-5:跑即時攝影機推論(**重寫,瀏覽器情境**)
|
|
||||||
|
|
||||||
**身份**:P1
|
|
||||||
**敘述**:
|
|
||||||
> 身為 FAE,我希望在瀏覽器 Workspace 頁選一顆 webcam、選一個模型、按 Start,看到 MJPEG 即時串流和推論 overlay。
|
|
||||||
|
|
||||||
**驗收標準**:
|
|
||||||
- AC-5.1:Workspace 頁能列出所有可用 webcam(透過後端 `/api/camera`,而非瀏覽器 `navigator.mediaDevices`;這點對 Chrome / Edge / Safari 行為一致)
|
|
||||||
- AC-5.2:選 webcam + 模型後按 Start,≤ 3 秒看到第一幀
|
|
||||||
- AC-5.3:推論 overlay(方框 / 標籤 / 信心度)正確顯示
|
|
||||||
- AC-5.4:即時 FPS / 延遲 / CPU / RAM 顯示
|
|
||||||
- AC-5.5:按 Stop 即時停止
|
|
||||||
- AC-5.6(**新**):**不使用**瀏覽器 `getUserMedia()` API(避開 Chrome/Edge/Safari 權限差異 + HTTPS 限制)。所有攝影機存取走後端 AVFoundation / DirectShow / V4L2 pipeline(和 v1.2 架構相同)
|
|
||||||
|
|
||||||
### US-6:上傳影片推論(**修訂**,取代 v1.2 US-7 / US-8)
|
|
||||||
|
|
||||||
**身份**:P1, P2
|
|
||||||
**敘述**:
|
|
||||||
> 身為使用者,我希望能把一段 .mp4 / .avi / .mov / .mpeg / .mpg 影片拖進瀏覽器 Workspace,跑離線推論、看到逐幀結果。
|
|
||||||
|
|
||||||
**驗收標準**:
|
|
||||||
- AC-6.1:瀏覽器 Workspace 頁支援拖放或 file picker 上傳影片
|
|
||||||
- AC-6.2:支援副檔名:`.mp4 / .avi / .mov / .mpeg / .mpg`(R5 共識 11)
|
|
||||||
- AC-6.3:上傳後後端走 ffmpeg decode → Kneron 推論 → 前端顯示結果
|
|
||||||
- AC-6.4:**不支援** URL 推論 / YouTube / Vimeo / Dailymotion(R5 共識 2)
|
|
||||||
|
|
||||||
### US-7:Server 崩潰 / 意外中斷(**新增**)
|
|
||||||
|
|
||||||
**身份**:P1, P2
|
|
||||||
**情境**:Go server 子程序因某種原因崩潰(OOM、panic、依賴錯誤),或使用者關閉 Wails 視窗
|
|
||||||
**敘述**:
|
|
||||||
> 身為使用者,我希望當 server 斷線時,我在瀏覽器看到清楚的「Local Server 已離線」訊息,而不是無聲的網頁卡住。我也希望 Wails 控制台能告訴我發生什麼、我能按 Restart 重啟。
|
|
||||||
|
|
||||||
**驗收標準**:
|
|
||||||
- AC-7.1:瀏覽器 Web UI 偵測 server 斷線(health check 失敗 or WebSocket 斷 + retry 失敗)→ 顯示全螢幕 Server Offline Overlay(R5-2 / N3)。**Overlay 無 ✕ 按鈕、不可手動關閉**(`role="alertdialog"` + focus trap,Design review Minor 2)
|
|
||||||
- AC-7.2:Overlay 卡片內訊息:「Local Server 已離線,請回到 visionA-local 桌面視窗重新啟動 Server」+「重試連線」Primary 按鈕 +「了解更多 ↓」Ghost 按鈕。卡片背景純色(非 backdrop 半透明),確保 WCAG 4.5:1 對比
|
|
||||||
- AC-7.3:Wails 控制台 Server 狀態顯示變為「Error」(紅色),log panel 上方浮現 Error banner 面板,含 **三個動作按鈕**:`Restart Server` / `View log` / `Report issue`(對應 §4 N1 / N6,Design Spec v2 `v2/control-panel.md §5`,Design review Minor 3)
|
|
||||||
- AC-7.4:使用者在 Wails 控制台按 Restart Server 後,server 重啟、取得新 boot-id,瀏覽器 Web UI 透過 Retry 重連、偵測到新 boot-id、自動 reload state(R5 共識 14 / N4)
|
|
||||||
- AC-7.5:使用者直接關閉 Wails 視窗 → **server 結束**(R5-2 維持 v1.2 Q7)→ 瀏覽器 Web UI 顯示相同的 Offline Overlay + 提示「請重新啟動 visionA-local」
|
|
||||||
- AC-7.6:為避免使用者誤解「關閉視窗 = 程式還在背景跑」,**雙保險警示**:(a) Wails 控制台 Footer 右下角**持久警示文字** `⚠ 關閉此視窗會停止 Local Server`(12 px muted,始終可見,§4 N1);(b) 標題列 / menu bar 的「Close」按鈕 tooltip / confirm dialog 同樣寫明。(a) 是主要手段(避免使用者盲目點 confirm),(b) 為輔
|
|
||||||
- **AC-7.7(R5-D1 新)**:Server 崩潰事件(watchServer 連續 3 次失敗進入 Error state 的瞬間),**除控制台 Error banner 外,同步發一次 OS 原生通知**(macOS Notification Center / Windows Toast / Linux `libnotify`)。使用者可能正在使用瀏覽器 tab 而未看控制台,OS 通知負責把他拉回來。去重策略(Error 狀態期間避免重複發)由 Architect 在 TDD v2 決定
|
|
||||||
|
|
||||||
### 砍掉的 User Stories
|
|
||||||
|
|
||||||
| US | v1.2 標題 | v2.0 處理 |
|
|
||||||
|----|----------|-----------|
|
|
||||||
| v1.2 US-2 | Mock 模式試玩 | **砍**(R5-5a) |
|
|
||||||
| v1.2 US-6 | 從 Tray 快速控制 | 維持 v1.2 已砍(第三輪 Q-A) |
|
|
||||||
| v1.2 US-8 | YouTube / URL 推論 | **砍**(R5 共識 2) |
|
|
||||||
| v1.2 US-9 | 查看 server 日誌 | **升級**:住 Wails 控制台成為一級功能(N1) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 非功能需求(更新)
|
|
||||||
|
|
||||||
### 6.1 效能指標
|
|
||||||
|
|
||||||
| 指標 | v1.2 | v2.0 | 變化說明 |
|
|
||||||
|------|------|------|---------|
|
|
||||||
| 安裝時間 | ≤ 3 分 / 上限 5 分 | **同 v1.2** | R4-4 沿用 |
|
|
||||||
| 首次推論時間(首次啟動)| ≤ 20s / 上限 30s(至 Mock 第一幀)| **≤ 20s / 上限 30s(至瀏覽器第一幀)** | 終點改為瀏覽器 Web UI 顯示第一幀真實推論結果(因 Mock 砍)。R5-4 的自動開瀏覽器是達成此指標的必要條件。Architect TDD v2 需驗證預算 |
|
|
||||||
| 首次推論時間(回訪)| ≤ 10s / 上限 15s | **同 v1.2**(但終點改為瀏覽器)| 同上 |
|
|
||||||
| 實機接入時間 | ≤ 5s / 上限 10s | 同 v1.2 | — |
|
|
||||||
| 攝影機串流延遲(首次)| ≤ 200ms / 上限 250ms | 同 v1.2(R4-2)| — |
|
|
||||||
| 攝影機串流延遲(穩定後)| ≤ 120ms / 上限 150ms | 同 v1.2(R4-2)| — |
|
|
||||||
| idle CPU(無硬體狀態)| ≤ 3% / 上限 5%(Mock 模式)| **≤ 3% / 上限 5%(無硬體狀態)** | Mock 改為「無硬體空白狀態」。CPU 估計值不變,因為 v1.2 Mock 本來就不 spawn Python |
|
|
||||||
| idle RAM(無硬體狀態)| ≤ 500 MB / 上限 600 MB | **≤ 450 MB / 上限 550 MB** | 因無 Mock state、無 Python sidecar、無 yt-dlp 子程序,可略降。**此指標不含瀏覽器 tab 記憶體**(由 OS 系統瀏覽器負責,不是 Wails app 的責任範圍)。Architect 實測:Wails + Go server + Python 樂觀估 275–405 MB,達標 |
|
|
||||||
| 真實推論 RAM | ≤ 1 GB / 上限 1.5 GB | 同 v1.2 | — |
|
|
||||||
|
|
||||||
### 6.2 安裝檔大小(更新)
|
|
||||||
|
|
||||||
| 平台 | v1.2 上限 | v2.0 目標 | 組成變化 |
|
|
||||||
|------|-----------|-----------|---------|
|
|
||||||
| macOS `.dmg` | ≤ 220 MB | **≤ 185 MB**(目標)/ 上限 ≤ 220 MB | -35MB yt-dlp、ffmpeg 從 77MB GPL build 換成 ~20MB LGPL decoder-only = 淨 -92MB |
|
|
||||||
| Windows `.exe` | ≤ 200 MB | **≤ 165 MB**(目標)/ 上限 ≤ 220 MB | -35MB yt-dlp、ffmpeg 換 BtbN LGPL ~40MB(差異較小,但仍略降) |
|
|
||||||
| Ubuntu `.AppImage` | ≤ 200 MB | **≤ 165 MB**(目標)/ 上限 ≤ 220 MB | 同 Windows |
|
|
||||||
|
|
||||||
> **根據**:R5-6 / R5-6a / R5-6b / R5-6c ffmpeg LGPL 混合方案,砍 yt-dlp 全套。實際數字需 Architect 在 TDD v2 實測確認。
|
|
||||||
|
|
||||||
### 6.3 瀏覽器相容性(新)
|
|
||||||
|
|
||||||
- **預設**:開啟使用者 OS 預設瀏覽器(macOS:Safari/Chrome/Edge 依 OS 設定;Windows:Edge;Linux:依 `xdg-settings`)
|
|
||||||
- **auto-open 平台預設**(見 §4 N2):macOS / Windows `autoOpenBrowser = true`;**Linux `autoOpenBrowser = false`**(`xdg-open` 在極簡 WM 下行為不穩定,R5-D2)
|
|
||||||
- **推薦**:Chrome 或 Edge(MJPEG decode 穩定性最好、devtools 最完整、WebSocket 穩定)
|
|
||||||
- **可用但不保證**:Safari(MJPEG decode 可能較慢;因為 US-5 AC-5.6 決定不走 `getUserMedia`,Safari 的攝影機權限流程差異不構成問題)
|
|
||||||
- **不支援**:IE、舊版 Firefox ESR(< 115)、行動版瀏覽器(不是使用情境)
|
|
||||||
- **WebSocket 就緒偵測**(R5-E6 / AC-1.3d):依賴瀏覽器支援 WebSocket — 所有現代瀏覽器原生支援,風險低
|
|
||||||
|
|
||||||
> **注意**:v1.2 靠 Wails webview(WebView2 / WKWebView)是 Chromium/Safari 核心的一個已知固定版本,v2.0 改用使用者系統瀏覽器後**多了一維瀏覽器版本差異**。這是 Architect TDD v2 和 Testing Agent 要接受的新測試矩陣。
|
|
||||||
|
|
||||||
### 6.4 安全性需求(修訂)
|
|
||||||
|
|
||||||
- ✅ Server 只監聽 `127.0.0.1`,**絕對不 bind 0.0.0.0**(同 v1.2,R5 共識 7 再次確認)
|
|
||||||
- ✅ **CORS 中介層**:`Access-Control-Allow-Origin` 只允許 `http://127.0.0.1:*` / `http://localhost:*`(**v2.0 新增**,R5 共識 6)
|
|
||||||
- ✅ 使用者資料存在 OS 慣例目錄(同 v1.2)
|
|
||||||
- ❌ 無 TLS(localhost 不需要)(同 v1.2)
|
|
||||||
- ❌ 無認證(單人工具)(同 v1.2)
|
|
||||||
- ⚠️ 瀏覽器 DevTools 暴露:v2.0 使用者可以用 F12 看到所有 API 呼叫、WebSocket 流量。這不是安全問題(因為是單人本機),但要在 FAE 訓練文件提醒「不要把瀏覽器分頁截圖給外部,可能含硬體序號」
|
|
||||||
|
|
||||||
### 6.5 其他非功能需求
|
|
||||||
|
|
||||||
其餘章節(相容性、可維護性、可打包、可用性、日誌)**同 v1.2**,詳見 [`nonfunctional.md`](./nonfunctional.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 第三方授權宣告(大改,取代 v1.2 §4.8)
|
|
||||||
|
|
||||||
### 7.1 依賴對照表(v2.0)
|
|
||||||
|
|
||||||
| 依賴 | 用途 | 授權 | 宣告要求 | v1.2 → v2.0 變動 |
|
|
||||||
|------|------|------|---------|-----------------|
|
|
||||||
| **ffmpeg LGPL build(BtbN)** | Windows / Linux 的攝影機 / 影片 decode | **LGPL v2.1+** | 必須宣告(LGPL 條文、動態連結、About 對話框列 `Powered by FFmpeg (LGPL)` + 連結) | v1.2 是 GPL build 標 `under legal review` 🔴,v2.0 改 LGPL 方案 B 解除 blocker(R5-6) |
|
|
||||||
| **ffmpeg LGPL build(macOS 自 build)** | macOS 的攝影機 / 影片 decode | **LGPL v2.1+** | 同上;額外說明「本 binary 由 Innovedus 在 macOS 上自 ffmpeg 官方 source 以 `./configure --enable-shared --disable-gpl` 等 flag build 出,源碼可取得」 | **v2.0 新增**。Decoder-only ~20MB,只含 mp4/avi/mov/mpeg/mpg 五種 decoder(R5-6a),commit 到 `vendor/ffmpeg/macos/`(R5-6b)。ffprobe 一起包(R5-6c)|
|
|
||||||
| **KneronPLUS SDK**(wheel)| 裝置 / 推論 | Kneron 專有 | 同 v1.2(發佈前 gate,R5 風險維持)| 同 v1.2 |
|
|
||||||
| **Python runtime**(python-build-standalone)| 執行 KneronPLUS | PSF 2.0 | 同 v1.2 | 同 v1.2 |
|
|
||||||
| **Python wheels**(numpy / opencv / pyusb 等)| 推論依賴 | BSD / MIT / Apache 2.0 為主 | 同 v1.2 | 同 v1.2 |
|
|
||||||
| **Wails v2** | Wails 控制台殼 | MIT | 同 v1.2 | 角色改變(v1.2 是前端 shell,v2.0 是控制台 shell)|
|
|
||||||
| **Next.js / React / shadcn / Radix / Tailwind / Zustand** | 瀏覽器 Web UI | MIT 為主 | 同 v1.2 | 同 v1.2 |
|
|
||||||
| ~~**yt-dlp**~~ | ~~URL 影片下載~~ | ~~Unlicense~~ | **v2.0 刪除** | 整個 vendor 拔掉 |
|
|
||||||
|
|
||||||
### 7.2 macOS ffmpeg 自 build 的長期責任
|
|
||||||
|
|
||||||
Architect TDD v2 必須產出 `scripts/build-ffmpeg-macos.sh`(記錄 configure flags / source tarball URL / checksum 驗證)。LGPL ffmpeg 幾乎不需更新(R5-6b);~20MB binary commit 到 repo 可接受;2-3 年後 build 系統變化時需 ~0.5 人天重 build。
|
|
||||||
|
|
||||||
### 7.3 授權檔案落點
|
|
||||||
|
|
||||||
同 v1.2 `<APPDATA>/licenses/`,但:❌ 刪 `LICENSE-yt-dlp.txt`;✅ `LICENSE-ffmpeg.txt` 從 GPL 換成 LGPL v2.1 + v3;✅ 新增 `ffmpeg-macos-build-notes.md` 說明 macOS binary 來源。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 風險與相依性(大幅修訂)
|
|
||||||
|
|
||||||
### 8.1 技術風險變動
|
|
||||||
|
|
||||||
| 風險 ID | v1.2 狀態 | v2.0 處理 |
|
|
||||||
|---------|-----------|-----------|
|
|
||||||
| R1(KneronPLUS Linux wheel glibc)| 高可能 / 高影響 | 同 v1.2(不變)|
|
|
||||||
| R2(Windows WinUSB UAC)| 中 / 高 | 同 v1.2 |
|
|
||||||
| R3(KneronPLUS macOS x86_64)| 中 / 高,已被 Q4 降級 | 同 v1.2 |
|
|
||||||
| R4(Linux venv)| 中 / 中 | 同 v1.2 |
|
|
||||||
| R5(Kneron .nef re-distribution)| 發佈前 gate | 同 v1.2 |
|
|
||||||
| **R6(ffmpeg GPL blocker)** | 🔴 release blocker | **✅ 解除**(R5-6 走 LGPL 方案 B)|
|
|
||||||
| R11(發佈通路)| P2 追蹤 | 同 v1.2 |
|
|
||||||
| R12(CI runner)| P2 追蹤 | 同 v1.2 |
|
|
||||||
|
|
||||||
### 8.2 新增風險(v2.0)
|
|
||||||
|
|
||||||
#### N-R1 — 瀏覽器相容性差異(新)
|
|
||||||
|
|
||||||
| 項目 | 內容 |
|
|
||||||
|------|------|
|
|
||||||
| 描述 | v2.0 改用使用者系統瀏覽器後,MJPEG decode 效能、WebSocket 穩定性、深色模式判斷都會因瀏覽器而異。Safari 的 MJPEG 延遲可能超過 150ms 上限 |
|
|
||||||
| 可能性 | 中 |
|
|
||||||
| 影響 | 中(Safari 使用者體驗可能變差,但 P1 FAE 多用 Chrome)|
|
|
||||||
| 等級 | **P2 追蹤項** |
|
|
||||||
| 緩解 | 1) 預設推薦 Chrome / Edge;2) Safari 列為「可用但不保證」;3) Testing Agent 測試矩陣加 Safari;4) 發佈說明寫「若遇延遲過高,請改用 Chrome / Edge」 |
|
|
||||||
| PM 行動 | Testing 階段實測 Safari,若嚴重超標則在 Wails 控制台自動開瀏覽器時優先選 Chrome / Edge |
|
|
||||||
|
|
||||||
#### N-R2 — macOS 自 build ffmpeg 的長期維護成本(新)
|
|
||||||
|
|
||||||
| 項目 | 內容 |
|
|
||||||
|------|------|
|
|
||||||
| 描述 | macOS 上沒有現成的 LGPL decoder-only ffmpeg binary,需要 Innovedus 自 build(R5-6a / R5-6b)。2-3 年後若 ffmpeg build 系統變化 / configure flag 調整 / source tarball 結構變化,可能需要重新 build |
|
|
||||||
| 可能性 | 中(長期)|
|
|
||||||
| 影響 | 低—中 |
|
|
||||||
| 等級 | **P3 追蹤項**(不緊急)|
|
|
||||||
| 緩解 | 1) TDD v2 產出 `scripts/build-ffmpeg-macos.sh`,記錄完整 build 步驟;2) commit 時把 source tarball checksum、configure flags、build 環境一起寫進 commit message;3) 每 2 年做一次 build 演練確保 script 還能跑 |
|
|
||||||
| PM 行動 | 交付時在 delivery docs 加「ffmpeg rebuild SOP」 |
|
|
||||||
|
|
||||||
#### N-R3 — 使用者誤解「關閉視窗 ≠ 結束 server」(新)
|
|
||||||
|
|
||||||
| 項目 | 內容 |
|
|
||||||
|------|------|
|
|
||||||
| 描述 | R5-2 決定維持「關閉 Wails 視窗 = 結束 server」,但因為 v2.0 多了瀏覽器視窗這個「真正的操作介面」,使用者可能誤以為「我關掉 Wails 視窗沒關係、瀏覽器 tab 還在、應該還能用」。這種誤解會導致他在瀏覽器看到 Offline Overlay 時很困惑 |
|
|
||||||
| 可能性 | 高 |
|
|
||||||
| 影響 | 中(體驗面混亂,不危險)|
|
|
||||||
| 等級 | **P2 追蹤項** |
|
|
||||||
| 緩解 | 1) Wails 控制台 Close 按鈕的 tooltip / confirm dialog 明確寫「關閉此視窗會結束 Local Server」(AC-7.6);2) Web UI 的 Offline Overlay 文案引導「請回到 visionA-local 桌面視窗重新啟動」;3) First-Run 歡迎頁加一句說明 |
|
|
||||||
| PM 行動 | Design Agent 在 Design Spec v2 確認這三處文案都到位 |
|
|
||||||
|
|
||||||
#### N-R4 — 砍 Mock 後 CI / E2E 測試困難(新)
|
|
||||||
|
|
||||||
| 項目 | 內容 |
|
|
||||||
|------|------|
|
|
||||||
| 描述 | v1.2 有 Mock 模式,CI runner 沒插硬體也能跑 E2E 測試(跑 Mock pipeline)。v2.0 砍 Mock 後,CI runner 要跑裝置管理 / 模型切換 / 推論 E2E 時,若沒硬體就測不了 |
|
|
||||||
| 可能性 | 高 |
|
|
||||||
| 影響 | 中(測試覆蓋率下降)|
|
|
||||||
| 等級 | **P2 追蹤項** |
|
|
||||||
| 緩解 | 1) E2E 測試分層:「不需硬體的 UI 測試」(載入、i18n、Settings、Offline Overlay、Wails 控制台 start/stop button)可在無硬體 CI 跑;「需要硬體的測試」(裝置偵測、推論)只能在有硬體的 runner 跑;2) 用 HTTP fixture mock 後端 API(前端 unit 測試層級),但不碰 Go server 的推論 pipeline;3) Testing Agent 在測試計畫明確區分兩層 |
|
|
||||||
| PM 行動 | 與 Testing Agent / DevOps 討論是否要建「有硬體 CI runner」(M5+ 規劃)|
|
|
||||||
|
|
||||||
### 8.3 PM 需要進一步確認的事項(寫入 progress.md 未解決問題)
|
|
||||||
|
|
||||||
沿用 v1.2 三項(R5 授權 / R11 發佈通路 / R12 CI runner);新增 **N-R1 瀏覽器相容性**(Testing 驗證 Safari/Chrome/Edge/Firefox MJPEG + WebSocket)、**N-R4 CI/E2E 測試分層**(Testing + DevOps 於 TDD v2 提出分層方案)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 策略性路線圖(更新)
|
|
||||||
|
|
||||||
- **Now(本 sprint,重構實作)**:PRD v2.1 / Design Spec v2 / TDD v2 三方互審 → Architect 產 ffmpeg LGPL vendor script + Wails 控制台實作 + CORS middleware + 刪檔清單 → Design 產控制台 wireframe + Offline Overlay + source-selector 拔 URL → 拆 milestone 進開發
|
|
||||||
- **Next(重構完成後)**:M7 Windows build 重驗(R5-7);v1.0.0 release gate:Kneron re-distribution 授權(R5 發佈前 gate);三平台 smoke test
|
|
||||||
- **Later(v1.0.0 發佈後)**:視回饋決定是否放回 v1.2 功能(Mock / URL 推論 / tray);視需求升級 LAN 模式(重走 R5-1 動機 C)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 變更追蹤(和 v1.2 的完整差異清單)
|
|
||||||
|
|
||||||
| 區域 | v1.2 | v2.0 | R5 依據 |
|
|
||||||
|------|------|------|---------|
|
|
||||||
| 產品定位 | Wails 桌面 app 內嵌 Next.js UI | 本機服務 + 網頁控制台 | R5-1 |
|
|
||||||
| 競品類比 | Photo Booth | Ollama / Docker Desktop / LM Studio / SD WebUI | R5-1 |
|
|
||||||
| 一句話價值主張 | 「裝起來就能跑、離線可用、零依賴」 | 「雙擊 → 自動開瀏覽器 → 插 USB → 5 分鐘跑第一次推論」 | R5-1 / R5-4 |
|
|
||||||
| 北極星指標終點 | Mock 第一幀 | 瀏覽器第一幀(真實硬體)| R5-5a |
|
|
||||||
| Mock 模式 | MVP 必達 | **砍** | R5-5a |
|
|
||||||
| URL 影片推論 | US-8 / `/api/media/url` / yt-dlp 內嵌 | **砍** | R5 共識 2 |
|
|
||||||
| yt-dlp vendor(35MB)| 打包 | **砍** | R5 共識 2 |
|
|
||||||
| ffmpeg | GPL build(blocker 🔴)| **LGPL 方案 B 混合**(Win/Linux BtbN、macOS 自 build decoder-only ~20MB commit)| R5-6 / R5-6a / R5-6b / R5-6c |
|
|
||||||
| 安裝檔大小(macOS 目標)| ≤ 220 MB | **≤ 185 MB** | R5-6 |
|
|
||||||
| idle RAM(無硬體)| ≤ 500 MB / 上限 600 MB | ≤ 450 MB / 上限 550 MB | 砍 Mock / yt-dlp |
|
|
||||||
| Wails 視窗關閉 | 結束程式 | **同 v1.2**(R5-2 復議後維持)| R5-2 |
|
|
||||||
| Tray | 砍(v1.2 Q-A)| **同 v1.2**(R5-3 復議後維持)| R5-3 |
|
|
||||||
| 啟動時自動開瀏覽器 | 無 | **新增,每次啟動都開**(R5-D3),macOS/Win 預設 ON、**Linux 預設 OFF**(R5-D2),toggle 住 Wails 控制台 Settings(已與 Design 取得共識) | R5-4 / R5-D2 / R5-D3 |
|
|
||||||
| AC-1.3 啟動預算 | — | **≤ 60 秒**(原 v2.0 草案 10 秒硬指標被 R5-E1 取代)+ 階段化進度(6 階段,R5-E2)+ 20 秒卡住提示(R5-E3)+ 60 秒超時進 Error state(R5-E4)+ WebSocket 就緒偵測(R5-E6) | **R5-E1~E6** |
|
|
||||||
| Server 崩潰 OS 通知 | 無 | **新增**(控制台 Error banner 並存,R5-D1)| R5-D1 |
|
|
||||||
| First-Run wizard 步數 | 3 步(歡迎 / 模式選擇 / 硬體偵測) | **2 步**(歡迎 / 硬體偵測,砍模式選擇;無硬體可按「稍後再設定」略過) | R5-5a / Design review Minor 4 |
|
|
||||||
| Offline Overlay 硬阻斷 | — | **新增驗收特性**(`role="alertdialog"` + focus trap + 無 ✕ 按鈕 + 純色卡片) | Design review Minor 2 |
|
|
||||||
| Wails 控制台 Footer 持久警示 | 無 | **新增**「⚠ 關閉此視窗會停止 Local Server」取代單次 confirm 作為主要提醒 | Design review Minor 3 |
|
|
||||||
| Error state 三動作按鈕 | 無 | **新增** `Restart Server` / `View log` / `Report issue`(Wails 控制台 Error banner 內) | Design review Minor 3 |
|
|
||||||
| Wails 控制台 | 無(webview 載 Next.js)| **新增 vanilla HTML/JS/CSS**:Start/Stop/Restart/Open Browser/log panel/status/port/dir/version | R5-5 / R5 共識 5 |
|
|
||||||
| Server Offline Overlay | 無 | **新增**(Web UI 全螢幕覆蓋層)| R5-2 |
|
|
||||||
| CORS middleware | 無 | **新增**(127.0.0.1 / localhost only)| R5 共識 6 |
|
|
||||||
| watchServer 連 3 次失敗 | `os.Exit(1)` | **改為 Error state,不退出** | R5 共識 8 |
|
|
||||||
| Server boot-id | 無 | **新增**(支援 Restart Server 按鈕的 tab 自動重連)| R5 共識 14 |
|
|
||||||
| US-1 | 3 分鐘看到 Dashboard | 5 分鐘瀏覽器跑第一次推論 | R5-1 / R5-4 |
|
|
||||||
| US-2 | Mock 試玩 | 日常啟動(取代)| R5-5a |
|
|
||||||
| US-5 | Wails webview 攝影機 | 瀏覽器 Workspace + 後端攝影機 pipeline(不走 `getUserMedia`)| R5-1 |
|
|
||||||
| US-7 / US-8 | 影片 / URL 上傳 | 合併為 US-6,砍 URL | R5 共識 2 |
|
|
||||||
| US-9 | Settings 進階分頁次要功能 | **升級為 Wails 控制台一級功能** | R5-1 / R5-5 |
|
|
||||||
| 新 US-7(server 崩潰 / 離線)| 無 | **新增** | R5-2 |
|
|
||||||
| 上傳影片副檔名 | mp4 / avi / mov / webm(瀏覽器能吃)| **mp4 / avi / mov / mpeg / mpg** | R5 共識 11 |
|
|
||||||
| Persona P3 Sam | 三順位目標 | **實質降級為非目標** | R5-5a |
|
|
||||||
| 新風險 | — | N-R1 瀏覽器相容 / N-R2 macOS ffmpeg 維護 / N-R3 關窗誤解 / N-R4 CI 測試分層 | — |
|
|
||||||
| 解除風險 | R6 ffmpeg GPL blocker | **解除** | R5-6 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 給 Orchestrator / 下輪審閱的問題
|
|
||||||
|
|
||||||
| # | 問題 | v2.1 狀態 |
|
|
||||||
|---|------|---------|
|
|
||||||
| 11-1 | auto-open Settings 資料落在哪?Web UI 和 Wails 控制台共用同一個 config 嗎? | ✅ **已解決**(Architect 答:`preferences.json @ <dataDir>/`,write-rename 原子寫,fallback DefaultPreferences;auto-open toggle 由 Wails 進程讀取,Web UI 不碰此欄位) |
|
|
||||||
| 11-2 | US-1 AC-1.3 10 秒預算是否可達? | ✅ **已解決(由 R5-E 取代)** — 整個問題被重新定義為「60 秒總預算 + 階段化進度 + perceived performance」,不再是硬時間指標 |
|
|
||||||
| 11-3 | idle RAM ≤ 450 MB 是否可達? | ✅ **已解決**(Architect 實測:Wails + Go server + Python 275–405 MB 達標;悲觀估含瀏覽器 tab ~500 MB 超 50 MB,但瀏覽器 tab 不在本指標範圍內,已於 §6.1 clarify) |
|
|
||||||
| 11-4 | N-R4 砍 Mock 後 CI / E2E 測試分層 | ⏳ **懸置**,交 Testing Agent 在測試計畫提出「不需硬體的 UI 測試」vs「需硬體測試」分層方案 |
|
|
||||||
| 11-5 | Web UI 要不要加常駐「本機服務」徽章? | ✅ **已解決:不加**(Design Agent 決定,改由 First-Run Step 1 歡迎頁一次性告知;見 Design review §D) |
|
|
||||||
| 11-6 | Settings auto-open toggle 住哪裡? | ✅ **已解決**(Design 仲裁採 PRD 原案:住 Wails 控制台 Settings menu;Design Spec v2 `v2/settings-update.md` 將同步修正) |
|
|
||||||
| 11-7 | R5-E 階段化進度 6 階段文案(中英雙語)定稿 | ⏳ **懸置**(R5-E5 使用者授權 Design Agent 決定;最終在 Design Spec v2 wireframe 定版時由使用者審閱 override) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 變更紀錄
|
|
||||||
|
|
||||||
| 版本 | 日期 | 作者 | 變更 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| v2.0 | 2026-04-14 | PM Agent | 依 R5 五輪決策重寫:砍 Mock / URL 推論 / yt-dlp;新增 Wails 控制台 + Server Offline Overlay + 首次自動開瀏覽器 + CORS;ffmpeg 從 GPL blocker 解除為 LGPL 方案 B 混合;Persona P3 降級;US-1/US-2/US-5/US-6/US-7 重寫;解除 R6,新增 N-R1 ~ N-R4 風險。完整差異見 §10 |
|
|
||||||
| v2.1 | 2026-04-14 | PM Agent | 吸收 Design 交叉審閱(4 Major + 4 Minor)+ R5-D1/D2/D3 + R5-E1~E6:(1) 「首次啟動自動開」→「每次啟動自動開」全文修正(R5-D3);(2) auto-open 平台預設 Linux OFF(R5-D2);(3) US-7 新增 AC-7.7 Server 崩潰時發 OS 原生通知(R5-D1);(4) §4 新增 N7 OS 通知、N8 啟動階段化進度(6 階段);(5) **AC-1.3 從 10 秒硬指標改寫為 60 秒 + perceived performance + AC-1.3a/b/c/d 四條新驗收**(R5-E1~E6);(6) US-2 明確日常啟動同樣 auto-open(Minor 1);(7) Offline Overlay 補齊 `role="alertdialog"` + focus trap + 不可關閉 + 純色卡片(Minor 2);(8) US-7 AC-7.3 / AC-7.6 補 Wails 控制台 Footer 持久警示 + Error state 三動作按鈕(Minor 3);(9) US-1 AC-1.4 補 First-Run 從 3 步縮為 2 步 + 「稍後再設定」略過(Minor 4);(10) §6.1 idle RAM 加不含瀏覽器 tab 註記 + Architect 實測佐證;(11) §11 懸念 5 題結案 + 新增 11-6 / 11-7,保留 11-4 CI 測試分層懸置 |
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
# Design 交叉審閱 PRD v2(2026-04-14)
|
|
||||||
|
|
||||||
> 審閱者:Design Agent
|
|
||||||
> 審閱對象:`/Users/jimchen/visionA/local-tool/.autoflow/02-prd/PRD-v2.md`(484 行)
|
|
||||||
> 對照基準:Design Spec v2(`03-design/design-spec-v2.md` + `03-design/v2/*.md`,共 1452 行子檔)+ R5 決策表 + R5-D 補充決策
|
|
||||||
> 本審閱使用繁體中文台灣用語
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 摘要(3 行)
|
|
||||||
|
|
||||||
- **總結論**:**需小改**(整體體驗面完整度 85%,4 個 Major + 4 個 Minor)
|
|
||||||
- **發現問題數**:Major 4、Minor 4
|
|
||||||
- **是否阻擋進開發**:⚠️ **部分阻擋**。R5-D1/D2/D3 三題是使用者已做的決策,PRD v2 沒完整吸收 → PRD 必須先修正 Major 1–3 才能進 M 級開發;其餘問題可在補充 FAQ / UX writing 階段處理
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 使用者體驗完整性檢查
|
|
||||||
|
|
||||||
| 檢查項 | 檢查結果 |
|
|
||||||
|-------|---------|
|
|
||||||
| **US-1 首次啟動**:有無明確寫「每次啟動都會自動開瀏覽器」(R5-D3)?Linux 預設 OFF 的差異處理有沒有寫? | ❌ **Major 1**。PRD v2 §0 摘要、§4 N2、§5 US-1 全部沿用「首次啟動自動開瀏覽器」字面。R5-D3 已明確「每次啟動都自動開」,PRD 必須把「首次」改為「啟動時自動開瀏覽器(每次啟動都生效)」。§6.3 / §4 也完全沒提 **Linux 預設 OFF 的差異**(R5-D2)|
|
|
||||||
| **US-2 日常啟動**:同上 | ⚠️ **Minor 1**。US-2 AC-2.1 寫「≤ 5 秒」但沒明確寫「日常啟動時瀏覽器 auto-open 預設仍會發生」。AC-2.3 有提「若使用者關過 auto-open 則只開 Wails」,這一條**是正確的**,但和 §0 的「首次啟動自動開」字面有矛盾 |
|
|
||||||
| **US-5 關閉 app**:有無明確寫「關閉 Wails 視窗會觸發 Offline Overlay」?文案是否清楚? | ✅ **通過**。US-7 AC-7.5 + AC-7.6 有寫 |
|
|
||||||
| **US-6 上傳影片**:副檔名是否正確寫 mp4/avi/mov/mpeg/mpg? | ✅ **通過**。AC-6.2 正確 |
|
|
||||||
| **US-7 server 離線情境(新)**:Offline Overlay 是否正確描述?有沒有漏寫 Design Spec v2 的硬阻斷特性? | ⚠️ **Minor 2**。§4 N3 寫「**半透明**覆蓋層」和「**覆蓋層下方的 UI 凍結不可操作**」。「半透明」這個措辭會誤導 Architect/Frontend — Design Spec v2 server-offline-overlay.md §ARIA 明確規定「**卡片背景必須是純色**(不是半透明),才能達到 4.5:1 對比」。PRD 的「半透明」只指 **backdrop**,不指訊息卡片本體。建議改為「全螢幕半透明 backdrop + 置中純色卡片」。**不可關閉** 特性也沒在 PRD 任何一條驗收標準出現(硬阻斷 + focus trap + `role="alertdialog"` 全部缺失)|
|
|
||||||
| **§4 新功能「每次啟動自動開瀏覽器」(R5-D3)** | ❌ **Major 1**(同上)|
|
|
||||||
| **§4 新功能「Linux 預設 OFF 切換」(R5-D2)** | ❌ **Major 2**。完全缺失。§4 N2、§6.3、§6.4、§8 任何一節都沒提這個跨平台差異。這會導致 Architect 不知道要在 config loader 做平台判斷 |
|
|
||||||
| **§4 新功能「Server 崩潰時除控制台 banner 外仍發 OS 通知」(R5-D1)** | ❌ **Major 3**。PRD v2 AC-3.7 只說「插 USB 時不發原生 OS 通知」,但 R5-D1 是**另一個情境**(server 崩潰)— 使用者明確決定「**保留** OS 通知」。PRD v2 §4 / §5 US-7 完全沒寫,Architect 如果只看 PRD 會以為「crash 時不用發通知」|
|
|
||||||
| **§6 瀏覽器相容性(Safari 攝影機警告、Edge/Chrome 推薦)是否明確?** | ✅ **通過**。§6.3 寫法清楚,且 §US-5 AC-5.6 解釋了為什麼不走 `getUserMedia`(所以 Safari 沒有攝影機權限問題)|
|
|
||||||
| **§6 i18n 中英雙語是否沒被砍?** | ✅ **通過**。§4.1 明確保留 |
|
|
||||||
| **§6 Dark Mode 跟隨系統是否保留?** | ✅ **通過**。§4.1 明確保留 |
|
|
||||||
|
|
||||||
**A 區結論**:PRD v2 吸收 R5 主決策沒問題,但 **R5-D 補充決策三題全部沒吸收**(這是使用者後來加的三個決定)。這三題是 PRD v2 Major 等級的缺漏。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. Design Spec v2 子檔對應檢查
|
|
||||||
|
|
||||||
| Design Spec v2 子檔 | PRD v2 對應章節 | 檢查結果 |
|
|
||||||
|---------------------|---------------|---------|
|
|
||||||
| `v2/control-panel.md` | §4 新功能 N1 Wails 控制台 + US-1 AC-1.3 / US-7 AC-7.3 | ✅ **覆蓋**。但 PRD 沒有明確提到 Footer 的「持久警示文字」設計(Design Spec v2 §4.6 control-panel.md 行 178)。PRD 的 AC-7.6 只提 tooltip + confirm dialog,遺漏 Footer 持久警示。**見 Minor 3** |
|
|
||||||
| `v2/server-offline-overlay.md` | §5 US-7 + §4 N3 | ⚠️ **覆蓋不完整**。見 Minor 2(硬阻斷 / 不可關閉 / focus trap 沒寫進 AC)|
|
|
||||||
| `v2/source-selector-update.md` | §5 US-6 + §4 砍除 #1/#3/#4 + §4 保留「批次影像上傳」 | ✅ **覆蓋** |
|
|
||||||
| `v2/first-run-update.md` | §5 US-1 + §4 砍除 #8 First-Run 模式選擇步驟 | ⚠️ **遺漏**。PRD 沒提「**First-Run wizard 從 3 步縮為 2 步**」(Design Spec v2 first-run-update.md 說明「砍模式選擇,只剩歡迎 / 偵測」)。開發階段 Frontend Agent 可能不知道要砍那一步。**見 Minor 4** |
|
|
||||||
| `v2/settings-update.md` | §4 保留 Settings + §4 N2「自動開瀏覽器」toggle | ⚠️ **位置矛盾**。Design Spec v2 settings-update.md §1.1 表格把 auto-open toggle 放在「**Web UI 裡的 Settings 一般 tab**」(sidebar navigation 到 Settings 頁),但 PRD v2 §4 N2 和 §4.4 功能對照表把 Settings「自動開瀏覽器」放在 **Wails 控制台**。**兩個文件對這個 toggle 住哪裡意見分歧**。**見 Major 4** |
|
|
||||||
|
|
||||||
**B 區結論**:5 個子檔大致都有被 PRD 提到,但 **Settings auto-open toggle 住哪裡** 這個定位問題是 Design Spec v2 和 PRD v2 之間的實質分歧,這是 Major 等級的不一致。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. 體驗紅線 / 缺漏
|
|
||||||
|
|
||||||
### C.1 北極星指標的時間預算
|
|
||||||
|
|
||||||
PRD v2 §1.4 新北極星「≤ 5 分鐘從雙擊到瀏覽器第一幀推論」是**正確更新**,符合 Design Spec v2 的無摩擦設計(每次自動開瀏覽器)。✅
|
|
||||||
|
|
||||||
### C.2 砍 Mock 後的 P3 fallback 體驗
|
|
||||||
|
|
||||||
Design Spec v2 `v2/first-run-update.md` 行 319–320 + §Error state 有「**偵測不到硬體 → 可略過進 Dashboard 空白狀態 + 引導文字**」的設計。PRD v2 AC-1.4 確實有寫「沒插硬體顯示空白 + 提示文字」,✅ **通過**。但是 **PRD 沒寫「使用者能不能按 Skip / 繼續」** — 意味 First-Run wizard 的 Step 2 「硬體偵測」是不是強制卡關?建議在 AC-1.4 補一句:「First-Run Step 2 在無硬體情況下可按 `稍後再設定` 略過,進 Dashboard 空白狀態。」**見 Minor 3**
|
|
||||||
|
|
||||||
### C.3 Server Crash 的錯誤處理體驗
|
|
||||||
|
|
||||||
Design Spec v2 `v2/control-panel.md` §5「Server Error 面板」有完整設計:Error banner + 三個動作按鈕(Restart Server / View log / Report)。PRD v2 US-7 AC-7.3 只說「顯示 Error 狀態 + log panel 顯示錯誤」,**完全沒提 Restart Server / View log / Report 三個動作按鈕**。Architect 可能不知道要實作這三個動作。**見 Minor 4**(同時也是上面 Major 3 的配套)
|
|
||||||
|
|
||||||
### C.4 關閉 Wails 視窗的 race condition
|
|
||||||
|
|
||||||
Design Spec v2 `v2/control-panel.md` 行 178「Footer 右側持久提示:`⚠ Closing this window will stop the server.` 12px muted」是一個重要的**無需彈窗的被動警示**設計。PRD v2 AC-7.6 只寫 tooltip / confirm dialog,**沒提 Footer 持久提示**。兩者設計理念不一樣:PRD 是「按下 Close 才顯示」,Design Spec v2 是「始終可見」。**前者體驗差,因為使用者按 Close 後可能直接盲目點「確定」**(Design Spec v2 行 182 已警告過這件事)。**見 Minor 3**(必須把 Footer 持久提示寫進 AC-7.6)
|
|
||||||
|
|
||||||
### C.5 Offline Overlay 的硬阻斷特性
|
|
||||||
|
|
||||||
如上面 Minor 2 所述,**PRD v2 §4 N3 沒告訴 Architect**:
|
|
||||||
- Overlay 是 `role="alertdialog"` + `aria-modal="true"` + focus trap
|
|
||||||
- 不可被使用者手動關閉(只能靠重試成功 / reload)
|
|
||||||
- 卡片背景必須純色(不是 backdrop 的半透明)
|
|
||||||
|
|
||||||
這三條是 WCAG A11y 關鍵設計決策,PRD v2 必須在 §4 N3 或 AC-7.1/7.2 補齊。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. 對 PM 懸念 §11-5 的回答(常駐徽章)
|
|
||||||
|
|
||||||
**PM 問題**:Web UI 要不要加一個「Server 是從 visionA-local 跑的」常駐徽章?
|
|
||||||
|
|
||||||
**Design Agent 決定**:❌ **不加**(支持 PM 的傾向)
|
|
||||||
|
|
||||||
**理由**(FAANG Senior Product Designer 視角):
|
|
||||||
|
|
||||||
1. **視覺雜訊成本 vs 防誤解收益不對等**。常駐徽章會佔用每一個瀏覽器畫面的固定像素(通常 32–40 px),但它防止的「誤以為是網頁服務」情境只在「使用者截圖給外部」的**極小機率事件**發生。以 FAE Arthur 為主要使用者,他自己知道這是 local app,截圖給客戶時「自己加註解」比「產品永遠帶徽章」的 ROI 好太多。
|
|
||||||
2. **資訊已由其他管道傳遞**。瀏覽器網址列就是 `http://127.0.0.1:3721`,這是**最權威的「這是本機服務」證據**,而且是瀏覽器原生 UI,設計成本 0。再加徽章是重複告知。
|
|
||||||
3. **和設計語言衝突**。Web UI 的設計語言(shadcn + Tailwind + 淡色背景)追求**資訊層級乾淨**,常駐徽章會像浮水印一樣破壞這個語言。Ollama Web UI / LM Studio / SD WebUI 都沒有這種徽章,這是業界慣例。
|
|
||||||
4. **替代方案**:如果 PM 仍擔心「使用者不知道這是本機服務」,Design Spec v2 `v2/first-run-update.md` 的 Step 1(歡迎頁)可以加一句「你正在使用 visionA-local 的本機 Web 控制台」一次性文案,既不佔用永久視覺空間,又能在關鍵時刻傳達訊息。
|
|
||||||
|
|
||||||
**建議 PM 在 PRD v2 §11-5 寫**:「不加常駐徽章(Design 決定)。改由 First-Run Step 1 的歡迎文字一次性告知。」
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. 對 Architect Q6 的回答(Overlay 關閉 tab 按鈕)
|
|
||||||
|
|
||||||
**Architect 問題**:`window.close()` 在瀏覽器主動開的 tab 不能用(UX 取捨)
|
|
||||||
|
|
||||||
**Design Agent 決定**:✅ **Offline Overlay 不設「關閉 tab」按鈕**
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
|
|
||||||
1. **技術限制已確認**。瀏覽器安全規範:`window.close()` 只能關閉**由 `window.open()` 開的 tab**。visionA-local 的 tab 是使用者手動開的(`xdg-open` / `open` / `start` 透過 OS 層開瀏覽器 → 瀏覽器建立新 tab),不是 JS 開的 → `window.close()` **一律失效**(Chrome/Edge/Safari/Firefox 全部一致)。
|
|
||||||
2. **Design Spec v2 `v2/server-offline-overlay.md` 已經沒有這個按鈕**(見行 38–41 原型、行 80–81 元件表)。目前只有兩個按鈕:
|
|
||||||
- `重試連線`(Primary, lg)
|
|
||||||
- `了解更多 ↓`(Ghost, sm,展開 help text)
|
|
||||||
3. **使用者的兩條恢復路徑都不需要「關閉 tab」按鈕**:
|
|
||||||
- **路徑 A(雙擊重開 app)**:Design Spec v2 行 142 已設計「Overlay 顯示期間 polling `/api/health` 每 3 秒」,使用者只要重開 app → 自動恢復 → Overlay 自動消失,不需手動關 tab。
|
|
||||||
- **路徑 B(不想用了)**:使用者直接按瀏覽器的 `⌘W` / `Ctrl+W` / 點 tab 的 ✕,是瀏覽器原生操作,完全不需要 JS 協助。
|
|
||||||
4. **「了解更多」展開 help text 已足夠**。如果使用者真的困惑「為什麼按鈕關不了 tab」,help text 裡補一句「要結束使用 visionA-local,請直接關閉瀏覽器分頁 (⌘W / Ctrl+W)」就好。
|
|
||||||
|
|
||||||
**建議 Architect 在 TDD v2 Q6 寫**:「Offline Overlay 不提供關閉 tab 按鈕(Design 決定)。恢復路徑 A(重開 app → auto recovery)與路徑 B(使用者自行 ⌘W)已足夠,不需 JS 介入。」
|
|
||||||
|
|
||||||
**額外建議**:Design Spec v2 `v2/server-offline-overlay.md` 行 154–157 的「了解更多」英文 / 中文 copy 應補一句「要離開本頁,請直接關閉瀏覽器分頁」。這是 Design 自己要補的 UX writing 後續項。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. 臆測 / 誤解 / 超範圍清單
|
|
||||||
|
|
||||||
| # | 類別 | 位置 | 內容 | 處理建議 |
|
|
||||||
|---|------|------|------|---------|
|
|
||||||
| F1 | **誤解 R5-D3** | §0 摘要點 5、§4 N2、§5 US-1 敘述 / AC-1.3、§10 變更追蹤 | 沿用「首次啟動自動開瀏覽器」字面,沒反映使用者 R5-D3「每次啟動都自動開」的決定 | 全文 replace:「首次啟動自動開瀏覽器」→「啟動時自動開瀏覽器(每次啟動)」 |
|
|
||||||
| F2 | **遺漏 R5-D2** | §4 N2、§6.3 瀏覽器相容性 | 沒寫 Linux 預設 OFF、macOS/Windows 預設 ON 的差異 | §4 N2 補一句:「平台預設:macOS / Windows ON;Linux OFF(因 `xdg-open` 在極簡 WM 下行為不穩定,R5-D2)」 |
|
|
||||||
| F3 | **遺漏 R5-D1** | §5 US-7 AC-7.3、§4 新增功能 | Server crash 時使用者決定「保留 OS 通知」,PRD 沒寫 | §5 US-7 補 AC-7.7:「Server 崩潰時,除控制台 Error banner 外,同步發一次 OS 原生通知(macOS Notification Center / Windows Toast / Linux `libnotify`,R5-D1)」 |
|
|
||||||
| F4 | **和 Design Spec v2 分歧** | §4 N2 / §4.4 對照表 | PRD 把 auto-open toggle 放在 Wails 控制台,Design Spec v2 放在 Web UI Settings > 一般 tab | 見 Major 4 的仲裁方案 |
|
|
||||||
| F5 | **沒臆測新增功能** | — | PRD v2 沒有超出 R5 範圍的功能(已確認)| ✅ 通過 |
|
|
||||||
| F6 | **Offline Overlay 描述失真** | §4 N3 | 寫「半透明覆蓋層」可能誤導為整個 overlay 都半透明 | 補強為「半透明 backdrop(`rgba(0,0,0,0.4-0.5)`)+ 置中純色卡片(光模式 `color.surface`)」 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. 問題清單
|
|
||||||
|
|
||||||
### Major(阻擋開發,必須改)
|
|
||||||
|
|
||||||
**Major 1:PRD v2 沒吸收 R5-D3「每次啟動都自動開瀏覽器」**
|
|
||||||
- 位置:§0 §4 N2 §5 US-1 §10
|
|
||||||
- 影響:若不改,Architect 會把「只在 `firstRunCompleted === false` 時 auto-open」寫進 TDD,導致第二次啟動不自動開瀏覽器,違反使用者決定
|
|
||||||
- 修正:PRD 全文去掉「首次」二字,改為「啟動時自動開瀏覽器(預設每次啟動生效)」
|
|
||||||
|
|
||||||
**Major 2:PRD v2 沒寫 R5-D2 Linux 平台預設差異**
|
|
||||||
- 位置:§4 N2 / §6.3 / §6.5
|
|
||||||
- 影響:若不改,Architect 會寫「三平台統一預設 ON」,Linux 使用者第一次啟動會看到瀏覽器沒開、不知道發生什麼
|
|
||||||
- 修正:§4 N2 補平台預設表;§6.3 瀏覽器相容性加一行「Linux:auto-open toggle 預設 OFF,見 §4 N2」
|
|
||||||
|
|
||||||
**Major 3:PRD v2 沒寫 R5-D1 Server 崩潰仍發 OS 通知**
|
|
||||||
- 位置:§5 US-7
|
|
||||||
- 影響:Architect 會以為「crash 時只顯示控制台 banner」,違反使用者決定
|
|
||||||
- 修正:US-7 新增 AC-7.7 / AC-7.8:「崩潰事件同步發一次 OS 原生通知;只在前景切換回控制台時不重複發(去重策略由 Architect 在 TDD v2 決定)」
|
|
||||||
|
|
||||||
**Major 4:Settings auto-open toggle 住哪裡 — PRD 和 Design Spec v2 分歧**
|
|
||||||
- 位置:PRD v2 §4 N2 / §4.4 vs Design Spec v2 `v2/settings-update.md` §1.1
|
|
||||||
- 背景:PRD 說住 Wails 控制台;Design Spec v2 說住 Web UI Settings > 一般 tab
|
|
||||||
- **Design Agent 仲裁**:**應該放 Wails 控制台(PRD 是對的)**。理由:
|
|
||||||
1. auto-open 是 **Wails 啟動時的行為**,讀取 config 的時機點在 Wails `main.go` 啟動階段,那時 Web UI 還沒 load;config 必須被 Wails 進程讀到
|
|
||||||
2. 使用者心智模型:「我要改 Wails 的行為 → 去 Wails 控制台改」比「我要改 Wails 的行為 → 去瀏覽器改」更直覺
|
|
||||||
3. **Design Spec v2 `v2/settings-update.md` 本節需要修正**。這是 Design Agent 自己的文件錯位,我會在下一輪更新
|
|
||||||
- 修正:PRD v2 §4 N2 不用改(原本就對);Design Spec v2 `v2/settings-update.md` 下一輪迭代時改為「auto-open toggle 住 Wails 控制台的 Settings menu,不住 Web UI」
|
|
||||||
|
|
||||||
### Minor(可在後續補,不阻擋開發)
|
|
||||||
|
|
||||||
**Minor 1:US-2 AC 沒寫清楚日常啟動時 auto-open 預設仍會發生**
|
|
||||||
- 位置:§5 US-2 AC-2.1
|
|
||||||
- 修正:在 AC-2.1 補一句「日常啟動時,若 Wails 控制台 Settings `autoOpenBrowser === true`,仍會自動開瀏覽器」
|
|
||||||
|
|
||||||
**Minor 2:Offline Overlay 的 A11y 硬阻斷特性沒寫進 PRD**
|
|
||||||
- 位置:§4 N3 / §5 US-7 AC-7.1
|
|
||||||
- 修正:§4 N3 補「Overlay `role="alertdialog"` + `aria-modal="true"` + focus trap + 不可手動關閉(只能 retry 成功 / 重 load)」;AC-7.1 補「Overlay 無 ✕ 按鈕」
|
|
||||||
|
|
||||||
**Minor 3:US-7 沒寫 Wails 控制台 Footer 持久警示 + Error state 三個動作按鈕**
|
|
||||||
- 位置:§5 US-7 AC-7.3 / AC-7.6
|
|
||||||
- 修正:
|
|
||||||
- AC-7.3 補:「Error state 面板含 `Restart Server` / `View log` / `Report issue` 三個動作按鈕(見 Design Spec v2 `v2/control-panel.md §5`)」
|
|
||||||
- AC-7.6 把「tooltip / confirm dialog」升級為「Footer 持久警示 + tooltip / confirm dialog 雙保險」
|
|
||||||
|
|
||||||
**Minor 4:US-1 沒寫 First-Run wizard 從 3 步縮為 2 步**
|
|
||||||
- 位置:§5 US-1 敘述 / AC-1.4
|
|
||||||
- 修正:AC-1.4 補「First-Run wizard 只剩 Step 1 歡迎 + Step 2 硬體偵測,砍 Mock 模式選擇步驟(R5-5a);Step 2 在無硬體時可按 `稍後再設定` 略過進 Dashboard 空白狀態」
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. 通過 / 不通過 結論
|
|
||||||
|
|
||||||
**不通過**(需要先處理 Major 1–3 才能進下一步)。
|
|
||||||
|
|
||||||
**Major 1/2/3 是使用者已做的決定被 PRD 漏寫**,必須由 PM Agent 修訂 PRD v2 一次小版本(v2.1),補齊三個 R5-D 決策。**Major 4 Design 自己承諾下一輪修正 Design Spec v2**(把 auto-open toggle 從 Web UI Settings 移到 Wails 控制台 Settings menu),PRD v2 本身不用改。
|
|
||||||
|
|
||||||
Minor 1–4 可由 PM 在同一次 v2.1 修訂時順便補齊,或記入「未解決問題」清單在開發前補齊,不阻擋 M 級架構動工,但必須在 Architect 寫 TDD v2 **之前**補上,否則會再次發生文件間矛盾。
|
|
||||||
|
|
||||||
**建議流程**:
|
|
||||||
1. PM Agent 收到本 review → 產出 PRD v2.1,修正 Major 1–3 + Minor 1–4(預估 20 分鐘)
|
|
||||||
2. Design Agent 同時修正 Design Spec v2 `v2/settings-update.md`(Major 4,auto-open 位置更正)
|
|
||||||
3. Architect 拿 PRD v2.1 + 更新後的 Design Spec v2 寫 TDD v2
|
|
||||||
4. 三方再次互審後進 M 級開發
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 附錄:引用對照
|
|
||||||
|
|
||||||
- R5-D1:progress.md「R5-Design 補充」第 1 題 — 保留 OS 通知
|
|
||||||
- R5-D2:progress.md「R5-Design 補充」第 2 題 — Linux 預設 OFF
|
|
||||||
- R5-D3:progress.md「R5-Design 補充」第 3 題 — 每次啟動都自動開
|
|
||||||
- Design Spec v2 `v2/control-panel.md` 行 77 / 178 — Footer 持久警示
|
|
||||||
- Design Spec v2 `v2/control-panel.md` §5 — Error state 面板三個按鈕
|
|
||||||
- Design Spec v2 `v2/server-offline-overlay.md` 行 38–41 / 80–81 / 203 / 216–217 — Overlay 元件 + 硬阻斷 + focus trap
|
|
||||||
- Design Spec v2 `v2/first-run-update.md` 行 24 / 51–52 — auto-open 預設 ON、兩步 wizard
|
|
||||||
- Design Spec v2 `v2/settings-update.md` §1.1 — auto-open toggle 位置(本文認定需修正)
|
|
||||||
- PRD v2 §0 / §4 N2 / §5 US-1 / §5 US-7 — 以上被指出的段落
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Design 第二輪審閱 PRD v2.1(2026-04-14)
|
|
||||||
|
|
||||||
> 審閱者:Design Agent
|
|
||||||
> 審閱對象:`/Users/jimchen/visionA/local-tool/.autoflow/02-prd/PRD-v2.md`(v2.1,500 行)
|
|
||||||
> 對照基準:第一輪審閱(本檔 §A–§H)+ R5-D / R5-E 決策 + Design Spec v2.1 `03-design/v2/startup-progress.md`(417 行,6 階段已定版)
|
|
||||||
> 範圍:**只審 v2.1 補丁差異**,不重審 v2.0 未動章節
|
|
||||||
> 本審閱使用繁體中文台灣用語
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 摘要(3 行)
|
|
||||||
|
|
||||||
- **總結論**:✅ **通過**(不需第三輪 PRD 修訂;可直接進 M8 開發前 TDD v2 撰寫)
|
|
||||||
- **第一輪 4 Major / 4 Minor 修復情況**:Major 全部修好(4/4)、Minor 全部修好(4/4)。R5-E1~E6 全數正確落地進 US-1 AC-1.3 系列。
|
|
||||||
- **是否阻擋 M8**:❌ **不阻擋**。第二輪只發現 1 個 Minor 文字殘留 + 1 個 Minor Linux skipped 狀態未在 PRD 提及(可由 TDD / Design Spec 側吸收,不需 PRD 再修)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 第一輪 Major 修復檢查
|
|
||||||
|
|
||||||
| 第一輪 Major | v2.1 修復位置 | 判定 |
|
|
||||||
|------|-------------|------|
|
|
||||||
| **Major 1** R5-D3「每次啟動自動開」 | §0 §22 摘要點 5 改寫為「**每次啟動**都自動開」;§0.1 補丁表 M1 明列 replace;§2.1 P1 描述、§4 N2、§5 US-1 AC-1.6、§5 US-2 AC-2.1、§10 變更追蹤行 456 全部一致 | ✅ **修好** |
|
|
||||||
| **Major 2** R5-D2 Linux 預設 OFF | §0 摘要點 5 + §4 N2 + §5 US-1 AC-1.6 + §5 US-2 AC-2.3 + §6.3 + §10 變更追蹤行 456 全部明確列出「macOS/Windows ON;Linux OFF」+ 引用 R5-D2 + 引用 `xdg-open` 行為不穩理由 | ✅ **修好** |
|
|
||||||
| **Major 3** R5-D1 Server 崩潰 OS 通知 | §5 US-7 新增 **AC-7.7**(行 275)明寫「除控制台 Error banner 外,同步發一次 OS 原生通知」+ 平台對應(Notification Center / Toast / `libnotify`)+ 去重策略授權 Architect 在 TDD v2 決定;§4 N7(行 167)也新增「Server 崩潰 OS 原生通知」功能條目 | ✅ **修好**(同時於 US 與功能清單兩處落地,比第一輪要求更完整) |
|
|
||||||
| **Major 4** auto-open toggle 位置 | §4 N2(行 162)明標「auto-open toggle 住 Wails 控制台的 Settings menu **(已與 Design 取得共識)**」;§4.4 對照表行 179 同步;§11-6 標 ✅ 已解決 | ✅ **修好** |
|
|
||||||
|
|
||||||
**A 區結論**:第一輪 4 個 Major 全部修好,無殘留。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 第一輪 Minor 修復檢查
|
|
||||||
|
|
||||||
| 第一輪 Minor | v2.1 修復位置 | 判定 |
|
|
||||||
|------|-------------|------|
|
|
||||||
| **Minor 1** US-2 沒寫日常 auto-open | §5 US-2 AC-2.1(行 214)改寫:「日常啟動時,若 Wails 控制台 Settings `autoOpenBrowser === true`,**仍會自動開瀏覽器**(和首次啟動行為一致,R5-D3 / Design review Minor 1)」 | ✅ **修好** |
|
|
||||||
| **Minor 2** Offline Overlay 硬阻斷特性 | §4 N3(行 163)補:「**A11y 硬阻斷特性**:`role="alertdialog"` + `aria-modal="true"` + focus trap + **不可由使用者手動關閉**(無 ✕ 按鈕)」;§5 US-7 AC-7.1(行 269)也補「Overlay 無 ✕ 按鈕、不可手動關閉」+ 引用 Design review Minor 2;§4 N3 同時補強為「全螢幕半透明 backdrop(`rgba(0,0,0,0.4-0.5)`)+ 置中純色卡片」(解除 F6 措辭誤導) | ✅ **修好** |
|
|
||||||
| **Minor 3** Footer 持久警示 + Error state 三按鈕 | §4 N1(行 161)補完整段:「**Footer 持久警示**:右下角固定顯示 `⚠ 關閉此視窗會停止 Local Server`(12 px muted)」+「**Error state 面板**:log panel 上方浮動 banner 含 `Restart Server` / `View log` / `Report issue` 三個動作按鈕」;§5 US-7 AC-7.3(行 271)+ AC-7.6(行 274)同步 | ✅ **修好** |
|
|
||||||
| **Minor 4** First-Run wizard 3 步→2 步 | §5 US-1 AC-1.4(行 202)補:「First-Run wizard **從 v1.2 的 3 步縮為 2 步**(砍模式選擇,R5-5a):Step 1 歡迎 → Step 2 硬體偵測。Step 2 在無硬體情況下可按 `稍後再設定` 略過」;§10 變更追蹤行 459 同步登記 | ✅ **修好** |
|
|
||||||
|
|
||||||
**B 區結論**:第一輪 4 個 Minor 全部修好,無殘留。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. R5-E 落地檢查(v2.1 最大新增)
|
|
||||||
|
|
||||||
| R5-E | PRD v2.1 落點 | 判定 |
|
|
||||||
|------|-------------|------|
|
|
||||||
| **R5-E1** AC-1.3 改 60 秒 | §5 US-1 AC-1.3(行 197)改寫:「全程 ≤ 60 秒(R5-E1,原 10 秒硬指標取消)... 採 Nielsen Norman perceived performance 原則」;§11-2 標「✅ 已解決(由 R5-E 取代)」 | ✅ **正確落地** |
|
|
||||||
| **R5-E2** 階段化進度(6 階段)| §5 US-1 **AC-1.3a**(行 198):「啟動全程顯示階段化進度(6 階段,見 §4 N8),每階段有編號 + 動作描述 + 視覺回饋(spinner 或進度條)+ 中英雙語文案」;§4 N8(行 168)同步新增完整功能條目 | ✅ **正確落地** |
|
|
||||||
| **R5-E3** 20 秒 retry hint | §5 US-1 **AC-1.3b**(行 199):「任一啟動階段卡超過 **20 秒**,UI 必須顯示『正在重試』或『這步比預期久』類提示,**不可停留在白畫面或無變化狀態**」 | ✅ **正確落地** |
|
|
||||||
| **R5-E4** 60 秒 Error state | §5 US-1 **AC-1.3c**(行 200):「總啟動時間超過 **60 秒**仍未進入『就緒』狀態,Wails 控制台進入 Error state,顯示『Restart Server』『View log』『Report issue』三個動作按鈕(對應 §4 N1 / N6,與 watchServer 3 次失敗後行為一致)」 | ✅ **正確落地**(按鈕命名與 Design Spec v2.1 §3.7 完全一致) |
|
|
||||||
| **R5-E5** 階段文案 Design 決定 | §4 N8 行 168 末句:「階段文案由 Design Agent 最終決定(R5-E5)」;§5 AC-1.3a 同樣引用;§11-7 標「⏳ 懸置(R5-E5 使用者授權 Design Agent 決定)」— 但 Design Spec v2.1 已經定版(見 §D) | ✅ **正確落地**(PRD 不列文字本身,授權 Design 主導) |
|
|
||||||
| **R5-E6** WebSocket 就緒 | §5 US-1 **AC-1.3d**(行 201):「『瀏覽器就緒』的定義 = **Web UI 建立 WebSocket 連線到 server**(hub 收到第一個 client),不是瀏覽器視窗出現時。不做新 endpoint、不做固定延遲(R5-E6)」;§4 N8 + §6.3「WebSocket 就緒偵測」一節(行 321)同步 | ✅ **正確落地** |
|
|
||||||
|
|
||||||
**C 區結論**:R5-E1~E6 六項全部正確、完整落地進 PRD v2.1,且分布合理(功能條目 §4 N8 / 驗收標準 §5 AC-1.3 系列 / 非功能 §6.3 / 變更紀錄 §10 / 懸念 §11 都有同步),不會發生 Architect 漏看的情況。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. PRD 與 Design Spec v2.1 一致性
|
|
||||||
|
|
||||||
對照 Design Spec v2.1 `03-design/v2/startup-progress.md`:
|
|
||||||
|
|
||||||
| 對照點 | Design Spec v2.1 | PRD v2.1 | 判定 |
|
|
||||||
|------|------------------|---------|------|
|
|
||||||
| 階段數量 | 6 階段(§4 表格) | §4 N8 + AC-1.3a 都明寫「6 階段」 | ✅ 一致 |
|
|
||||||
| 階段順序與名稱 | 1 初始化控制台 / 2 檢查 Python / 3 啟動 server / 4 偵測裝置 / 5 開瀏覽器 / 6 等待 Web UI 連線 | §4 N8 順序:「初始化控制台 → 檢查 Python → 啟動 server → 偵測裝置 → 開瀏覽器 → 瀏覽器就緒」 | ✅ 一致(PRD 第 6 階段稱「瀏覽器就緒」,Design Spec 稱「等待 Web UI 連線」,語意對齊不衝突;Design 為 ground truth) |
|
|
||||||
| Error state 三按鈕命名 | `重試 / Retry`、`檢視 log / View Log`、`回報問題 / Report Issue` | AC-1.3c:「Restart Server / View log / Report issue」;AC-7.3:「`Restart Server` / `View log` / `Report issue`」 | ⚠️ **Minor 5(新)**:PRD 把第一顆按鈕寫成「**Restart Server**」(控制台原本既有的按鈕命名),Design Spec v2.1 §3.7 行 219 寫「**重試 / Retry**」(重置進度面板,重新跑階段 1)。**兩者語意不同**:Restart Server 是重啟 server 子程序、Retry 是重置啟動流程跑階段 1。建議 PRD 在 AC-1.3c 與 AC-7.3 統一為「Retry / 重試」(與 Design Spec v2.1 一致),或請 Architect 在 TDD v2 決定兩個是否合併為同一動作。**不阻擋開發**,但 TDD 必須仲裁。 |
|
|
||||||
| 60 秒總預算 | §3.7 + §1(R5-E4)| AC-1.3 / AC-1.3c | ✅ 一致 |
|
|
||||||
| 20 秒卡住 hint | §3.6 + §2.2 wireframe | AC-1.3b | ✅ 一致 |
|
|
||||||
| WebSocket = ready 訊號 | §4 階段 6 完成條件 | AC-1.3d + §4 N8 | ✅ 一致 |
|
|
||||||
| **Linux 階段 5 跳過 / 階段 6 manual hint** | Design Spec v2.1 §4.1「階段 5 Linux/Settings OFF 情境:`pending → skipped`」+「階段 6 Settings OFF 情境:description 改為 `請點擊控制台的「在瀏覽器開啟」按鈕`」 | PRD v2.1 §4 N2 / §6.3 / §5 AC-1.6 / AC-2.3 都有寫 Linux 預設 OFF 與「使用者可手動按 Open in Browser」,但**沒明寫階段化進度面板在 Linux 下「階段 5 跳過 + 階段 6 改 manual hint」**這個細節 | ⚠️ **Minor 6(新)**:缺 Linux 路徑下的階段面板行為描述。但這純屬實作層細節,Design Spec v2.1 已是 ground truth,Frontend 看 Design Spec 即可。**不阻擋、不需 PRD 修訂**。 |
|
|
||||||
| Reduced motion / WCAG 4.5:1 | Design Spec v2.1 §6 | PRD v2.1 §6.4 / §4 N3 已寫卡片純色 4.5:1,未提 startup panel 的 reduced-motion,但這已超出 PRD 顆粒度 | ✅ 可接受(細節由 Design Spec 與 TDD 承擔) |
|
|
||||||
|
|
||||||
**D 區結論**:核心一致,唯一需要警示的是 **Minor 5(按鈕命名分歧)**,TDD v2 必須仲裁。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. §11 懸念狀態核對
|
|
||||||
|
|
||||||
| # | v2.1 標示 | 第二輪判定 |
|
|
||||||
|---|---------|----------|
|
|
||||||
| 11-1 preferences 落點 | ✅ 已解決 | ✅ 確認 |
|
|
||||||
| 11-2 AC-1.3 10 秒可達 | ✅ 已解決(由 R5-E 取代) | ✅ 確認 |
|
|
||||||
| 11-3 idle RAM 450 MB | ✅ 已解決 | ✅ 確認 |
|
|
||||||
| 11-4 N-R4 CI/E2E 分層 | ⏳ 懸置 → Testing | ✅ 維持懸置(屬於 Testing 範疇,不阻擋 PRD) |
|
|
||||||
| 11-5 常駐徽章 | ✅ 已解決:不加 | ✅ 確認 |
|
|
||||||
| 11-6 auto-open toggle 位置 | ✅ 已解決:Wails 控制台 | ✅ 確認 |
|
|
||||||
| 11-7 R5-E 階段文案定稿 | ⏳ 懸置 | 🟡 **可改為 ✅ 已解決** — Design Spec v2.1 §4 已定版 6 階段中英雙語文案(zh-TW + en),且使用者已授權 Design 決定(R5-E5)。建議 PRD 下次小修時把 §11-7 改為 ✅ 並指向 `03-design/v2/startup-progress.md §4 / §7`。**不阻擋** |
|
|
||||||
|
|
||||||
**E 區結論**:懸念狀態合理,§11-7 可以順手結案但非必要。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. 第二輪新發現問題
|
|
||||||
|
|
||||||
### Major(阻擋)
|
|
||||||
|
|
||||||
**無**。
|
|
||||||
|
|
||||||
### Minor(不阻擋,建議交 TDD / Design Spec 階段順手處理)
|
|
||||||
|
|
||||||
| # | 標題 | 位置 | 修正建議 | 接手者 |
|
|
||||||
|---|------|------|---------|-------|
|
|
||||||
| **Minor 5** | Error state 第一顆按鈕命名分歧(Restart Server vs Retry) | PRD §5 AC-1.3c / AC-7.3 vs Design Spec v2.1 §3.7 | TDD v2 仲裁兩個動作是否合併。建議:Startup Error 階段用「Retry(重置進度,重跑階段 1)」、Running 階段 watchServer 失敗用「Restart Server(kill + 重 spawn server 子程序)」,是兩個獨立動作 | Architect(TDD v2) |
|
|
||||||
| **Minor 6** | PRD 未描述 Linux/auto-open OFF 情境下啟動進度面板的階段 5 skipped + 階段 6 manual hint 行為 | PRD §4 N8 / AC-1.3a | 不需修 PRD;Frontend 依 Design Spec v2.1 §4.1 實作即可 | Frontend(M8 開發) |
|
|
||||||
| **Minor 7(極輕微)** | §變更紀錄行 499 v2.0 那一列仍寫「首次自動開瀏覽器」 | PRD 行 499 | 純歷史紀錄、非生效規格,不需修 | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. 第二輪通過 / 不通過 結論
|
|
||||||
|
|
||||||
✅ **通過**。
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
1. 第一輪 Major 4/4 + Minor 4/4 全部修好,無殘留。
|
|
||||||
2. R5-E1~E6 六項全部正確落地,分布合理(功能、AC、非功能、變更紀錄、懸念全部同步)。
|
|
||||||
3. 與 Design Spec v2.1 `startup-progress.md` 主要一致;唯一分歧(Error state 按鈕命名 Minor 5)屬於語意層差異,由 TDD v2 仲裁即可。
|
|
||||||
4. §11 懸念狀態合理,§11-7 可順手結案但非必要。
|
|
||||||
5. PRD v2.1 控制在 500 行,符合文件管理策略;模組化結構未被破壞。
|
|
||||||
|
|
||||||
**不需第三輪 PRD 修訂**。建議流程:
|
|
||||||
|
|
||||||
1. ✅ PRD v2.1 凍結,進入 M8 開發前 TDD v2 撰寫
|
|
||||||
2. Architect 撰寫 TDD v2 時:
|
|
||||||
- 仲裁 **Minor 5** Error state 按鈕命名(Retry vs Restart Server)
|
|
||||||
- 接手 Minor 6 細節(Frontend 依 Design Spec v2.1 §4.1 實作 Linux 路徑)
|
|
||||||
- 接手 R5-D1 OS 通知去重策略(PRD 已授權)
|
|
||||||
3. Design Agent 同步:
|
|
||||||
- 確認 `v2/settings-update.md` 已修正 auto-open toggle 位置(Major 4 後續)
|
|
||||||
- 把 `startup-progress.md §4` 6 階段文案定版告知 PM,請 PM 在下次小修時把 §11-7 改 ✅
|
|
||||||
|
|
||||||
**對 M8 開發的訊號**:🟢 GO。
|
|
||||||
|
|
||||||
@ -1,537 +0,0 @@
|
|||||||
# Design 第一輪分析 — visionA-local 方向變更(2026-04-14)
|
|
||||||
|
|
||||||
> Design Agent · 第一輪分析筆記(非正式規格) · 2026-04-14
|
|
||||||
> 對應事件:使用者提出方向重大變更 — Wails 桌面 app 由「跑 Next.js 主 UI 的殼」**退化**為「Local Server 控制台」,主 UI 改為純瀏覽器網頁。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 摘要(3 行)
|
|
||||||
|
|
||||||
- 使用者要求把 visionA-local 從「Photo Booth 型桌面 app」轉為「**Docker Desktop / Ollama 型服務控制台 + 瀏覽器網頁 UI**」雙 UI 架構:桌面殼只管「server 生命週期 + log」,瀏覽器端承擔所有業務操作。
|
|
||||||
- 推論輸入從目前的 `camera / image / video(file) / video(url)` **砍為三種**:`camera / image / video 檔案上傳`,**URL/YT-DLP 整條移除**;模型來源只剩「預置 + 使用者上傳」,不再有第三方目錄。
|
|
||||||
- 這個改動在體驗面有實質 upside(多視窗、devtools、書籤、和 localhost 生態整合),但也讓原本已完成的 splash regression 修復方向逆轉、和第三輪 Q-A「砍 tray」決策再度打架(因為沒有 tray 又沒有主視窗、server 要不要在桌面 app 關閉後繼續活)。**這幾個衝突必須在進開發前先解。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 變更解讀(體驗面)
|
|
||||||
|
|
||||||
### A1. 為什麼使用者想這樣
|
|
||||||
|
|
||||||
使用者沒明講動機,但以 UX 研究角度推測有幾個合理動機,**按可能性由高到低**排序:
|
|
||||||
|
|
||||||
1. **對齊 localhost 生態的心智模型**
|
|
||||||
使用者自己是 FAE / 開發者,日常工具鏈是 `docker desktop` / `ollama serve` / `jupyter notebook` / `stable-diffusion-webui`。這些工具都走「桌面端只是 server 控制器,真正操作在瀏覽器 / CLI」的雙重介面模式。把 visionA-local 做成同樣形狀,學習成本 = 0,符合「這類工具應該長這樣」的直覺。
|
|
||||||
|
|
||||||
2. **瀏覽器能力是 Wails WebView 的超集**
|
|
||||||
- **多視窗**:同時開兩個 tab 對照 camera 推論 vs 影片推論
|
|
||||||
- **devtools**:F12 即用,可以看 network、console、storage
|
|
||||||
- **書籤 / 分頁歷史**:跳回上次看的裝置
|
|
||||||
- **瀏覽器擴充**:screenshot、錄影、Postman 等 workflow 工具直接接上
|
|
||||||
- **瀏覽器字體 / 高 DPI / 縮放**:Wails 在 Windows 的 WebView2 某些版本字體渲染有 bug,瀏覽器沒這問題
|
|
||||||
- **分享 URL**:`http://localhost:3721/workspace/xxx` 可以貼給同事或記在筆記裡
|
|
||||||
|
|
||||||
3. **推論結果可以在不同視窗同時看**
|
|
||||||
目前 Wails 單視窗模式,使用者只能看一個 workspace。FAE 到客戶現場 demo 時常見需求:一邊跑 camera、一邊準備下一段影片 — 開兩個瀏覽器 tab 就解決了。
|
|
||||||
|
|
||||||
4. **切換到不同 OS 或遠端機時維持一致**
|
|
||||||
雖然 visionA-local 定位單機,但使用者也許會在 Linux 無頭機器上 run server,然後用 Mac 筆電瀏覽器連 LAN 過去 demo。**這個動機如果存在,就不是單純的 UX 偏好,而是產品範圍的擴張**(LAN 可訪問),要主動問清楚(見 D 節)。
|
|
||||||
|
|
||||||
5. **Server 健康度獨立可見**
|
|
||||||
目前 Wails UI 把 server 當成黑盒子,server 崩了 UI 才喊 fatal dialog。獨立 log 面板讓使用者**在事情出錯前**就看到徵兆(例如 Python sidecar 有 warning),降低 debug 門檻。
|
|
||||||
|
|
||||||
**最有說服力的論證是 1 + 5 的組合**:使用者想要的不是「瀏覽器介面」這個手段,而是「**把 visionA-local 從『桌面軟體』重新定位為『本機服務 + 網頁控制台』**」這個心智模型。這是產品層面的 re-positioning,不只是 UI 重排。
|
|
||||||
|
|
||||||
### A2. 對使用者旅程的影響
|
|
||||||
|
|
||||||
#### 首次開啟流程(First-Run)
|
|
||||||
|
|
||||||
**現況(M7 splash + Next.js)**:
|
|
||||||
```
|
|
||||||
雙擊 app → Wails 視窗開 → splash 輪詢 GetServerStatus
|
|
||||||
→ server 起來 → window.location.replace 跳 Next.js 主 UI
|
|
||||||
→ First-Run 歡迎頁(歡迎 / 模式 / 偵測)→ Dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
**新方向**:
|
|
||||||
```
|
|
||||||
雙擊 app → 桌面控制台視窗開 → 自動 start server → log 跑 → status: running
|
|
||||||
→ 使用者點「Open in browser」→ 瀏覽器開 localhost:3721 → 進 First-Run → Dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
**體驗斷裂點**:
|
|
||||||
- 使用者從「雙擊 app → 看到 app」變成「雙擊 app → 看到 server 控制台(不是我要的)→ 還要再點一下」。這是**多一個步驟**。
|
|
||||||
- 對第一次用的 FAE 來說,看到「Local server: running · Open in browser」可能會愣一下 — 「我不是要裝網頁工具啊?」
|
|
||||||
- **解法**:首次啟動**自動**彈出瀏覽器(桌面控制台照樣開著在背景)。Docker Desktop 沒這麼做是因為 docker daemon 不需要瀏覽器,但 Ollama / Stable Diffusion WebUI 都會自動開。對齊後者體驗。
|
|
||||||
|
|
||||||
#### 日常使用流程
|
|
||||||
|
|
||||||
**現況**:雙擊 app → 直接操作
|
|
||||||
**新方向**:雙擊 app → 瀏覽器自動彈(或從 dock/tray 記憶上次狀態)→ 操作
|
|
||||||
- 如果使用者前一個 session 還沒關瀏覽器 tab,雙擊 app 時不該又彈一個新 tab — 應該偵測既有 tab 並 focus(技術上 `window.open` with same `target` name 就行)。
|
|
||||||
- 如果桌面控制台已經在跑、使用者雙擊 app 第二次,要 raise 既有控制台視窗(single-instance,第四輪決策有定義 `/ipc/raise` endpoint,可重用)。
|
|
||||||
|
|
||||||
#### 錯誤排除流程
|
|
||||||
|
|
||||||
**這是新架構最大的 UX 升級點。** 目前 server 崩潰時,使用者看到的是:
|
|
||||||
- Wails 視窗從 Next.js UI 變回 splash
|
|
||||||
- 或者彈出 fatal dialog「Server 已停止」
|
|
||||||
- 使用者要自己去 `~/Library/Application Support/visiona-local/logs/` 翻 log
|
|
||||||
|
|
||||||
**新架構**:
|
|
||||||
- 桌面控制台永遠看得到 log,server 崩了會有紅色行 / stack trace 直接 inline
|
|
||||||
- 一鍵 Restart 按鈕就地重試
|
|
||||||
- 可以 Copy log 貼 slack 問人
|
|
||||||
- 「Open logs folder」按鈕開 Finder/Explorer 到 log 目錄
|
|
||||||
|
|
||||||
**這個價值是 A1 論證 5 的具體落地。桌面控制台不是退化,是獲得一個「自助除錯介面」。**
|
|
||||||
|
|
||||||
#### 離開流程
|
|
||||||
|
|
||||||
**第四輪決策 Q7 是「B 傳統式(關閉 = 結束)」。新架構打到這個決策頭上:**
|
|
||||||
- 如果桌面控制台關了 = server 停了 → 瀏覽器的網頁就 **突然變磚塊**(fetch 全 500,camera 串流斷線)。使用者會很困惑:「我只是關掉那個 log 視窗啊?為什麼我的推論頁面壞了?」
|
|
||||||
- 如果桌面控制台關了 ≠ server 停 → 又回到 tray / 背景服務心智模型,和第三輪 Q-A「砍 tray」決策衝突。
|
|
||||||
- **這是 D 節最重要的待決策問題**。
|
|
||||||
|
|
||||||
### A3. 現有頁面搬遷工作
|
|
||||||
|
|
||||||
現有 Next.js 頁面結構:
|
|
||||||
```
|
|
||||||
frontend/src/app/
|
|
||||||
├── layout.tsx ← 全域 layout(sidebar + header)
|
|
||||||
├── page.tsx ← Dashboard(/)
|
|
||||||
├── workspace/[deviceId] ← 推論主畫面
|
|
||||||
├── devices/ ← 裝置列表
|
|
||||||
├── devices/[id] ← 裝置細節
|
|
||||||
├── models/ ← 模型列表
|
|
||||||
├── models/[id] ← 模型細節
|
|
||||||
└── settings/ ← 設定(4 分頁)
|
|
||||||
```
|
|
||||||
|
|
||||||
**搬遷需要改動的點**:
|
|
||||||
|
|
||||||
| 面向 | 現況 | 新架構需要做什麼 | 工作量 |
|
|
||||||
|------|------|-----------------|--------|
|
|
||||||
| **頁面結構** | Next.js pages | **完全不用動**。現有頁面直接以瀏覽器開 `http://localhost:<port>/` 就能跑 | 0 |
|
|
||||||
| **workspace 推論源 tabs** | camera / image / video,video tab 內有 file/url 雙模式 | **砍掉 video tab 內的 url 按鈕**,video tab 變成單一「upload file」。`pasteUrl` / `urlPlaceholder` / `urlHelpText` i18n keys 刪除。`source-selector.tsx` 的 `videoMode` state 和 `startFromUrl` 呼叫都刪。另外 `batch_image` sub-mode 保留(它是 image tab 的多檔上傳變體)。 | S |
|
|
||||||
| **第四輪「url 推論首次 ≤ 250ms」指標** | 本來假設 url 來源也算在延遲指標內 | URL 整條砍後,這條指標只剩 camera + 本地 video file。更新 PRD 的 R4-2 註記。 | XS(文字調整) |
|
|
||||||
| **模型來源** | models 頁面有預置模型 + Upload Dialog | 使用者說「除了預設的幾種只能用上傳的」→ 確認這和目前狀態一致(預置 + 上傳),**不需要改**。只是要刪掉任何「從 URL 下載模型」或「線上模型市集」的殘留(目前沒有,但要檢查) | XS |
|
|
||||||
| **devices 頁面 scan/connect** | 現有已經有 `Refresh Devices` 按鈕(快捷鍵 ⌘Shift+R),也有 per-device connect | 不用改,使用者說的「scan/connect device 介面」已經存在 | 0 |
|
|
||||||
| **Wails 原生呼叫** | splash 用 `GetServerStatus()` binding、fatal dialog 用 `runtime.EventsEmit` | **splash 從瀏覽器消失**,變成桌面控制台內部邏輯。Next.js 主 UI 必須**徹底不用任何 Wails JS binding**(M7-B 設計時已經做到,這點不用改)| 0 |
|
|
||||||
| **Dark Mode 偵測** | 現況走 CSS `prefers-color-scheme`(第四輪決策) | 瀏覽器原生支援,不用改 | 0 |
|
|
||||||
| **i18n 切換** | Settings > 一般 > 語言 | 不用改,瀏覽器 localStorage 持久化即可 | 0 |
|
|
||||||
| **OS 通知** | 第四輪 R4-8:裝置連/斷 toast、server 崩潰 native notification | **toast 完全不變**(瀏覽器支援好)。**Server 崩潰的 native notification 換位置**:不再由 Wails 處理,改由**桌面控制台自己處理**(它本來就是服務管理者,由它發通知比瀏覽器更合邏輯) | S |
|
|
||||||
| **Sidebar 與 Top bar** | Next.js 的 `layout.tsx` 已實作,含 sidebar navigation + user-friendly chrome | 不用改,但要考慮**要不要隱藏任何「return to desktop app」按鈕**之類的東西(目前沒有) | 0 |
|
|
||||||
| **Window chrome tokens** | 第四輪 `spec/07-design-tokens.md §7.5` 有 desktop 專用 elevation / window chrome token | 這些 token 只在桌面控制台用;網頁端不用(瀏覽器 chrome 由瀏覽器繪製)。不需要刪,但桌面控制台可以沿用 | 0 |
|
|
||||||
| **快捷鍵集**(⌘1-4 切換主區塊、⌘,、⌘U 等) | 現有由 Next.js 內部 keyboard handler 綁定 | **分裂為兩套**:桌面控制台只有 `⌘W(關閉=結束)/ ⌘Q`;其餘 ⌘1-4、⌘, 、⌘Shift+R、⌘U 全部留在瀏覽器端。`⌘W` 在瀏覽器是「關 tab」這是原生行為,不衝突。**但 `⌘Q` 在瀏覽器是「結束 browser」會誤殺所有 tab**,要和使用者確認怎麼處理(見 D 節) | M |
|
|
||||||
|
|
||||||
**總結**:**現有 Next.js 頁面 80-90% 可以零改動直接搬**。實際修改集中在三個點:
|
|
||||||
1. 砍 workspace 的 URL 推論 UI(1 個元件 + 相關 i18n)
|
|
||||||
2. 桌面控制台是全新 app(Wails main.go 大改)
|
|
||||||
3. First-Run 自動開瀏覽器的新 hook
|
|
||||||
|
|
||||||
### A4. 砍掉 URL 推論的影響
|
|
||||||
|
|
||||||
使用者這句「推論只需包含這三種 camera/image/上傳影片」等於宣判 URL 推論死刑,影響面:
|
|
||||||
|
|
||||||
**UX 面影響**:
|
|
||||||
|
|
||||||
1. **workspace 的 video tab 簡化**:
|
|
||||||
- 現況:點 video tab → 看到 `[上傳檔案] [貼上連結]` 兩顆 button → 選 file → 再點 Select Video
|
|
||||||
- 新方向:點 video tab → **直接就是檔案上傳區**(大 drop zone + 檔案選擇)
|
|
||||||
- 這**改善**了 video tab 的體驗(少一個決策層),符合 Jakob Nielsen 的「減少選擇負擔」原則。
|
|
||||||
- 支援格式:使用者說 `avi, mpeg, mp4, 瀏覽器能吃的格式`,實際上瀏覽器 `<input type="file" accept="video/*">` 就能列一批。我建議 accept list 顯式寫 `.mp4,.avi,.mpeg,.mpg,.mov,.mkv,.webm`(瀏覽器相容格式的超集),因為後端是 ffmpeg 而非 `<video>` tag,ffmpeg 吃得下的更多。**這條要和 Architect 確認 ffmpeg 的 decoder whitelist**。
|
|
||||||
|
|
||||||
2. **i18n 文案刪除**:
|
|
||||||
`camera.pasteUrl` / `camera.urlPlaceholder` / `camera.urlHelpText` 三個 key 刪掉(zh-TW + en),避免殘留 dead key。
|
|
||||||
|
|
||||||
3. **yt-dlp 打包怎麼辦?**
|
|
||||||
- 第四輪 R4 已決定 yt-dlp 要內嵌(35MB)+ ffmpeg 內嵌(77MB GPL build)。URL 推論砍掉後,**yt-dlp 的 35MB 完全是死重量**。
|
|
||||||
- **強烈建議**把 yt-dlp 從 vendor 清單移除,安裝檔可以瘦身 35MB(220MB → 185MB)。
|
|
||||||
- **ffmpeg 不能砍**:本地影片解碼還是需要 ffmpeg(`avi / mpeg / mov` 瀏覽器原生 `<video>` 不一定吃,後端 ffmpeg 是 decoder + transcode 關鍵)。
|
|
||||||
- **這條要寫進 D 節給 PM / Architect**:這是 M6 任務成果的部分退場,要追蹤 vendor 清單、Makefile、PRD 的第三方授權頁、安裝檔預期大小等多處更新。
|
|
||||||
|
|
||||||
4. **URL 推論是否有隱藏的未來用途?**
|
|
||||||
例如「RTSP 串流」(IP camera)也是走 URL 輸入。如果使用者未來想接監視器場景,RTSP 就回不來了。**這條要和 PM 確認是否真的永久砍,還是只是 MVP 先砍**。(我的 PM hat 建議:MVP 先砍,future backlog 標記 `RTSP 可能回歸`。)
|
|
||||||
|
|
||||||
5. **對現場 demo 的影響**:
|
|
||||||
FAE 帶筆電到客戶現場,如果沒有 Wi-Fi / 網路受限,YouTube URL 本來就跑不動 — 砍掉它和「完全離線」承諾更一致。**這是 PM/PRD 的 strategy 論證可以直接套用的 UX narrative**。
|
|
||||||
|
|
||||||
**總結**:砍 URL 是**淨收益**(簡化 UI + 瘦身 + 對齊離線承諾),只要注意 yt-dlp 要一起砍、PRD 文案要清。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 新的 Desktop Control Panel 設計提案
|
|
||||||
|
|
||||||
### B1. 視窗規格
|
|
||||||
|
|
||||||
| 項目 | 建議值 | 說明 |
|
|
||||||
|------|--------|------|
|
|
||||||
| 預設視窗寬度 | **720 px** | 不能太窄,log 行要能看;不能太寬,沒必要 |
|
|
||||||
| 預設視窗高度 | **560 px** | log 區塊要能看 15-20 行 |
|
|
||||||
| 最小寬度 | **560 px** | 保證按鈕 + status 不疊 |
|
|
||||||
| 最小高度 | **420 px** | |
|
|
||||||
| 可調整大小 | 是 | 使用者可能想把它拖大看更多 log |
|
|
||||||
| 最大化按鈕 | 保留 | |
|
|
||||||
| 最小化按鈕 | 保留 | |
|
|
||||||
| 置頂 | 否 | 不干擾瀏覽器操作 |
|
|
||||||
| 全螢幕 | 否(不需要) | |
|
|
||||||
| Title bar | 原生 | 不做自訂 title bar,省跨平台工 |
|
|
||||||
| 視窗標題 | `visionA-local · Server Control` | |
|
|
||||||
| 應用程式 icon | 沿用現有 `visiona-local/frontend/icon.png` | |
|
|
||||||
|
|
||||||
### B2. 佈局區塊(ASCII wireframe)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ ● visionA-local · Server Control [─][□][✕]│ ← Title bar(原生)
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ [visionA-local] Status: ●Running Port: 3721 v0.1.0 │ ← Header(品牌 + 狀態列)
|
|
||||||
│ Uptime: 00:12:43 PID: 45821 │
|
|
||||||
│ │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ [ Open in Browser ] [ Start ] [ Stop ] [ Restart ] │ ← Primary controls
|
|
||||||
│ │
|
|
||||||
│ ☑ Follow tail ☑ Show timestamps [ Filter: _______ ] │ ← Log controls
|
|
||||||
│ │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ 2026-04-14 10:23:41 INFO HTTP server listening on :3721 │
|
|
||||||
│ 2026-04-14 10:23:41 INFO wails ipc port: 49152 │
|
|
||||||
│ 2026-04-14 10:23:42 INFO device scan: found 1 Kneron KL520 │
|
|
||||||
│ 2026-04-14 10:23:43 INFO GET /api/devices 200 (4ms) │ ← Log panel
|
|
||||||
│ 2026-04-14 10:23:45 INFO GET /api/models 200 (2ms) │ (等寬字體, 可捲動,
|
|
||||||
│ 2026-04-14 10:23:58 WARN python sidecar restart (attempt 1) │ 等級著色)
|
|
||||||
│ 2026-04-14 10:23:59 INFO python sidecar ready │
|
|
||||||
│ 2026-04-14 10:24:12 INFO inference session start: classification│
|
|
||||||
│ ... │
|
|
||||||
│ │
|
|
||||||
│ │
|
|
||||||
│ │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ [ Clear log ] [ Copy log ] [ Export log ] [ Open log folder ] │ ← Log actions
|
|
||||||
│ │
|
|
||||||
│ Lines: 142 / 1000│ ← Footer status
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### B3. 元件細節
|
|
||||||
|
|
||||||
#### Header
|
|
||||||
|
|
||||||
- **品牌標記**:左側一個 20px 高的 visionA-local logo + 產品字樣(同現有 splash 視覺)
|
|
||||||
- **狀態指示燈**:大圓點 + 文字
|
|
||||||
- `●Running`(綠 / semantic `color.success`)
|
|
||||||
- `●Starting...`(黃 / semantic `color.warning`,附 spinner)
|
|
||||||
- `●Stopped`(灰 / semantic `color.muted-foreground`)
|
|
||||||
- `●Crashed`(紅 / semantic `color.destructive`,附小 ⚠️ icon)
|
|
||||||
- **運行資訊**:port / uptime / PID / version — 次要資訊用 `color.muted-foreground`
|
|
||||||
- **沿用既有 design tokens**(shadcn oklch),不另外造一套
|
|
||||||
|
|
||||||
#### Primary Controls(3 顆服務按鈕 + 1 顆瀏覽器按鈕)
|
|
||||||
|
|
||||||
| 按鈕 | 狀態邏輯 | 視覺優先級 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| **Open in Browser** | 只在 `Running` 時 enabled;點擊呼叫 OS open URL | **Primary(filled)**,位於最左側最顯眼處 |
|
|
||||||
| Start | 只在 `Stopped / Crashed` 時 enabled | Secondary(outlined) |
|
|
||||||
| Stop | 只在 `Running` 時 enabled;點擊後要有確認(避免誤按) | Secondary |
|
|
||||||
| Restart | 只在 `Running` 時 enabled | Secondary |
|
|
||||||
|
|
||||||
設計 rationale:**`Open in Browser` 是日常最高頻操作**(每次啟動都要按一次),必須視覺最重;Start/Stop/Restart 只在出事時才點。這違反了「Start 應該在第一個」的慣例,但符合使用頻率。
|
|
||||||
|
|
||||||
可以考慮**合併 Stop + Restart 為一顆「⋯」overflow menu**,避免誤按:
|
|
||||||
```
|
|
||||||
[ Open in Browser ] [ Start ] [ ⋯ Manage ▼ ]
|
|
||||||
├─ Stop server
|
|
||||||
├─ Restart server
|
|
||||||
└─ Clear data (dangerous)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Log 控制列(Log controls)
|
|
||||||
|
|
||||||
- **Follow tail**:預設開。使用者往上捲動時**自動關閉**(像 Docker Desktop / `tail -f`),捲到最底時自動重新開啟(或顯示 `Jump to latest` 按鈕)
|
|
||||||
- **Show timestamps**:預設開。關閉後 log 行不含時間戳,節省寬度
|
|
||||||
- **Filter**:即時過濾(字串 match,不做 regex 避免使用者不會用)。keyboard:`⌘F` 聚焦 filter
|
|
||||||
|
|
||||||
#### Log panel
|
|
||||||
|
|
||||||
- **字體**:等寬(SF Mono / Consolas / Monospace)
|
|
||||||
- **字級**:12px,無 line-height 壓縮
|
|
||||||
- **等級著色**:
|
|
||||||
- `INFO` → 預設文字色
|
|
||||||
- `WARN` → `color.warning`(琥珀)
|
|
||||||
- `ERROR` → `color.destructive`(紅)
|
|
||||||
- `DEBUG` → `color.muted-foreground`
|
|
||||||
- **長度上限**:記憶體最多 1000 行(ring buffer),超過舊的丟。避免長時間跑爆記憶體。
|
|
||||||
- **完整歷史**:寫檔到 `~/Library/Application Support/visiona-local/logs/server.log`,和當前 `stderr.log` / `stdout.log` 共存(Architect 確認)
|
|
||||||
- **支援選取複製**:使用者可以滑鼠拖選文字。無論 follow tail 是否開啟,選取時都要凍結畫面
|
|
||||||
- **Auto scroll 平滑**:新行滑入動畫 60ms,太快會閃
|
|
||||||
- **Drag-select + ⌘A select all + ⌘C copy**:原生支援即可
|
|
||||||
|
|
||||||
#### Log actions(底部按鈕列)
|
|
||||||
|
|
||||||
| 按鈕 | 功能 |
|
|
||||||
|------|------|
|
|
||||||
| Clear log | 清空**畫面上**的 log(不動 log 檔)。二次確認 |
|
|
||||||
| Copy log | 複製全部 log 到剪貼簿(連同 timestamp) |
|
|
||||||
| Export log | 匯出 .log 檔到指定位置(原生 Save Dialog) |
|
|
||||||
| Open log folder | 用 OS 預設 file manager 開 `~/Library/Application Support/visiona-local/logs/` |
|
|
||||||
|
|
||||||
#### Footer status
|
|
||||||
|
|
||||||
- **行數統計**:`Lines: 142 / 1000`(顯示當前行數 / 上限)
|
|
||||||
- **Memory hint**(可選):`Server RAM: 240 MB` — 從 `ps` 取樣每 3 秒刷新,供使用者觀察是否記憶體漏水
|
|
||||||
|
|
||||||
### B4. 首次啟動行為
|
|
||||||
|
|
||||||
**建議預設行為**:
|
|
||||||
|
|
||||||
1. 雙擊 app → 桌面控制台視窗開(預設位置:螢幕中央)
|
|
||||||
2. **自動** start server(不需要使用者點 Start)
|
|
||||||
3. Server 起來後(status → Running),**自動**呼叫 OS 開預設瀏覽器到 `http://localhost:3721/`
|
|
||||||
4. 桌面控制台**留在背景**(不最小化、不關閉 — 不然使用者會以為 app 壞了)
|
|
||||||
5. 使用者下次啟動時,記住**上次**的行為:
|
|
||||||
- 如果上次使用者**手動把控制台關閉**,下次啟動仍然彈控制台(因為關閉 = 結束程式,第四輪 Q7 決策)
|
|
||||||
- 如果上次使用者**只是把瀏覽器關了**,下次只要控制台還活著,雙擊 app → 直接 raise 既有控制台 + 重新彈瀏覽器
|
|
||||||
|
|
||||||
**Settings(在桌面控制台內)**:
|
|
||||||
- `☑ 啟動時自動開啟瀏覽器`(預設開)
|
|
||||||
- `☑ 啟動時自動 start server`(預設開)
|
|
||||||
- `☑ Log 開啟時自動 follow tail`(預設開)
|
|
||||||
- `☑ Dark mode: Follow system`(唯讀,和第三輪決策一致)
|
|
||||||
- Language: `[ 繁體中文 ▼ ] / [ English ]`(桌面控制台自己也要 i18n)
|
|
||||||
|
|
||||||
**例外情境**:
|
|
||||||
- Server 在 5 秒內沒 ready(例如 Python sidecar 起不來)→ log 裡已經有錯訊,不彈瀏覽器;使用者看 log 自助排除
|
|
||||||
- Server 起來但 port 被佔 → 改用 fallback port 並在 header 顯示,`Open in Browser` 連到新 port
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. Web UI vs Desktop Control Panel 職責分野
|
|
||||||
|
|
||||||
| 功能 / 面向 | Desktop Control Panel | Web UI(瀏覽器)| 備註 |
|
|
||||||
|------------|----------------------|-----------------|------|
|
|
||||||
| Server 啟動 / 停止 / 重啟 | ✅ **唯一入口** | ❌ | Web UI 不能自殺 |
|
|
||||||
| Server log 顯示與操作 | ✅ **唯一入口** | ❌ | |
|
|
||||||
| 「Open in Browser」一鍵跳轉 | ✅ 唯一入口 | — | |
|
|
||||||
| First-Run 歡迎 / 模式選擇 / 硬體偵測 | ❌ | ✅ | Next.js 現有 flow |
|
|
||||||
| Dashboard(快速開始 + 狀態卡)| ❌ | ✅ | |
|
|
||||||
| Devices 頁面(scan / connect / detail)| ❌ | ✅ | |
|
|
||||||
| Models 頁面(預置 + 上傳 + detail)| ❌ | ✅ | |
|
|
||||||
| Workspace 推論頁面(camera / image / video file)| ❌ | ✅ | **砍掉 URL tab** |
|
|
||||||
| Settings > 一般(語言、深色模式狀態) | **部分**(只控制桌面控制台自己)| ✅ | 語言設定**兩套獨立**!見下方問題 |
|
|
||||||
| Settings > 硬體 / 模型 / 進階 | ❌ | ✅ | |
|
|
||||||
| OS 通知:裝置連/斷 | ❌(App toast 走瀏覽器)| ✅(toast)| Web 端用瀏覽器 `Notification API` 或 in-page toast |
|
|
||||||
| OS 通知:Server 崩潰 | ✅ **改由控制台負責** | ❌ | 原本第四輪 R4-8 是 Wails shell out,現在改成桌面控制台直接呼 OS notification |
|
|
||||||
| 資料目錄開啟 | ✅ Open log folder 按鈕 | Settings > 進階 也可以 | 兩邊都有,方便 |
|
|
||||||
| 快捷鍵 ⌘,(Settings)| ❌ | ✅ | 瀏覽器端 |
|
|
||||||
| 快捷鍵 ⌘1-4(切換主區塊)| ❌ | ✅ | |
|
|
||||||
| 快捷鍵 ⌘Shift+R(重新整理裝置)| ❌ | ✅ | |
|
|
||||||
| 快捷鍵 ⌘U(上傳模型)| ❌ | ✅ | |
|
|
||||||
| 快捷鍵 ⌘W / ⌘Q | ✅ | ✅(瀏覽器原生關 tab)| **衝突**:⌘Q 在瀏覽器會結束瀏覽器,誤殺所有 tab;見 D 節 |
|
|
||||||
| i18n 語系 | **自己一套**(控制台)| **自己一套**(web app)| 兩邊都要雙語,可共用 i18n 辭典 |
|
|
||||||
| Dark mode | 跟隨系統 | 跟隨系統 | 兩邊都是 CSS `prefers-color-scheme` |
|
|
||||||
| 品牌 logo / 字體 / 色盤 | **與 Web 一致** | — | Design token 跨兩套 UI 套用 |
|
|
||||||
|
|
||||||
**設計決策重點:Settings 的語系切換**
|
|
||||||
|
|
||||||
目前設計是「Settings > 一般 > 語言」在瀏覽器 web UI 內。桌面控制台**本身**也有文字,必須有自己的語系切換(通常跟系統 locale)。
|
|
||||||
|
|
||||||
兩個選項:
|
|
||||||
- **方案 1**:桌面控制台只跟系統 locale,不提供手動切換(最省工,符合「控制台是透明的服務管理者」定位)
|
|
||||||
- **方案 2**:桌面控制台 Settings 有獨立語系切換,但預設「跟隨 Web UI 設定」(會需要 IPC 讓兩邊同步)
|
|
||||||
|
|
||||||
**Design 建議方案 1**,因為控制台出現的文字量極少(按鈕 + log level)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. 待使用者決策的問題
|
|
||||||
|
|
||||||
### D1. 桌面控制台關閉後 server 是否繼續跑? ⚠️ **最重要**
|
|
||||||
|
|
||||||
目前第四輪 Q7 決策是「關閉 = 結束」,第三輪 Q-A 決策是「不做 tray」。新架構下這兩個決策打架:
|
|
||||||
|
|
||||||
| 選項 | 行為 | 代價 | 收穫 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| **A. 關閉 = 結束(維持第四輪 Q7)** | 關桌面控制台 → server 也停 → 瀏覽器 tab 變磚塊 | 使用者會困惑「我為什麼不能關 log 視窗?」。必須顯示確認對話框「關閉將停止 server 並中斷瀏覽器操作,確定?」(但這本身就是 UX 摩擦) | 簡單、和現有決策一致 |
|
|
||||||
| **B. 控制台可最小化但不可關閉** | 控制台只能最小化,關閉按鈕 disabled 或變「最小化」 | **非傳統**,違反 OS 慣例,macOS 的 `⌘W` / 紅點會壞掉 | 強制維持 server 活著 |
|
|
||||||
| **C. 背景 daemon 模式(復活 tray)** | 關控制台 → 縮回 menu bar / tray → server 繼續跑 → tray 點擊重開控制台 | **正面推翻第三輪 Q-A 決策**,tray 跨平台工要做 | 最符合 Docker Desktop / Ollama 模式 |
|
|
||||||
| **D. 關閉控制台彈警告,使用者選「停 server」或「只關視窗」** | 提供兩個選項 | 決策疲勞,每次關都彈 | 最靈活但最煩 |
|
|
||||||
|
|
||||||
**Design 建議:選 C,並正式推翻 Q-A**。理由:
|
|
||||||
- A 會讓使用者每次關控制台都中斷 server,完全違背「當成 daemon 用」的 Docker Desktop 心智模型
|
|
||||||
- B 不符合 OS 慣例,無障礙地雷
|
|
||||||
- D 每次都問太煩
|
|
||||||
- C 雖然要投資 tray 做圖資產與 Wails tray(第三輪說踩坑),但**新架構下 tray 的價值從「可有可無」變成「核心」**,值得投資。Ollama / Docker Desktop 都走這條,使用者預期會符合。
|
|
||||||
|
|
||||||
**這題使用者必須親自拍板。**
|
|
||||||
|
|
||||||
### D2. 首次啟動是否自動彈瀏覽器?
|
|
||||||
|
|
||||||
| 選項 | 我的評價 |
|
|
||||||
|------|---------|
|
|
||||||
| **A. 自動彈(建議)** | 符合 Ollama / SD WebUI 體驗,降低「我要怎麼開?」摩擦 |
|
|
||||||
| B. 手動點「Open in Browser」 | 符合 Docker Desktop 體驗,但 Docker Desktop 本身就不需要瀏覽器;visionA-local 不彈等於多一步 |
|
|
||||||
| C. Settings 可切換,預設自動 | 最靈活 |
|
|
||||||
|
|
||||||
**建議 C,預設 A**。
|
|
||||||
|
|
||||||
### D3. 瀏覽器端是否需要 auth?Localhost-only 還是允許 LAN?
|
|
||||||
|
|
||||||
目前 server 預設 `:3721` 沒 auth。使用者如果把筆電 hotspot 打開、或連到公共 Wi-Fi,`http://筆電-ip:3721` 可能被同網段的人看到推論結果。
|
|
||||||
|
|
||||||
| 選項 | 說明 |
|
|
||||||
|------|------|
|
|
||||||
| **A. Localhost-only(`127.0.0.1:3721`)+ 不做 auth(建議)** | 最安全,符合「單機工具」定位,無 LAN 視窗 |
|
|
||||||
| B. 綁 `0.0.0.0` + Token auth | 支援 LAN demo(副作用:使用者 A1 論證 4 可能要這個)但要做 token UI + URL 帶 token + 首次啟動產生 token |
|
|
||||||
| C. 綁 `0.0.0.0` + 不做 auth | 最危險,絕對不要 |
|
|
||||||
| D. Settings 可切換,預設 A | 靈活 |
|
|
||||||
|
|
||||||
**建議 A**。如果使用者真的要 LAN demo,future backlog,現在不做。
|
|
||||||
|
|
||||||
### D4. Desktop 控制台支援 Dark Mode?跟隨系統?
|
|
||||||
|
|
||||||
**建議:跟隨系統**(和 Web UI 一致,使用 `prefers-color-scheme`)。不做手動切換。
|
|
||||||
|
|
||||||
### D5. Log 保留多少 session?
|
|
||||||
|
|
||||||
- 畫面內 ring buffer:1000 行(建議)
|
|
||||||
- 磁碟 log 檔:保留最近 7 天 / 7 個 session,每個 10MB 上限,rotate
|
|
||||||
- 「Clear log」按鈕是否也清檔案?**建議否**,只清畫面
|
|
||||||
|
|
||||||
**待確認:7 天 / 7 個 session 這個 retention 對使用者夠嗎?**
|
|
||||||
|
|
||||||
### D6. Desktop 控制台視窗位置記憶
|
|
||||||
|
|
||||||
第一次開:螢幕中央。
|
|
||||||
第二次開:記住上次關閉時的位置 + 大小嗎?**建議是**,寫到 `~/Library/Application Support/visiona-local/control-panel.json`。
|
|
||||||
|
|
||||||
### D7. 桌面控制台的 ⌘Q / ⌘W 行為
|
|
||||||
|
|
||||||
- `⌘W` 關視窗:在新架構下 = 觸發 D1 的決策(Stop/Hide/Ask)
|
|
||||||
- `⌘Q` 結束 app:一定 = Stop server + quit
|
|
||||||
|
|
||||||
**瀏覽器裡的 ⌘Q** 會結束瀏覽器本身,不是 visionA-local 的問題,使用者本來就會知道。但要在 UI 文案明說「⌘Q 會結束 server」避免誤會。
|
|
||||||
|
|
||||||
### D8. Web UI 是否保留任何 Wails 專屬功能?
|
|
||||||
|
|
||||||
例如:原生檔案對話框、native drag-and-drop、`@wailsapp/runtime` 事件。
|
|
||||||
**建議:完全不用**。Next.js 在瀏覽器跑就是標準 Web API,**徹底切斷** Wails binding,這樣未來如果要讓使用者直接在 Chrome 開也能跑。現況 M7-B 已經做到這點,維持即可。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. 風險觀察
|
|
||||||
|
|
||||||
### E1. 使用者認知負荷:兩套 UI 會造成困惑嗎?
|
|
||||||
|
|
||||||
**可能的情境**:
|
|
||||||
- 使用者在桌面控制台看到 `Language: [繁中]`,又在 web UI Settings 看到 `語言: [繁中]` — 兩個是同一個還是不同?
|
|
||||||
- 使用者在 web UI 砍了某個自上傳模型,桌面控制台 log 卻還顯示它?
|
|
||||||
|
|
||||||
**緩解**:
|
|
||||||
- 文案要明確:控制台叫 **"Server Control Panel"**,web UI 是 **"visionA-local Web"**,兩個名字不要混
|
|
||||||
- 桌面控制台 UI 極簡,只做服務管理,**完全不碰業務邏輯**(不在這裡管 device、model)— 避免使用者以為這裡也能操作
|
|
||||||
|
|
||||||
### E2. 「一鍵啟動」變「兩步啟動」是體驗退化嗎?
|
|
||||||
|
|
||||||
如果自動開瀏覽器生效 → 體驗等於一鍵。如果沒有 → 退化。
|
|
||||||
**D2 的決策直接決定這個風險是否存在**。
|
|
||||||
|
|
||||||
### E3. 既有的 i18n / Design System / Dark Mode 要重新套到桌面控制台嗎?
|
|
||||||
|
|
||||||
- **i18n**:要。桌面控制台文字雖少,但仍需中英雙語(符合第二輪 Q13 決策)。**建議**直接讀 web UI 的 `zh-TW.ts / en.ts`,抽一個 `desktop-control` namespace。
|
|
||||||
- **Design tokens**:要。從 `spec/07-design-tokens.md` 撿 `color.success / warning / destructive / muted / surface / on-surface`,Wails WebView 一樣是 CSS,直接用。
|
|
||||||
- **Dark Mode**:要(和 web UI 一致 `prefers-color-scheme`)。
|
|
||||||
|
|
||||||
**這代表桌面控制台不是 Tauri/Electron 原生 widget,而是一個 WebView 頁面**(它本來就是 Wails = WebView)。這很好,因為可以直接複用 Tailwind + shadcn 元件。
|
|
||||||
|
|
||||||
### E4. Log 面板的 PII / 敏感資訊風險
|
|
||||||
|
|
||||||
Server log 可能會印:
|
|
||||||
- 檔名(包含使用者的私人檔名)
|
|
||||||
- 模型名稱
|
|
||||||
- 裝置 serial
|
|
||||||
- API 呼叫 body(如果 log level = DEBUG)
|
|
||||||
|
|
||||||
使用者點「Copy log」貼到 Slack 就洩漏了。
|
|
||||||
**緩解**:
|
|
||||||
- 預設 log level = INFO(不印 body)
|
|
||||||
- `Copy log` / `Export log` 前顯示一次性提示「Log 可能包含檔名與裝置資訊,確定要複製?」
|
|
||||||
- Log 目錄不加密,視 OS 檔案權限保護
|
|
||||||
|
|
||||||
### E5. Server 崩潰後桌面控制台需要「自動重啟」嗎?
|
|
||||||
|
|
||||||
第四輪 Plan B 有 Python sidecar crash loading + 自動重啟(`spec/08-states.md §8.8`)。
|
|
||||||
新架構下,控制台自己是 watchdog,**可以**直接做 server 層級的 auto-restart(例如崩潰 3 秒後自動重啟一次,再崩就紅字等使用者手動)。**這個行為要和 Architect 討論**,因為第四輪原本是 sidecar 層級重啟。
|
|
||||||
|
|
||||||
### E6. 「有 log 就有濫用」風險
|
|
||||||
|
|
||||||
如果使用者在桌面控制台看到 log 後,**試圖自己寫 script 去 tail log 檔**(例如公司 IT 規範要求把 log 送到 SIEM),log 檔格式就變成一個隱性 API。
|
|
||||||
**緩解**:log 檔格式要穩定,寫進 TDD 的「日誌格式契約」章節(這條以前沒有,可以新增)。
|
|
||||||
|
|
||||||
### E7. 新架構和原 PRD 「零依賴、離線可用」承諾一致嗎?
|
|
||||||
|
|
||||||
- 瀏覽器?使用者一定有(系統預設)→ ✅
|
|
||||||
- 127.0.0.1 連線?完全本機 → ✅
|
|
||||||
- 不需要網路 → ✅
|
|
||||||
|
|
||||||
**完全一致,沒問題。**
|
|
||||||
|
|
||||||
### E8. 「關閉 Wails 視窗」在新架構下的心智模型衝突
|
|
||||||
|
|
||||||
**這個風險是 D1 的延伸**。目前設計決策 Q7 (關閉=結束) 是**假設主視窗是業務 UI** 的前提下做的。主視窗變成控制台後,這個決策的前提崩了。必須重新詢問使用者。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. 給 PM 和 Architect 的議題
|
|
||||||
|
|
||||||
### 給 PM:
|
|
||||||
|
|
||||||
1. **R4-2 MJPEG 延遲指標的 URL 分母要移除**(原本可能包含 `via url` 情境,砍後只剩 camera + local file)
|
|
||||||
2. **第三方授權頁要更新**:yt-dlp 從 vendor 清單移除,安裝檔預期大小從 220MB → 185MB,發佈 AC 要重算
|
|
||||||
3. **RTSP 未來回歸路徑**:使用者說「只做這三種」,要寫到 vision-and-non-goals.md 的非目標清單,避免未來說好的功能被砍後爭議
|
|
||||||
4. **Persona 更新**:新架構下「FAE 帶筆電到客戶現場」的首次啟動腳本要重寫,因為多一個「桌面控制台自動彈 + 瀏覽器自動開」步驟
|
|
||||||
5. **PRD 產品定位語言**:從「桌面 app」改成「本機服務 + 網頁控制台」— 這是 strategic repositioning,不只 UI 改動
|
|
||||||
6. **Q-A 砍 tray 決策要不要推翻**(D1)— 這是三方要一起拉回來討論的關鍵點
|
|
||||||
|
|
||||||
### 給 Architect:
|
|
||||||
|
|
||||||
1. **main.go 架構大改**:從「`go:embed all:frontend` + splash redirect」改為「全新 control panel 頁面 + server lifecycle 控制器」。M7-B 的 splash 修復的邏輯大部分可重用(輪詢 `GetServerStatus`),但呈現層完全不同。
|
|
||||||
2. **`/ipc/raise` endpoint**:從「將 splash 視窗提前」改為「raise 控制台視窗 + open browser」。語意變了。
|
|
||||||
3. **Single-instance 第二次雙擊 UX**:第四輪 `spec/06-cross-platform.md §6.9` 定義是「靜默 raise」,新架構下要不要同時 open browser?(我的建議:是,順手)
|
|
||||||
4. **OS 通知**:第四輪 R4-8 的 `Server 崩潰 → shell out 原生通知`,新架構下改由桌面控制台直接呼(桌面控制台本來就是 Wails,直接 `runtime.MessageDialog` 或用系統 notification API)
|
|
||||||
5. **Log pipeline**:server stderr/stdout → 桌面控制台 WebView。需要一個 IPC 或直接讀 log file tail 的機制。建議用 Wails `runtime.EventsEmit` 把新行推到前端
|
|
||||||
6. **yt-dlp vendor**:`make vendor-ytdlp` 任務移除,payload-macos / payload-windows / payload-linux 都不再 stage yt-dlp。**Go server 端如果還有任何 `exec.Command("yt-dlp", ...)` 的程式碼路徑,要一起刪**。
|
|
||||||
7. **ffmpeg 保留**:即使砍 URL 推論,ffmpeg 仍要給本地 video file decode 用。R4 的 GPL release blocker 仍然存在。
|
|
||||||
8. **Port 衝突 fallback**:原架構下 port 佔用是 Next.js 跑不起來 → 整個 app 廢掉。新架構下控制台仍能跑,可以試 fallback port 3722 / 3723,並在 header 顯示當前 port
|
|
||||||
9. **Log 檔案格式契約**(E6 風險):需要寫到 TDD 新增章節「日誌格式」,定義欄位與 stability commitment
|
|
||||||
10. **Desktop control panel 是獨立 routing 還是和 Next.js 共用 WebView?**
|
|
||||||
- 選項 A:Wails main window load 一個靜態 HTML/JS/CSS(不是 Next.js),主 UI 跑在瀏覽器 → **推薦**,切得乾淨
|
|
||||||
- 選項 B:Wails main window load Next.js 的一個獨立 route `/_desktop-control`,然後瀏覽器跳 `/` → 複用 Next.js 但混在一起,Next.js build 會多 chunk
|
|
||||||
- 建議 A,和 M7-B 的 splash 同樣策略:極簡 HTML + vanilla JS(或小型 React),只做 server 控制和 log 顯示
|
|
||||||
|
|
||||||
### 給三方共同討論:
|
|
||||||
|
|
||||||
1. **要不要正式推翻「砍 tray」決策?**(見 D1)這是這次變更的單一最大決策點。
|
|
||||||
2. **要不要正式把 Q7「關閉=結束」改成「關閉=背景 daemon」?**(依賴 1)
|
|
||||||
3. **要不要讓 web UI 也可以綁 LAN?**(D3)這會把產品從「本機單人工具」變成「一人發起,多人觀看」的雛形,需要 PM 重新評估定位。
|
|
||||||
4. **新架構下的 First-Run flow 在哪一層跑?**
|
|
||||||
- 選項 A:完全在瀏覽器(Next.js),控制台只管啟動
|
|
||||||
- 選項 B:桌面控制台也顯示「首次啟動,正在準備…」進度條
|
|
||||||
- 建議 A(簡單),控制台永遠只做 server 管理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 結語 / 下一步建議
|
|
||||||
|
|
||||||
這次變更的本質是**產品類別變更**:從「桌面軟體」→「本機服務 + 網頁控制台」。如果使用者確定要這樣做,我強烈建議:
|
|
||||||
|
|
||||||
1. **先解 D1**(桌面控制台關閉後 server 是否繼續跑 + 是否復活 tray),這是最大的架構叉路
|
|
||||||
2. **再解 D2/D3**(啟動行為、LAN 範圍),這些決定預設體驗
|
|
||||||
3. 三方各自產出正式文件的 delta(PM 更 PRD strategy + feature-inventory + vision-and-non-goals;Design 更 design-spec §1 IA + §5 replacement for Tray + §6 cross-platform + 新增 §11 Desktop Control Panel;Architect 更 design-doc 架構圖 + main.go 改寫 + api-endpoints + removed-code 加 yt-dlp)
|
|
||||||
4. 然後才進開發
|
|
||||||
|
|
||||||
**不要先進開發再補文件。** 這次是第三輪 Q-A 決策的直接推翻 + Q7 決策的間接推翻,如果跳過文件同步,未來不知道為什麼會這樣做。
|
|
||||||
|
|
||||||
— Design Agent / 2026-04-14
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
# visionA-local 設計規格 v2(索引)
|
|
||||||
|
|
||||||
> Design Agent · 第五輪正式規格 · 初版 2026-04-14
|
|
||||||
> **目前版本:v2.1(2026-04-14 補丁)** — 吸收 Architect 交叉審閱 Major 1+2 / Minor 1-5 修正 + R5-D1/D2/D3 / R5-E 追加決策
|
|
||||||
> 本版依 **R5 決策**重構:visionA-local 從「Wails 跑 Next.js 的單一桌面 app」轉為「**Wails Server Control Panel + 瀏覽器 Web UI**」雙 UI 架構。
|
|
||||||
> 本文件為 v2 索引檔,各章節詳細內容見 `v2/` 子檔。v1(`design-spec.md` + `spec/`)仍保留作為基準參考,未來的實作以 v2 為準。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v2 範圍
|
|
||||||
|
|
||||||
本次 v2 只重新設計**因 R5 決策而改變的部分**,其餘 v1 章節(IA、Settings 分頁結構、design tokens、i18n 策略、A11y 等)**仍然有效**。
|
|
||||||
|
|
||||||
### R5 決策總表(本 v2 的立論基礎)
|
|
||||||
|
|
||||||
| # | 題目 | 使用者決定 | 對設計的影響 |
|
|
||||||
|---|------|----------|------------|
|
|
||||||
| R5-1 | 重構動機 | A+B+G(多視窗便利 + devtools + 需求方要求) | 確立雙 UI 架構 |
|
|
||||||
| R5-2 | 關閉 Wails 視窗行為 | **關閉 = 結束 server**,瀏覽器顯示「Local Server 已離線」覆蓋層 | 新增 Server Offline Overlay 設計 |
|
|
||||||
| R5-3 | Tray 復議 | **維持砍 tray**(T1) | 控制台無 tray 退縮選項 |
|
|
||||||
| R5-4 | 首次啟動自動開瀏覽器 | **A:首次自動開,Settings 可關** | 新增 Settings toggle + First-Run 流程變動 |
|
|
||||||
| R5-5 | Wails 控制台 scope | PM 清單 **拿掉 Mock 模式切換** | 控制台不顯示 Mock UI |
|
|
||||||
| R5-5a | Mock 模式歸處 | **完全砍掉 Mock 模式** | Settings 刪除硬體分頁的 Mock 相關項 / First-Run 砍模式選擇步驟 |
|
|
||||||
| R5-6 | ffmpeg 授權 | LGPL 混合(Win/Linux BtbN、macOS 自 build) | 不影響 UI 設計本身 |
|
|
||||||
| R5-7 | M7 Windows | 先不管 | 不影響 v2 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 文件結構
|
|
||||||
|
|
||||||
| # | 章節 | 檔案 | 一句話摘要 |
|
|
||||||
|---|------|------|-----------|
|
|
||||||
| v2.1 | Wails Server Control Panel | `v2/control-panel.md` | 桌面控制台完整規格(wireframe、狀態機、元件、i18n、A11y) |
|
|
||||||
| v2.2 | Server Offline Overlay | `v2/server-offline-overlay.md` | 瀏覽器端偵測到 server 斷線時的全螢幕覆蓋層 |
|
|
||||||
| v2.3 | source-selector 修改 | `v2/source-selector-update.md` | 砍 URL mode、重寫 video tab、要刪的 i18n key 清單 |
|
|
||||||
| v2.4 | First-Run 流程重定義 | `v2/first-run-update.md` | 砍 Mock 模式選擇步驟、自動開瀏覽器的起手流程圖 |
|
|
||||||
| v2.5 | Settings 頁更新 | `v2/settings-update.md` | 新增「啟動時自動開啟瀏覽器」toggle、Linux 預設 OFF、落地 `preferences.json`、砍 Mock 相關設定 |
|
|
||||||
| **v2.6** | **啟動進度面板**(v2.1 新增) | **`v2/startup-progress.md`** | **R5-E 階段化啟動進度面板:6 階段 wireframe、中英雙語文案、20 秒卡頓提示、60 秒 timeout Error 流程** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v2 未動到的章節(仍以 v1 為準)
|
|
||||||
|
|
||||||
- `spec/01-information-architecture.md` — IA 不變(4 主區塊 + Settings)
|
|
||||||
- `spec/02-pages-diff.md` — 頁面清單變動極小(僅 workspace 推論源砍 URL)
|
|
||||||
- `spec/03-wireframes.md` — Dashboard / Devices / Models / Workspace 主體不變
|
|
||||||
- `spec/06-cross-platform.md` — 跨平台 UX 差異不變
|
|
||||||
- `spec/07-design-tokens.md` — tokens 完全沿用
|
|
||||||
- `spec/08-states.md` — 全域錯誤分級、空狀態文案不變
|
|
||||||
- `spec/09-accessibility.md` — 盡力而為原則不變
|
|
||||||
- `spec/10-i18n.md` — i18n 策略不變
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Token 一致性(總則)
|
|
||||||
|
|
||||||
**Wails Server Control Panel 與瀏覽器 Web UI 共用同一套 shadcn oklch design tokens**(見 `spec/07-design-tokens.md`)。
|
|
||||||
具體做法:控制台前端是 vanilla HTML/JS/CSS(R5 三方共識),但仍引入與 Next.js Web UI 一致的 CSS variable 定義檔(從 `frontend/src/app/globals.css` 擷取 `:root` 與 `[data-theme='dark']` 的 token block),達到:
|
|
||||||
|
|
||||||
- 控制台與 Web UI 在 Light / Dark 下視覺語言一致
|
|
||||||
- 錯誤訊息紅、警告琥珀、成功綠完全相同(不會出現控制台紅和 Web 紅不同的 UX 破綻)
|
|
||||||
- 未來若 tokens 更新,只要同步 CSS variable 檔即可
|
|
||||||
|
|
||||||
**不另外定義控制台專屬 tokens**,唯一例外是控制台的 log panel 使用等寬字體 token(`font.mono`,v1 已存在),以及標題列 `window-chrome.height`(v1 `spec/07-design-tokens.md §7.5` 已定義)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1 → v2 差異總覽
|
|
||||||
|
|
||||||
| 面向 | v1(第四輪) | v2(第五輪) | 來源決策 |
|
|
||||||
|------|-------------|-------------|---------|
|
|
||||||
| 主視窗呈現 | Wails 單視窗 = Next.js 主 UI | Wails 單視窗 = Server Control Panel;Web UI 跑在瀏覽器 | R5-1 |
|
|
||||||
| 視窗關閉 | 結束程式(第四輪 Q7) | 結束程式 **+** 瀏覽器顯示離線 Overlay | R5-2 |
|
|
||||||
| Tray | 無 | 無(維持) | R5-3 |
|
|
||||||
| 首次啟動 | Wails 開 → First-Run wizard 三步(歡迎 / 模式選擇 / 偵測) | Wails 控制台開 → 自動 start server → 自動 open 瀏覽器 → First-Run wizard **兩步**(歡迎 / 偵測) | R5-4、R5-5a |
|
|
||||||
| 首次啟動 Settings | — | 新增「首次啟動時自動開啟瀏覽器」toggle(預設 ON) | R5-4 |
|
|
||||||
| Mock 模式 | 預設真實硬體,可於 Settings > 硬體切換為 Mock | **完全砍除**,沒插硬體就空白 | R5-5a |
|
|
||||||
| Settings 分頁 | 一般 / 硬體 / 模型 / 進階 | 一般 / 硬體 / 模型 / 進階(結構不變,內容刪減) | v1 保留 |
|
|
||||||
| Workspace 推論源 | camera / image / video(file + url) | camera / image / video(僅 file) | R5 三方共識 |
|
|
||||||
| 支援影片副檔名 | `.mp4, .avi, .mov` | **`.mp4, .avi, .mov, .mpeg, .mpg`** | R5 三方共識 |
|
|
||||||
| 影片載入文案 | 「正在解析影片連結,YouTube 影片可能需要 10-30 秒…」 | 純檔案上傳,無解析文案 | R5 三方共識 |
|
|
||||||
| Wails 控制台 scope | 不存在 | Header 狀態列 + Primary controls + Log panel + Log actions + Footer | R5-5 |
|
|
||||||
| Server 狀態機 | 黑盒 | **五態視覺化**:Running / Starting / Stopping / Stopped / Error | v2 新增 |
|
|
||||||
| Server 崩潰通知 | Wails shell out native notification | 控制台內的 Error state 面板 + 紅字 log + OS notification | v2 細化 |
|
|
||||||
| 瀏覽器端 server 斷線 | 未定義 | 全螢幕 Server Offline Overlay(不可關閉、提供重試) | R5-2 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v2 → v2.1 差異總覽(2026-04-14 補丁輪)
|
|
||||||
|
|
||||||
v2.1 是 v2 三方互審後的修正補丁,不改變 R5 雙 UI 架構立論,只處理 Architect 互審發現 + R5-D / R5-E 追加決策。
|
|
||||||
|
|
||||||
| 面向 | v2(2026-04-14 初版) | v2.1(2026-04-14 補丁) | 來源 |
|
|
||||||
|------|--------------------|----------------------|------|
|
|
||||||
| Settings 落地檔 | `settings.json` + 「走 Wails settings store」 | **`preferences.json` @ `<dataDir>/`** + Go server write-rename atomic | Architect Review Major 1 |
|
|
||||||
| 「啟動時自動開瀏覽器」預設值 | 三平台一致 ON | **macOS/Windows = ON,Linux = OFF**(依 `runtime.GOOS`) | R5-D2 |
|
|
||||||
| Toggle label 字面 | 「首次啟動時自動開啟瀏覽器」(待確認) | **「啟動時自動開啟瀏覽器」**(R5-D3 定案:每次啟動都開) | R5-D3 |
|
|
||||||
| Log panel 上限 | 1000 行 + rotate 7 天 / 10 MB | **2000 行 + 無落地 rotate**(in-memory ring buffer,使用者透過 Export log 手動匯出) | Architect Review Minor m-1、m-12 |
|
|
||||||
| Error banner 「Report...」按鈕 | 正常按鈕 | **【hold】** 待 PM 提供 GitHub Issue repo URL | Architect Review Minor m-11 |
|
|
||||||
| Error state OS 通知 | v2 模糊(控制台可見,通知視為次要重複) | **R5-D1 保留**:Error state 發 non-blocking toast notification,與控制台 Error banner 並存 | R5-D1 |
|
|
||||||
| Starting state 視覺 | 只有 header spinner,5 秒 timeout | **階段化啟動進度面板**(`v2/startup-progress.md`),6 階段 + 20 秒卡頓提示 + **60 秒總上限** | R5-E |
|
|
||||||
| 首次啟動流程 | 「首次」自動開瀏覽器 | **「每次」** 啟動都自動開瀏覽器(Wails 每次新啟動) | R5-D3 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 給 Orchestrator 的問題(懸而未決)
|
|
||||||
|
|
||||||
**v2 懸而未決已解決**:
|
|
||||||
- ~~Q2 Server 崩潰 OS 通知~~ → **R5-D1 保留**(已吸收至 `control-panel.md §6.2` + `startup-progress.md §3.7`)
|
|
||||||
- ~~Q3 Linux 預設值~~ → **R5-D2 Linux 預設 OFF**(已吸收至 `settings-update.md §2.2`)
|
|
||||||
- ~~R5-4 label 字面歧義「首次 vs 每次」~~ → **R5-D3 每次**(已吸收至 `settings-update.md §2.3` + `control-panel.md §7.1`)
|
|
||||||
|
|
||||||
**仍懸而未決**:
|
|
||||||
1. **控制台 header「port 變更 fallback」視覺字樣**:v1 第四輪決策「port 佔用則 fallback 到 3722/3723」,建議格式 `Port: 3722 (default 3721 in use)`,待使用者最終確認字樣。
|
|
||||||
2. **v2 是否要回頭作廢 v1 的 `spec/04-first-run.md`**:目前 v2 另開 `v2/first-run-update.md`,但未修改 v1 原檔。建議 v2 穩定後作廢 v1 原檔並改為索引到 v2;在此之前保持雙軌。
|
|
||||||
3. **R5-E startup-progress.md 新增 3 個小懸念**(`v2/startup-progress.md §11`):
|
|
||||||
- (a) 20 秒卡頓 hint 文案「正在重試...」vs 中性「正在處理中,請稍候...」
|
|
||||||
- (b) 階段 6 WebSocket 連線被安全軟體擋的 Error 說明是否要特別提示
|
|
||||||
- (c) 使用者按 Retry 的語意:重置整個啟動流程 vs 重試當前階段(Design 建議前者)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**下一步:**
|
|
||||||
- 三方對 v2.1 最終收斂確認(Architect 回看 Design v2.1 是否全部修正到位)
|
|
||||||
- 使用者最終審 `v2/startup-progress.md` 的 6 階段 wireframe + 文案定版
|
|
||||||
- 確認後進入開發階段 M8-1 ~ M8-10
|
|
||||||
- 所有 v2 文件的決策引用格式:`[R5-X]` / `[R5-D-X]` / `[R5-E-X]` 以便追溯
|
|
||||||
@ -1,551 +0,0 @@
|
|||||||
# Architect 交叉審閱 Design Spec v2(2026-04-14)
|
|
||||||
|
|
||||||
> 審閱者:Architect Agent
|
|
||||||
> 對象:`03-design/design-spec-v2.md` + `03-design/v2/*.md`
|
|
||||||
> 基準:`04-architecture/TDD-v2.md` + `04-architecture/v2/*.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
- **結論**:**有條件通過**(需小改)
|
|
||||||
- **Major**:**2**(A-4 Settings 儲存 spec + A-5 Linux 預設值)
|
|
||||||
- **Minor**:**12**
|
|
||||||
- **是否阻擋進開發**:**不阻擋**,Major 都是 < 10 行 spec 改動,可在 M8-4/M8-5 前補
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. Design ↔ TDD 實作一致性
|
|
||||||
|
|
||||||
| Design v2 | TDD v2 | 一致性 | 備註 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `control-panel.md`(5 態 + 元件 + i18n) | `control-panel.md` + `server-lifecycle.md §5` | ⚠️ 部分 | A-1~A-5 |
|
|
||||||
| `server-offline-overlay.md`(`role=alertdialog` + focus trap + 3s active polling) | `web-ui-offline-overlay.md` | ⚠️ 間隔差 | A-6 |
|
|
||||||
| `source-selector-update.md`(砍 URL + `.mpeg/.mpg` + i18n) | `deletions.md §1 §5` | ✅ 大致 | A-7 |
|
|
||||||
| `first-run-update.md`(2 步 + 硬體可略過) | 未描述(屬 Next.js onboarding) | ✅ 無衝突 | — |
|
|
||||||
| `settings-update.md`(auto-open toggle) | `control-panel.md §4.1` + `server-lifecycle.md §2.1` | ⚠️ 命名差 | A-4 |
|
|
||||||
|
|
||||||
**A-1**(Minor)狀態名大小寫 Design 用首大寫 / TDD 用全小寫 — 語意同,i18n key 對齊即可。不需改。
|
|
||||||
|
|
||||||
**A-2**(Minor)Design 3 顆 Primary(Stop/Restart 收 Manage dropdown)vs TDD 6 顆扁平。binding 層一致,Design 是 UI 組合方式。**M8-5 實作時 Manage dropdown item 呼叫 TDD 定義的 `StopServer()`/`RestartServer()` bindings**。我會在 M8-5 前補到 TDD `control-panel.md §3` 註記。
|
|
||||||
|
|
||||||
**A-3**(Minor)Log 行數上限:Design 1000 / TDD 2000。**決案採 2000**(Go ring buffer 容量常數,~400KB 記憶體可忽略)。**Design Spec §4.4 需改 1000 → 2000**(含 Footer 的 `Lines: {current} / 1000`)。
|
|
||||||
|
|
||||||
**A-4(Major)**Settings 儲存 spec 不一致
|
|
||||||
- Design Spec `settings-update.md §2.2`:`settings.json` + 「走 Wails 既有 settings store」
|
|
||||||
- TDD v2 `control-panel.md §4.1`:`preferences.go` + `<dataDir>/preferences.json`
|
|
||||||
- **問題**:Wails v2 **沒有**內建 settings store(Design 誤解);現況無任何 settings 存檔機制,需新建
|
|
||||||
- **決案**:採用 TDD 的 `preferences.json` + `<dataDir>/` 路徑 + 新建 `visiona-local/preferences.go`
|
|
||||||
- **Design 需改**:§2.2 檔名改 `preferences.json`,刪掉「走 Wails 既有 settings store」一句
|
|
||||||
|
|
||||||
**A-5(Major)**R5-D2 Linux 預設 OFF 兩份 spec 都未落地
|
|
||||||
- Design 寫「預設值:ON」三平台一致
|
|
||||||
- TDD `Preferences{OpenBrowserOnStart bool}` 預設填 `true`
|
|
||||||
- **決案**:新增 `DefaultPreferences()` 依 `runtime.GOOS` 回傳:
|
|
||||||
```go
|
|
||||||
func DefaultPreferences() Preferences {
|
|
||||||
return Preferences{OpenBrowserOnStart: runtime.GOOS != "linux"}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Design 需改**:§2.2「預設值」那格加註「macOS/Windows=ON,Linux=OFF(xdg-open 極簡 WM 可能失敗)」
|
|
||||||
- **TDD 需改**:`control-panel.md §4.1` 補 `DefaultPreferences()`(我負責 M8-4 前補)
|
|
||||||
|
|
||||||
**A-6**(Minor)Offline Overlay 間隔不同
|
|
||||||
- Design:10s 正常 / 失敗 2 次 / overlay 期間 3s
|
|
||||||
- TDD:5s 正常 / 失敗 3 次(無 active 間隔切換)
|
|
||||||
- **決案採 Design**(10s 省 CPU,3s active 更貼合 R5-D3 重啟救援體驗)
|
|
||||||
- **TDD 需改**:`web-ui-offline-overlay.md §3.1` `POLL_INTERVAL_MS=10000` / `FAILURE_THRESHOLD=2` + overlay active 切換至 `3000`(我負責 M8-7 前補)
|
|
||||||
|
|
||||||
**A-7**(Minor)i18n 刪除清單微差
|
|
||||||
- Design 砍 `camera.uploadFile`(TDD 漏列)
|
|
||||||
- TDD 砍 `cannotOpenVideoUrl`(Design 漏列)
|
|
||||||
- **M8-1 執行者取聯集即可**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. R5-D 補充決策技術落地
|
|
||||||
|
|
||||||
**B-1 R5-D1 Server 崩潰保留 OS 原生通知**
|
|
||||||
- 現況 `visiona-local/app.go:240-272` `showNativeError()` 已有三平台實作(osascript display dialog / PowerShell MessageBox / zenity / kdialog),但這是**給 `reportFatal()` 用的 modal dialog**
|
|
||||||
- TDD v2 `control-panel.md §4.7` 把 watchServer 3 次失敗從 `reportFatal` 改為 `setState(Error)`,**同時等於砍掉 OS 通知 → 違反 R5-D1**
|
|
||||||
- **決案**:Error state 時並存兩件事:(1) 控制台 banner(Design §6),(2) 新的 **non-blocking toast notification**(不是 modal)
|
|
||||||
- macOS:`osascript -e 'display notification "..." with title "..."'`(toast,非 dialog)
|
|
||||||
- Windows:優先 `wailsRuntime.SendNotification`(見 `frontend/wailsjs/runtime/runtime.d.ts:275-310` 已自動生成),fallback `msg *` 命令列
|
|
||||||
- Linux:`notify-send`,fallback `zenity --notification`
|
|
||||||
- 新增 `visiona-local/notify.go` 放 `sendCrashNotification(title, body)`
|
|
||||||
- **保留** `showNativeError()` / `reportFatal()` 僅給 startup 致命錯誤用(data dir 建不起來等)
|
|
||||||
- **TDD 需改**:`control-panel.md §4.7` 補 `sendCrashNotification` 呼叫(我負責 M8-4 前補)
|
|
||||||
- **Design 建議加**:§6.2/§6.3 加一句「Error state 時另發 non-blocking OS notification(R5-D1)」
|
|
||||||
|
|
||||||
**B-2 R5-D2 Linux 預設 OFF** — 見 A-5。
|
|
||||||
|
|
||||||
**B-3 R5-D3 每次啟動都自動開**
|
|
||||||
- TDD `control-panel.md §4.6`:`if prefs.OpenBrowserOnStart && !autoOpenedThisSession`
|
|
||||||
- `autoOpenedThisSession` 是 per-App-process flag,每次 Wails 新啟動會重建 → 每次啟動都會開 ✅
|
|
||||||
- Restart Server(同一 App process 內)不會重開瀏覽器 ✅
|
|
||||||
- **技術落地正確**
|
|
||||||
- **Design 建議改**:`control-panel.md §7.1` 第 5 步「[首次 / Settings 為 ON]」→「[每次 / Settings 為 ON]」
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. 體驗 ↔ 技術紅線
|
|
||||||
|
|
||||||
**C-1 OnBeforeClose**:Design Footer 持久提示取代 modal 確認 ↔ TDD `server-lifecycle.md §7` `OnBeforeClose return false` → ✅ 一致。但 Linux AppImage(i3/xmonad)flaky 的 edge case TDD §10 已記錄,M8-10 驗收時實測。
|
|
||||||
|
|
||||||
**C-2 Offline Overlay focus trap**:Design 要求 Tab 只能在「重試 / 了解更多」間循環,TDD `web-ui-offline-overlay.md §4.4` **沒 focus trap 實作**。**決案**:M8-7 Frontend Agent 手動實作(`useEffect` 裡 onKeyDown 擋 Tab + 強制 focus,~15 行,不引入 `react-focus-lock`)。**SSR 相容性**:`'use client'` + Zustand + effect 只在 client 跑,✅ 無問題。
|
|
||||||
|
|
||||||
**C-3 3s active polling 對 log pump 的壓力**:0.33 行/秒 vs R-v2-3 的推論 30-100 行/秒差兩個量級,✅ 無壓力。**額外建議**:server Gin logger 加入 `SkipPaths: []string{"/api/system/boot-id"}` 避免 boot-id probe 洗版控制台 log panel。我負責 M8-9 前補到 TDD。
|
|
||||||
|
|
||||||
**C-4 Wails 控制台 i18n**:
|
|
||||||
- Design Spec §9.1 列 30+ key,namespace `control.*`
|
|
||||||
- TDD §6.1 範例只 ~15 key,namespace `statusCard.*`/`actions.*`
|
|
||||||
- **決案**:以 Design 為準(`control.*` 更語意化,對齊 Web UI 風格)。M8-5 執行者依 Design Spec §9.1 填 `visiona-local/frontend/i18n/{zh-TW,en-US}.json`
|
|
||||||
|
|
||||||
**C-5 Dark Mode**:Design 要求跟隨系統 + 與 Web UI 共用 token,TDD §2 用 CSS vars + `prefers-color-scheme` → ✅ 方向一致。**建議做法**:M8-5 執行者**直接從 `frontend/src/app/globals.css` 複製 `:root` + `[data-theme='dark']` token block 到 `visiona-local/frontend/style.css`**,不重造一份。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Architect 7 懸念決策
|
|
||||||
|
|
||||||
**Q1 NewVideoSourceFromURL 呼叫者**(grep 驗證):
|
|
||||||
- `camera_handler.go:435` 在 `StartFromURL` 內(即將砍)
|
|
||||||
- `camera_handler.go:731` 在 seek handler 的 `if h.videoIsURL` 分支;砍 `StartFromURL` 後 `h.videoIsURL` 永遠 false → dead branch
|
|
||||||
- **決案**:`NewVideoSourceFromURL` / `NewVideoSourceFromURLWithSeek` / `h.videoIsURL` field 一起砍。M8-1 執行時套用。我負責補到 TDD `deletions.md §1.2` 末段。
|
|
||||||
|
|
||||||
**Q3 uuid vs crypto/rand**:**採 `crypto/rand`**(~10 行 hex encode,不引入 `google/uuid` 依賴)。TDD `server-lifecycle.md §9.1/§10 Q3` 以此為準。
|
|
||||||
|
|
||||||
**Q4 shutdownGracePeriod 5s vs 10s**:**blocked-on-pm-review**(我的意見:建議 10s 對齊 server `shutdownFn` timeout,使用者等 10s 是罕見 edge case)
|
|
||||||
|
|
||||||
**Q5 navigator.language fallback**:
|
|
||||||
```javascript
|
|
||||||
const raw = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
|
|
||||||
if (raw.startsWith('zh')) return 'zh-TW';
|
|
||||||
if (raw.startsWith('en')) return 'en-US';
|
|
||||||
return 'zh-TW'; // 預設繁中(visionA 團隊內部工具)
|
|
||||||
```
|
|
||||||
TDD §6.2/§8 Q4 以此為準。
|
|
||||||
|
|
||||||
**Q6 window.close() 限制**:**blocked-on-design-review**(我的意見:Chrome/Safari/Edge 禁止非 `window.open()` 開啟的 tab 呼叫 `window.close()`。建議 Design 把「關閉這個頁面」按鈕改為「我知道了」純視覺收斂,或改為提示「請手動關閉此分頁」)
|
|
||||||
|
|
||||||
**Q7 Preferences JSON atomic write**:**write-rename pattern**
|
|
||||||
```go
|
|
||||||
os.WriteFile(path+".tmp", data, 0o644); os.Rename(path+".tmp", path)
|
|
||||||
```
|
|
||||||
POSIX/Windows 都原子。TDD §8 Q3 以此為準。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. PM 3 技術懸念回答
|
|
||||||
|
|
||||||
**§11-1 Settings 資料落地位置**:
|
|
||||||
- 檔名 `preferences.json`,路徑 `<dataDir>/preferences.json`
|
|
||||||
- macOS `~/Library/Application Support/visiona-local/preferences.json`
|
|
||||||
- Windows `%APPDATA%\visiona-local\preferences.json`
|
|
||||||
- Linux `~/.local/share/visiona-local/preferences.json`
|
|
||||||
- 以 `migrateOldDataDirs()/ensureDataDir()` 算出的路徑(`visiona-local/app.go:103-155`)
|
|
||||||
- JSON 格式:`{"openBrowserOnStart": true}`
|
|
||||||
- 原子寫入:write-rename(D-Q7)
|
|
||||||
- 讀取失敗 fallback:`DefaultPreferences()`(平台差異,見 A-5)
|
|
||||||
|
|
||||||
**§11-2 US-1 AC-1.3 10 秒預算**:
|
|
||||||
| 步驟 | 估時 |
|
|
||||||
|---|---|
|
|
||||||
| Wails 載入 + OnStartup | 0.3-0.5s |
|
|
||||||
| migrate/lock/ipc/seed | 0.1-0.2s |
|
|
||||||
| ensurePythonRuntime | 0.2-0.5s |
|
|
||||||
| pickPort + spawn server | 0.05-0.1s |
|
|
||||||
| Server cold start | 1.5-2.5s |
|
|
||||||
| waitHealthy 首次成功 | 0.5-1.5s |
|
|
||||||
| OpenInBrowser + OS open | 0.3-0.8s |
|
|
||||||
| Next.js SPA 載入 | 0.8-1.5s |
|
|
||||||
| boot-id + WS ready | 0.3-0.5s |
|
|
||||||
| **樂觀** | **~4.1s** ✅ |
|
|
||||||
| **悲觀**(Intel Mac + system Python) | **~8.1s** ✅ |
|
|
||||||
| **Linux AppImage 首次解壓** | **+1-2s**,仍達標 |
|
|
||||||
| **Windows + Defender 掃描** | **+2-3s**,最壞 ~11s**可能超時** |
|
|
||||||
- **風險**:Windows 首次啟動可能超過 10s。**建議 M8-10 驗收實測,超時則 PRD AC-1.3 放寬到 12s**
|
|
||||||
|
|
||||||
**§11-3 idle RAM ≤ 450 MB**:
|
|
||||||
| 程序 | v1 | v2 |
|
|
||||||
|---|---|---|
|
|
||||||
| Wails shell | 150-200 MB | **120-160 MB**(無 Next.js bundle) |
|
|
||||||
| Go server | 80-120 MB | **70-100 MB**(砍 Mock + yt-dlp) |
|
|
||||||
| Python sidecar | 150-250 MB | **0 MB 若 on-demand;150-250 MB 若 always spawn** |
|
|
||||||
| Browser tab (Next.js Web UI) | — | **180-240 MB**(新項目) |
|
|
||||||
| **樂觀** | — | **~370 MB** ✅ |
|
|
||||||
| **悲觀** | — | **~500 MB** ❌ 超 50 MB |
|
|
||||||
|
|
||||||
- **建議**:PRD v2 §NFR 明示「**450 MB 不含 browser tab**,只含 Wails + Go server + Python sidecar;browser tab 視為使用者瀏覽器自己的資源」—— 這個 clarify 符合常識且達標
|
|
||||||
- 或:放寬到 **≤ 550 MB**
|
|
||||||
- **另需確認**:v1 `visiona-local/app.go:441` 的 `ensureDriverInstalled` 是否在無硬體時仍 spawn python。若是,v2 建議改為 on-demand 節省 idle RAM
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. 技術衝突
|
|
||||||
|
|
||||||
**F-1 CORS ↔ 瀏覽器 tab origin**:
|
|
||||||
- Wails「Open in Browser」→ `http://127.0.0.1:<port>` → 白名單內 ✅
|
|
||||||
- 使用者手改 `localhost` → 白名單也接受 ✅
|
|
||||||
- Same-origin fetch(不送 Origin)→ `CORSMiddleware()` 第一個 if 放行 ✅
|
|
||||||
- **無衝突**
|
|
||||||
|
|
||||||
**F-2 Restart Server port 保留**(Major 隱含議題):
|
|
||||||
- TDD `v2/server-lifecycle.md §3`:Restart 後重新 `pickPort(3721)`,可能 fallback 到 3722
|
|
||||||
- Boot-ID 機制假設 tab URL 的 port 不變 → Restart 後 port 變 → 舊 tab `window.location.reload()` 仍連舊 port → overlay 永久卡死
|
|
||||||
- **決案**:`ServerController.Restart()` **強制保留舊 port**,若 old port 不可用 → 進 Error state 並發 OS notification「port 被占用」,**不 fallback**。cold start 則仍允許 3721→3722 fallback
|
|
||||||
- **TDD 需改**:`server-lifecycle.md §3` 新增 Restart 同 port 規則(我負責 M8-4 前補)
|
|
||||||
- Design Spec `control-panel.md §7.3` 的 fallback 情境只適用 cold start,不影響 Design,但 Frontend Agent 需知
|
|
||||||
|
|
||||||
**F-3 Boot-ID 機制在「關 Wails = 結束 server」場景**:
|
|
||||||
- server 沒了 → boot-id 不會變 → 由 overlay 接手(連續失敗 → 顯示)
|
|
||||||
- 使用者重開 app 若走 cold start port fallback 到新 port → 舊 tab 的 URL(3721)連不上新 port(3722)→ overlay 永卡;使用者需從新 Wails 控制台 Open in Browser 開新 tab
|
|
||||||
- **設計正確**,TDD §2.3 時序已暗示,不需改
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. 臆測 / 超範圍
|
|
||||||
|
|
||||||
**G-1 Export log 按鈕**(Design §4.5):TDD 沒對應 binding。技術可行(`wailsRuntime.SaveFileDialog`),**M8-5 補 `ExportLog(path) error` binding**。我負責補到 TDD §4.2。
|
|
||||||
|
|
||||||
**G-2 Copy 按鈕**(Design §4.5):直接用 `navigator.clipboard.writeText()` 瀏覽器 API,Wails WebView 支援。**不需新 binding**。
|
|
||||||
|
|
||||||
**G-3 Report 按鈕**(Design §6.2 Error banner):需要 GitHub Issue URL,**目前沒公開 repo**。**降級**:M8-5 若 PM 沒提供 URL → 不實作 Report 按鈕,Error banner 只留 Restart + View details。**Design 需加**:§6.2 hold 註記「Report 按鈕待 PM 提供 repo URL,暫不實作」。
|
|
||||||
|
|
||||||
**G-4 Log rotate 7 天/10MB**(Design §4.4):TDD 沒 rotate 實作,v1 也無。**降級為 append 模式,不做 rotate**(rotate 需要 `lumberjack.v2` 或自刻定時掃描 + size 比較,非 M8 scope)。**Design 需改**:§4.4 「rotate 7 天/10MB」改為「append 模式;使用者可 Clear Logs 清畫面、Open Log Folder 手動刪檔」。未來 M9+ 再加。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. 問題清單
|
|
||||||
|
|
||||||
**Major(阻擋,需修正)**:
|
|
||||||
|
|
||||||
| # | 位置 | 問題 | 修正 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| M-1 | Design `settings-update.md §2.2` | 檔名 `settings.json` + 「走 Wails settings store」誤解 | 改 `preferences.json`,刪掉 Wails settings store 那句 |
|
|
||||||
| M-2 | Design `settings-update.md §2.2` + TDD `control-panel.md §4.1` | R5-D2 Linux 預設 OFF 未落地 | Design 加「macOS/Windows=ON,Linux=OFF」;TDD 加 `DefaultPreferences()` 依 GOOS |
|
|
||||||
|
|
||||||
**Minor(不阻擋)**:
|
|
||||||
|
|
||||||
| # | 位置 | 處理者 |
|
|
||||||
|---|------|------|
|
|
||||||
| m-1 | Design `control-panel.md §4.4` + Footer | 1000 → 2000 |
|
|
||||||
| m-2 | TDD `web-ui-offline-overlay.md §3.1` | 10s/2 次/3s active(我 M8-7 前補) |
|
|
||||||
| m-3 | M8-1 執行者 | i18n 刪除清單取聯集 |
|
|
||||||
| m-4 | TDD `control-panel.md §4.7` + Design `control-panel.md §6` | Error state 補 OS notification(我 M8-4 前補 TDD) |
|
|
||||||
| m-5 | Design `control-panel.md §7.1 第 5 步` | 「首次」→「每次」對齊 R5-D3 |
|
|
||||||
| m-6 | M8-7 Frontend Agent | Offline Overlay focus trap 實作 |
|
|
||||||
| m-7 | TDD `server-lifecycle.md §9` | Gin SkipPaths + crypto/rand(我 M8-9 前補) |
|
|
||||||
| m-8 | M8-5 執行者 | i18n 以 Design §9.1 為準 |
|
|
||||||
| m-9 | TDD `server-lifecycle.md §3` | Restart 強制同 port(我 M8-4 前補) |
|
|
||||||
| m-10 | TDD `control-panel.md §4.2` | 補 `ExportLog` binding(我 M8-5 前補) |
|
|
||||||
| m-11 | Design `control-panel.md §6.2` | Report 按鈕 hold 註記 |
|
|
||||||
| m-12 | Design `control-panel.md §4.4` | Log rotate 降級為無 rotate |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. 結論
|
|
||||||
|
|
||||||
**✅ 有條件通過** — Design Spec v2 方向正確,與 TDD v2 無不可修復衝突。
|
|
||||||
|
|
||||||
**Design Agent 必改**(Major + 關鍵 Minor):
|
|
||||||
- `settings-update.md §2.2`(M-1 + M-2)
|
|
||||||
- `control-panel.md §4.4`(m-1 + m-12)
|
|
||||||
- `control-panel.md §6.2`(m-4 + m-11)
|
|
||||||
- `control-panel.md §7.1`(m-5)
|
|
||||||
|
|
||||||
**TDD 必改(我負責,M8-* 執行前補)**:
|
|
||||||
- `control-panel.md §4.1`(`DefaultPreferences()` 平台差異)
|
|
||||||
- `control-panel.md §4.2`(`ExportLog` binding)
|
|
||||||
- `control-panel.md §4.7`(`sendCrashNotification` + 新增 `notify.go`)
|
|
||||||
- `control-panel.md §3`(Manage dropdown 註記)
|
|
||||||
- `server-lifecycle.md §3`(Restart 同 port)
|
|
||||||
- `server-lifecycle.md §9`(Gin SkipPaths + `crypto/rand`)
|
|
||||||
- `web-ui-offline-overlay.md §3.1`(10s/2 次/3s active)
|
|
||||||
- `deletions.md §1.2`(`videoIsURL` field 直接砍)
|
|
||||||
|
|
||||||
**Q 決案狀態**:Q1 ✅ Q3 ✅ Q4 ⏳ PM Q5 ✅ Q6 ⏳ Design Q7 ✅
|
|
||||||
|
|
||||||
**PM 3 技術懸念**:
|
|
||||||
- §11-1 ✅ `preferences.json` @ `<dataDir>/`,write-rename
|
|
||||||
- §11-2 ✅ 10 秒樂觀 ~4s / 悲觀 ~8s / Windows 邊界 ~11s(M8-10 驗收時實測,可能需放寬到 12s)
|
|
||||||
- §11-3 ⚠️ idle RAM 需 clarify「不含 browser tab」或放寬到 550 MB;另需確認 Python sidecar 是否 on-demand
|
|
||||||
|
|
||||||
**不阻擋進開發**:所有修正都是 < 10 行 spec 改動,可在 M8-4 / M8-5 執行前 1-2 小時內完成。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**審閱日期**:2026-04-14
|
|
||||||
**下一步**:交 Orchestrator 彙整三方 + R5-D 對齊 + Q4/Q6 收斂 → 使用者最終確認 → M8-1
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Architect 第二輪審閱 Design Spec v2.1(2026-04-14)
|
|
||||||
|
|
||||||
> 對象:`design-spec-v2.md` v2.1 索引 + `v2/settings-update.md` v2.1 + `v2/control-panel.md` v2.1 + **新檔** `v2/startup-progress.md` v2.1
|
|
||||||
> 基準:TDD v2.1 `v2/startup-pipeline.md`(518 行)+ 第一輪審閱結論
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
- **總結論**:**通過,需小改**(3 個 Minor,都在 TDD 側補齊)
|
|
||||||
- **第一輪 Major × 2 / Minor × 12 修復**:**Major 2/2 修好**;**Minor 12/12 修好或已落到 TDD/執行者待辦**
|
|
||||||
- **R5-E startup-progress.md 技術可行性**:**可行**,event schema 7 成對齊,有 3 處 TDD 側需補小改
|
|
||||||
- **是否阻擋進 M8 開發**:**不阻擋**,TDD 小改可在 M8-4b 執行前 30 分鐘內補完
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 第一輪 Major 修復檢查
|
|
||||||
|
|
||||||
| # | 第一輪意見 | v2.1 現況 | 結果 |
|
|
||||||
|---|-----------|---------|-----|
|
|
||||||
| **M-1** Settings 落地檔案 | 改 `preferences.json` + 刪 Wails settings store | `settings-update.md §2.2`(L48-56)已明文「`preferences.json` @ `<dataDir>/`」+「Go server 端負責讀寫(非 Wails 內建機制 — Wails v2 沒有 settings store)」+「write-rename pattern」;全檔 grep 無 `settings.json` 殘留,無「Wails settings store」殘留 | ✅ **修好** |
|
|
||||||
| **M-2** R5-D2 Linux 預設 OFF | 加「macOS/Windows=ON,Linux=OFF」依 GOOS | `settings-update.md §2.2`(L51)「macOS/Windows = ON;Linux = OFF」+ L55「`DefaultPreferences()` 依 `runtime.GOOS`」+ §2.2 Linux 首次使用說明(L59-63)+ §7 遷移策略(L180-190)全部落地 | ✅ **修好** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 第一輪 Minor 修復檢查
|
|
||||||
|
|
||||||
| # | 第一輪意見 | v2.1 現況 | 結果 |
|
|
||||||
|---|-----------|---------|-----|
|
|
||||||
| m-1 | Log 上限 1000→2000 | `control-panel.md §4.4`(L152)「最大行數 2000」、§4.6 Footer(L186)「`Lines: {current} / 2000`」| ✅ |
|
|
||||||
| m-2 | Offline Overlay 10s/2 次/3s active → TDD 側 | 非 Design 修,我負責 M8-7 前補 TDD | ⏳ 我的 TODO(未阻擋) |
|
|
||||||
| m-3 | i18n 刪除清單聯集 → 執行者 | M8-1 執行者責任 | ⏳ 執行者 TODO |
|
|
||||||
| m-4 | Error state OS notification | `control-panel.md §6.3`(L269-275)「R5-D1 OS 原生通知並存」完整三平台實作表;Design Diff §12-6(L459)| ✅ |
|
|
||||||
| m-5 | §7.1 「首次」→「每次」 | `control-panel.md §7.1`(L310)「5. 【每次 / Settings 為 ON(macOS/Windows 預設;Linux 預設 OFF)】」;L331「每次啟動(每次 Wails App process 新啟動)都會跑完整 6 階段流程」| ✅ |
|
|
||||||
| m-6 | Offline Overlay focus trap | M8-7 Frontend 責任 | ⏳ 執行者 TODO |
|
|
||||||
| m-7 | Gin SkipPaths + crypto/rand → TDD 側 | 我負責 M8-9 前補 | ⏳ 我的 TODO |
|
|
||||||
| m-8 | i18n 以 Design §9.1 為準 | Design `control-panel.md §9`(L387-408)完整 `control.*` 清單 | ✅ |
|
|
||||||
| m-9 | Restart 強制同 port → TDD 側 | 我負責 M8-4 前補 | ⏳ 我的 TODO |
|
|
||||||
| m-10 | `ExportLog` binding → TDD 側 | 我負責 M8-5 前補 | ⏳ 我的 TODO |
|
|
||||||
| m-11 | Report 按鈕 hold | `control-panel.md §6.2`(L265)「**【hold】** 現階段**先不實作**」;`startup-progress.md §3.7`(L221)「Report Issue **【hold】**」| ✅ |
|
|
||||||
| m-12 | Log rotate 降級 | `control-panel.md §4.4`(L152-162)「無落地寫檔(in-memory ring buffer)」+「為什麼取消落地寫檔與 rotate」整段說明 | ✅ |
|
|
||||||
|
|
||||||
**修復統計**:Design 側 8/8 已修(m-1、m-4、m-5、m-8、m-11、m-12 + M-1、M-2);TDD 側 4 個由我自己負責(m-2、m-7、m-9、m-10);執行者側 2 個(m-3、m-6)。**無 Design 側遺漏**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. R5-E `startup-progress.md` 技術可行性(重中之重)
|
|
||||||
|
|
||||||
### C-1 Wireframe DOM/CSS 難度
|
|
||||||
|
|
||||||
`startup-progress.md §2`(L25-101)三個 wireframe 全部 text-based,StageItem 結構(§3.3 L141-154)明確示範 vanilla HTML + CSS class data-state,可直接對齊 TDD `startup-pipeline.md §6`(L407-491)的 vanilla JS 實作。**無 framework-specific 要求**。✅
|
|
||||||
|
|
||||||
### C-2 6 階段定義對應 TDD §3 階段表
|
|
||||||
|
|
||||||
| # | Design label | TDD `§3` Go 實作點 | 對應 |
|
|
||||||
|---|-----|-----|-----|
|
|
||||||
| 1 | 初始化控制台 | `app.go:startup` 首行 → `seedUserDataDir()` 返回 | ✅ |
|
|
||||||
| 2 | 檢查 Python 執行環境 | `startServerV2` → `ensurePythonRuntime()` 返回 | ✅ |
|
|
||||||
| 3 | 啟動本機伺服器 | `waitHealthy(port, 30s)` 返回 | ✅ |
|
|
||||||
| 4 | 偵測 Kneron 裝置 | `GET /api/devices` 第一次 response | ✅ |
|
|
||||||
| 5 | 開啟瀏覽器 | `OpenInBrowser("")` 命令 return | ✅ |
|
|
||||||
| 6 | 等待 Web UI 連線 | WebSocket hub `OnClientConnected` 首次 | ✅ |
|
|
||||||
|
|
||||||
**完全對齊**。Design §8 的訊號來源(L361-367)和 TDD §3 表格用詞一致。✅
|
|
||||||
|
|
||||||
### C-3 Event schema 一致性(發現 3 個小衝突)
|
|
||||||
|
|
||||||
Design `startup-progress.md §9`(L386-393)預設的 event 名:
|
|
||||||
|
|
||||||
```
|
|
||||||
startup:stage {id, state, errorMessage?}
|
|
||||||
startup:complete / startup:error
|
|
||||||
```
|
|
||||||
|
|
||||||
TDD `startup-pipeline.md §1`(L28-84)定義的 event 名:
|
|
||||||
|
|
||||||
```
|
|
||||||
startup:progress (StartupProgressEvent: stage, totalStages, labelKey, status, startedAt)
|
|
||||||
startup:stage-timeout (StartupStageTimeoutEvent: stage, softTimeoutSeconds)
|
|
||||||
startup:error (StartupErrorEvent: stage, error, cause)
|
|
||||||
startup:ready (無 payload)
|
|
||||||
```
|
|
||||||
|
|
||||||
**衝突 1**:事件名 `startup:stage` vs `startup:progress` 不一致。**決案:採 TDD 的 `startup:progress`**(更語義化;`stage` 單字太模糊)。Design Agent 不必改 startup-progress.md §9(備忘性質),由 M8-4b / M8-5 執行者以 TDD 為準。
|
|
||||||
|
|
||||||
**衝突 2**:Design `StageState` 列舉(§8 L335-341)有 `pending | running | running-slow | done | failed | skipped`,TDD `StartupProgressEvent.Status`(§1.1 L41)只定義 `pending | running | completed | failed`。
|
|
||||||
|
|
||||||
差異分析:
|
|
||||||
- `running-slow` — Design 明確標註「UI 派生狀態」(L338 註解),由前端根據 `now - startedAt > 20s` 計算,Go 端不需 emit → **無衝突**
|
|
||||||
- `done` vs `completed` — 語義同,用詞差。**決案:Go 端 emit `completed`**,前端 Design class name 用 `done` 即可(CSS 層面對應)
|
|
||||||
- **`skipped` Go 端完全沒有** — 這是 **TDD 側 Minor 問題**,要補
|
|
||||||
|
|
||||||
**衝突 3**:`soft timeout` 事件名不同。Design §9 預設「前端自己 timer 檢查 20 秒 → 派生 `running-slow`」,TDD §1.2 定義「Go 端 watcher goroutine emit `startup:stage-timeout`」。兩種實作都可行,**決案以 TDD 為準**(後端 source of truth,避免兩邊各自計時導致時間漂移),Design 前端僅負責接 `startup:stage-timeout` event 更新 UI。
|
|
||||||
|
|
||||||
**小結**:事件協議 Design 預設是草稿,TDD 是 ground truth;M8-4b/M8-5 執行者以 TDD 為準即可,**Design Agent 無需修 startup-progress.md §9**。但 **TDD 需補 `skipped` status**(見 E 節)。
|
|
||||||
|
|
||||||
### C-4 Timeout UI 觸發時機對齊
|
|
||||||
|
|
||||||
- **20 秒 soft timeout**:Design §3.6(L189-205)「`stage.state === 'running' && (now - stage.startedAt) > 20_000ms`」;TDD §4 watcher goroutine(L332-346)`sinceStage > startupSoftTimeout` + `softTimeoutEmitted` flag 確保只 emit 一次。✅ **觸發時機一致**(Go emit event → 前端收到顯示 hint)
|
|
||||||
- **60 秒 hard timeout**:Design §3.7(L207-224)「任一階段 failed 或總計 > 60 秒 → Error mode」;TDD §4 watcher(L325-330)`sinceTotal > startupHardTimeout` → `Fail(cur, ...)` + `emitError(cur, ..., "total-timeout")`。✅ **一致**
|
|
||||||
|
|
||||||
### C-5 無障礙可行性
|
|
||||||
|
|
||||||
- `role="progressbar"` + `aria-valuemin="0"` + `aria-valuemax="6"` + `aria-valuenow` — vanilla HTML `<section>` 直接標即可 ✅
|
|
||||||
- `aria-live="polite"` 搭配 `.sr-only` div — Design §6(L281)已設計,vanilla JS `textContent` 更新即可觸發 SR 讀出 ✅
|
|
||||||
- `⌘0` / `Ctrl+0` focus + `Esc` — Design §6(L283):vanilla JS `addEventListener('keydown')` 攔截即可 ✅
|
|
||||||
- `prefers-reduced-motion` — CSS media query,vanilla CSS 支援 ✅
|
|
||||||
|
|
||||||
**可行性**:**100%**。不需引入任何 third-party library。
|
|
||||||
|
|
||||||
### C-6 Linux / Toggle OFF 分支(**重要**)
|
|
||||||
|
|
||||||
Design 規格(§4.1 L244-256):
|
|
||||||
- 階段 5:`pending → skipped`(不經過 `running`),顯示 ⏭ icon
|
|
||||||
- 階段 6:`pending → running`,description 改 manualHint,**不套 20 秒 retry hint**
|
|
||||||
- 當使用者手動點 Open in Browser 並建立 WebSocket → `done`
|
|
||||||
|
|
||||||
TDD §3 表(L117)階段 5:「若 `AutoOpenBrowser=false` 則立即 complete 跳過」— **TDD 是 `completed`,Design 是 `skipped`,語義不同**。Design 要顯示「跳過(依偏好設定)」⏭ icon,不是綠勾 ✅。
|
|
||||||
|
|
||||||
TDD 也**沒有處理階段 6 不套 soft timeout 的情境**:§4 watcher 對所有階段一視同仁,Toggle OFF 時階段 6 會在 20 秒後觸發 `startup:stage-timeout`,前端會顯示「正在重試...」—— 這是錯的,因為在等人為動作。
|
|
||||||
|
|
||||||
**兩個問題都是 TDD 側需補**(見 E 節 E-2 / E-3)。
|
|
||||||
|
|
||||||
### C-7 i18n key 30+ 條
|
|
||||||
|
|
||||||
Design §7(L291-329)列出 27 個 i18n key,namespace 一致用 `startup.*`。TDD §2(L87-103)只列 11 個 key 作為骨架,註明「文案由 Design Spec v2.1 敲定」。
|
|
||||||
|
|
||||||
**key 名稱微差**:
|
|
||||||
- Design:`startup.panel.title` / TDD:`startup.title` — 統一採 **Design 的 `startup.panel.title`**(更完整的 namespace 樹狀結構)
|
|
||||||
- Design:`startup.timeout.message` / TDD:`startup.retrying` — 統一採 **TDD 的 `startup.retrying`**(對應 TDD event `startup:stage-timeout` 的 copy,更精準)
|
|
||||||
- Design:`startup.error.description.timeout` / TDD:`startup.error.totalTimeout` — 統一採 **Design 的樹狀版**
|
|
||||||
|
|
||||||
這些微差不影響可行性,M8-4b/M8-5 執行者統一用 Design §7 清單 + TDD 補上 labelKey path 即可。載入時機:Wails 控制台前端 `app.js` 在 `main()` 最前呼叫 `i18n/loader.js` 載入 JSON,早於 `initStartupPanel()`,順序正確。✅
|
|
||||||
|
|
||||||
**可行性**:**可行**。
|
|
||||||
|
|
||||||
### 7 子項總結:**全部可實作**。發現的 3 個技術衝突都在 TDD 側(不在 Design 側)— skipped status、階段 6 不套 soft timeout、`RestartStartupSequence` binding — 由我在 M8-4b 執行前補 TDD 修復。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Design v2.1 新增 3 個懸念的技術回答
|
|
||||||
|
|
||||||
### D-Q1「正在重試」vs「正在處理中」文案
|
|
||||||
|
|
||||||
**技術面**:**不影響實作**。Go 端只 emit `startup:stage-timeout` event,文案在前端 i18n JSON,改字面 0 行程式碼成本。
|
|
||||||
|
|
||||||
**Architect 決案**:交由 Design 最終定稿即可,我不干涉文案選擇。若要技術建議:採 Design 原提案「正在重試...」—— event 名本身是 `stage-timeout`,副文字用「重試」與 icon ⚠ 搭配更有「系統知道有狀況且在努力」的語感,對齊 Nielsen Norman perceived-performance 原則。**不阻擋**。
|
|
||||||
|
|
||||||
### D-Q2 WebSocket 被安全軟體攔截的偵測
|
|
||||||
|
|
||||||
**技術面評估**:**不可行**。
|
|
||||||
- Windows Defender SmartScreen 攔截瀏覽器連線時,Go server 端 `OnClientConnected` 根本不會被呼叫,server 無法區分「使用者還沒打開瀏覽器」、「瀏覽器正在載入 Next.js」、「瀏覽器被擋」這三種情境
|
|
||||||
- 唯一能辨識的訊號是「階段 5 已 complete 但階段 6 > 30 秒未 complete」,但這個時間閾值和正常冷啟動(悲觀 ~8 秒 Next.js + WS)無法可靠區分
|
|
||||||
- 若硬要辨識,需要辨識多種安全軟體的特徵(Defender SmartScreen / McAfee / Kaspersky / 企業 MDM),過度設計
|
|
||||||
- **接受 Design 建議**:不做特殊偵測,Error 說明用通用文案「階段 6 超時,請檢查瀏覽器是否能開啟 http://127.0.0.1:{port}」+ 引導看 log details 即可
|
|
||||||
|
|
||||||
**Architect 決案**:**同意 Design 建議,不做特殊偵測**。M8-5 前端 i18n 的 `startup.error.description.timeout` 文案建議補上「請確認瀏覽器可存取 127.0.0.1:{port}」作為通用提示。
|
|
||||||
|
|
||||||
### D-Q3 Retry 按鈕語意:重置整個啟動 vs 重試當前階段(**關鍵技術決策**)
|
|
||||||
|
|
||||||
**技術面評估**:**「重置整個啟動流程」可行且建議採用**。
|
|
||||||
|
|
||||||
**實作細節**(`RestartStartupSequence()` 新 function,位於 `startup_pipeline.go`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
// RestartStartupSequence 停掉當前 pipeline,清理 server 狀態,重新跑 pipeline 從階段 1 開始。
|
|
||||||
// 只能在 Error state 時呼叫(Starting/Running 時 noop)。
|
|
||||||
func (a *App) RestartStartupSequence() error {
|
|
||||||
if a.ctrl.State() != ServerStateError {
|
|
||||||
return errors.New("RestartStartupSequence only allowed in Error state")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 停掉舊 watcher(若還活著)
|
|
||||||
if a.pipeline != nil {
|
|
||||||
a.pipeline.stopWatcher()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 強制 kill 舊 server process(若還存在)
|
|
||||||
// ctrl.Stop() 會走 graceful → 這裡用 ForceKill 因為 server 很可能已處於壞狀態
|
|
||||||
a.ctrl.ForceKill()
|
|
||||||
|
|
||||||
// 3. 重置 ServerController state machine 回 Stopped
|
|
||||||
a.ctrl.setState(ServerStateStopped, "")
|
|
||||||
|
|
||||||
// 4. 重建 StartupPipeline(清掉所有 stage state)
|
|
||||||
a.pipeline = NewStartupPipeline(a)
|
|
||||||
a.pipeline.Start(a.ctx)
|
|
||||||
|
|
||||||
// 5. 重跑 ctrl.Start() — 會依序觸發階段 2-6
|
|
||||||
return a.ctrl.Start()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**關鍵考量**:
|
|
||||||
|
|
||||||
1. **階段 1 不需重跑**:Wails `OnStartup` 已經跑過,`seedUserDataDir` / `migrateOldDataDirs` 等只需跑一次。**建議:Retry 時 `pipeline.Start(ctx)` 後直接 `Complete(1)`**(0.0 秒完成,視覺上瞬過)
|
|
||||||
2. **階段 2 `ensurePythonRuntime()` 重跑成本**:若 Python venv 已在 `<dataDir>/python/` 解壓完成,`ensurePythonRuntime()` 內部 check 應該 < 100ms return;若 Python extract 到一半失敗需重跑,成本較高但仍有邊界(Architect §11-2 估悲觀 0.5s)。**可接受**
|
|
||||||
3. **階段 3 server 全砍重起**:必須全砍。舊 server process 若處於半掛狀態(port 卡著、goroutine leak),保留只會讓新 pipeline 再卡一次。`ForceKill` → `pickPort` 重新選 port — 但這和第一輪 F-2(Restart 強制同 port)有衝突,需要微調:**Retry 情境下 port 可以 fallback**(反正 Error state 下舊 Web UI tab 已顯示 overlay,使用者會從新控制台 Open in Browser 開新 tab)
|
|
||||||
4. **階段 6 WebSocket sentinel file 清理**:若 TDD §3 採 sentinel file `<dataDir>/.first-ws-connected`,Retry 時必須 **先 `os.Remove()` sentinel file**,否則新階段 6 會瞬間 `completed` 錯誤
|
|
||||||
5. **Wails binding**:需新增 `RestartStartupSequence() error` 到 `app.go` bindings,前端 Design §3.7 Retry 按鈕 `onclick` 呼叫 `window.go.main.App.RestartStartupSequence()`
|
|
||||||
|
|
||||||
**Architect 決案**:**可行,採用「重置整個啟動流程」語意**。我會在 TDD `v2/startup-pipeline.md` 新增 §9「Retry 機制」小節落地上述實作細節(M8-4b 執行前補)。Design Agent **無需**修 startup-progress.md,維持 §3.7 Retry 按鈕「重置進度面板,重新跑階段 1」的 wireframe 文字即可。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. 新發現的 Design ↔ TDD 衝突(TDD 側 Minor)
|
|
||||||
|
|
||||||
### E-1 WebSocket `OnClientConnected` → Wails event 路徑未敲定
|
|
||||||
|
|
||||||
- Design `startup-progress.md §9-4`(L391)明確要求「Go WebSocket hub `OnConnect` 事件中 emit `startup:stage {id: 6, state: 'done'}`」
|
|
||||||
- TDD `startup-pipeline.md §3`(L122-132)提出兩個選項:long-poll endpoint 或 sentinel file,**尚未敲定**
|
|
||||||
- **Architect 補決案**:**採 sentinel file 方案**(long-poll endpoint 要改 server 路由、rg ctx 維護成本較高)。server WebSocket hub 的 `OnClientConnected` callback 首次觸發時 `os.WriteFile(<dataDir>/.first-ws-connected, []byte{}, 0o644)`;Wails 階段 5 complete 後 goroutine `for { if exists { pipeline.Complete(6); return }; time.Sleep(200ms) }`,30 秒逾時 `pipeline.Fail(6, ...)`
|
|
||||||
- **TDD 需改**:`startup-pipeline.md §3` 表格階段 6 列敲定 sentinel 方案(我負責 M8-4b 前補)
|
|
||||||
|
|
||||||
### E-2 `skipped` status Go 端未定義
|
|
||||||
|
|
||||||
- Design §3.4(L165)、§4.1(L244-248)明確需要 `skipped` status(階段 5 Toggle OFF 時)
|
|
||||||
- TDD §1.1 `StartupProgressEvent.Status` 只有 `pending | running | completed | failed`,**缺 `skipped`**
|
|
||||||
- **Architect 補決案**:TDD `startup-pipeline.md §1.1` 新增 `"skipped"` 到 Status 枚舉;§4 `StartupPipeline` 新增 `Skip(stage int)` method,內部 `p.stages[stage].status = "skipped"` + `emitProgress` + 自動進入下一階段(不 `Fail`)
|
|
||||||
- **TDD 需改**:§1.1 補 status、§4 補 `Skip()` method(我負責 M8-4b 前補)
|
|
||||||
|
|
||||||
### E-3 階段 6 `AutoOpenBrowser=false` 時不觸發 soft timeout
|
|
||||||
|
|
||||||
- Design §4.1(L255)「不套 20 秒 retry hint(因為是等待人為動作,不是系統卡住)」
|
|
||||||
- TDD §4 watcher goroutine 對所有階段一視同仁,**未排除階段 6 Toggle OFF 情境**
|
|
||||||
- **Architect 補決案**:TDD §4 watcher 新增條件 `if cur == 6 && !p.app.prefs.OpenBrowserOnStart { continue }`(跳過 soft timeout 檢查);hard timeout 仍照常檢查(使用者 60 秒內不點 Open in Browser 仍應進 Error state,合理)
|
|
||||||
- **TDD 需改**:§4 watcher goroutine 條件補上(我負責 M8-4b 前補)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. 第二輪新發現問題
|
|
||||||
|
|
||||||
### Major(阻擋)
|
|
||||||
|
|
||||||
**無**。
|
|
||||||
|
|
||||||
### Minor(不阻擋)
|
|
||||||
|
|
||||||
| # | 位置 | 問題 | 處理 |
|
|
||||||
|---|-----|-----|------|
|
|
||||||
| 2-m1 | TDD `startup-pipeline.md §1.1` | `StartupProgressEvent.Status` 缺 `"skipped"` | 我 M8-4b 前補 |
|
|
||||||
| 2-m2 | TDD `startup-pipeline.md §3` | 階段 6 WebSocket sentinel 方案未敲定 | 我 M8-4b 前補(採 sentinel file) |
|
|
||||||
| 2-m3 | TDD `startup-pipeline.md §4` + 新增 §9 | watcher 階段 6 Toggle OFF 跳過 soft timeout;新增 `RestartStartupSequence()` Retry 小節 | 我 M8-4b 前補 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. 第二輪結論
|
|
||||||
|
|
||||||
**✅ 通過**(需小改,TDD 側 3 個 Minor)
|
|
||||||
|
|
||||||
**Design Spec v2.1 側**:
|
|
||||||
- **Major 2/2 修好** — `settings-update.md §2.2` 檔名 + Linux 預設 OFF 全部落地
|
|
||||||
- **Design 側 Minor 8/8 修好**(m-1/m-4/m-5/m-8/m-11/m-12 + M-1/M-2)
|
|
||||||
- **無新發現 Design 側問題**,Design Agent **無需再動 v2.1 任何檔案**
|
|
||||||
|
|
||||||
**TDD v2.1 側**(我自己負責,M8-4b 執行前補):
|
|
||||||
- 第一輪遺留 4 項(m-2/m-7/m-9/m-10)
|
|
||||||
- 第二輪新增 3 項(2-m1/2-m2/2-m3)
|
|
||||||
- 合計 7 項小改,估計 1-2 小時完成
|
|
||||||
|
|
||||||
**R5-E startup-progress.md 通過**:Design 定義完整且技術可行,與 TDD startup-pipeline.md 7 成對齊,3 處差異全部是 TDD 側可小改。
|
|
||||||
|
|
||||||
**3 個 Design v2.1 懸念已技術回答**:
|
|
||||||
- D-Q1 文案:不影響技術,Design 自由定稿
|
|
||||||
- D-Q2 安全軟體偵測:不做(過度設計),採通用文案
|
|
||||||
- **D-Q3 Retry 按鈕語意:採「重置整個啟動流程」**,`RestartStartupSequence()` 可行,實作細節見 D-Q3 區塊
|
|
||||||
|
|
||||||
**不阻擋進 M8 開發**。三方 v2.1 已收斂。
|
|
||||||
|
|
||||||
**下一步**:Orchestrator 可將此第二輪結論 + Design v2.1 現況 + TDD 7 項待補 → 呈報使用者最終確認 → 我補 TDD 小改 → 進 M8-1。
|
|
||||||
|
|
||||||
**審閱日期**:2026-04-14(第二輪)
|
|
||||||
@ -1,465 +0,0 @@
|
|||||||
# v2.1 — Wails Server Control Panel 設計規格
|
|
||||||
|
|
||||||
> 本章對應 R5-1 / R5-2 / R5-3 / R5-4 / R5-5 / R5-5a / **R5-D1 / R5-D3 / R5-E**(v2.1 補丁)
|
|
||||||
> 上層索引:`../design-spec-v2.md`
|
|
||||||
> 版本:**v2.1** · 更新日期:2026-04-14
|
|
||||||
> 相關:`v2/startup-progress.md`(R5-E 階段化啟動進度面板,Starting state 時顯示)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 定位與職責
|
|
||||||
|
|
||||||
Wails Server Control Panel(以下稱「控制台」)是 visionA-local 雙擊開啟後看到的**第一個畫面**,也是使用者**唯一可以關 server** 的地方。它的職責:
|
|
||||||
|
|
||||||
- Server lifecycle 管理:Start / Stop / Restart
|
|
||||||
- 即時 log 顯示、過濾、複製、匯出
|
|
||||||
- 一鍵開啟瀏覽器 Web UI
|
|
||||||
- 顯示 server 狀態(port、PID、uptime、version)
|
|
||||||
- 錯誤狀態的視覺呈現與自助排除入口
|
|
||||||
|
|
||||||
**控制台不做的事**(與 v1 清單一致、R5 複核):
|
|
||||||
- 不管 device、model、inference(那是 Web UI 的事)
|
|
||||||
- 不顯示 Mock 模式切換(R5-5:拿掉 Mock 切換)
|
|
||||||
- 不提供語言切換(跟隨系統 locale,見 §9)
|
|
||||||
- 不提供 Dark Mode 切換(跟隨系統)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 視窗規格
|
|
||||||
|
|
||||||
| 項目 | 值 | 說明 |
|
|
||||||
|------|----|------|
|
|
||||||
| 預設寬度 | `720 px` | log 一行約 90-100 字元可顯示 |
|
|
||||||
| 預設高度 | `560 px` | log 區塊可顯示 18-20 行 |
|
|
||||||
| 最小寬度 | `560 px` | 防止 primary controls 擠壞 |
|
|
||||||
| 最小高度 | `420 px` | log 區最少 6 行 |
|
|
||||||
| 可調整大小 | 是 | 拖角落縮放 |
|
|
||||||
| 最大化 | 保留 | |
|
|
||||||
| 最小化 | 保留 | |
|
|
||||||
| 置頂 | 否 | |
|
|
||||||
| Title bar | 原生 | 不做自訂 title bar |
|
|
||||||
| 視窗標題 | `visionA-local · Server Control` | |
|
|
||||||
| 視窗 icon | 沿用 `frontend/icon.png` | |
|
|
||||||
| 初始位置 | 螢幕中央(首次)/上次位置(後續) | 記憶寫到 `~/Library/Application Support/visiona-local/control-panel.json` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 佈局 Wireframe(最終版)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌───────────────────────────────────────────────────────────────────┐
|
|
||||||
│ ● visionA-local · Server Control [ − ][ □ ][ × ]│ ← Title bar(原生)
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌────┐ visionA-local v0.1.0 │
|
|
||||||
│ │LOGO│ ● Running · Browser opened │ ← Header
|
|
||||||
│ └────┘ Port: 3721 Uptime: 00:12:43 PID: 45821 │
|
|
||||||
│ │
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ [ 🌐 Open in Browser ] [ Start ] [ ⋯ Manage ▾ ] │ ← Primary controls
|
|
||||||
│ │
|
|
||||||
│ ☑ Follow tail ☑ Show timestamps 🔍 [ Filter ... ] │ ← Log controls
|
|
||||||
│ │
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
│ 10:23:41 INFO HTTP server listening on 127.0.0.1:3721 │
|
|
||||||
│ 10:23:41 INFO wails ipc ready │
|
|
||||||
│ 10:23:42 INFO device scan: found 1 Kneron KL520 │
|
|
||||||
│ 10:23:43 INFO GET /api/devices 200 (4ms) │ ← Log panel
|
|
||||||
│ 10:23:45 INFO GET /api/models 200 (2ms) │ (等寬字體
|
|
||||||
│ 10:23:58 WARN python sidecar restart (attempt 1) │ 可捲動
|
|
||||||
│ 10:23:59 INFO python sidecar ready │ 等級著色)
|
|
||||||
│ 10:24:12 INFO inference session start: classification │
|
|
||||||
│ ... │
|
|
||||||
│ │
|
|
||||||
│ │
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
│ [ Clear ] [ Copy ] [ Export log ] [ Open log folder ] │ ← Log actions
|
|
||||||
│ │
|
|
||||||
│ Lines: 142 / 2000 ⚠ Closing this window will stop │ ← Footer
|
|
||||||
│ the server. │
|
|
||||||
└───────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**和 v1 分析稿(`design-analysis-round2-refactor.md` §B2)的差異**:
|
|
||||||
- 拿掉 log 控制列上方「Mock 模式切換」區(v1 分析稿其實沒有這個,只是 `controlPanelSection` 清單有——R5-5a 確認砍)
|
|
||||||
- Primary controls 從 4 顆精簡為 3 顆(`Start` / `Stop` / `Restart` 三顆合併為 `Start` + overflow menu)
|
|
||||||
- Header 狀態列文字擴充,加入 "Browser opened"(首次 auto-open 後的視覺回饋,見 §5)
|
|
||||||
- Footer 新增「關閉視窗會停止 server」持久提示(R5-2 明確決策,用持久文字取代每次彈對話框)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 元件清單
|
|
||||||
|
|
||||||
### 4.1 Header(高度 80 px)
|
|
||||||
|
|
||||||
| 元素 | 類型 | 尺寸 / 位置 | 狀態 | 備註 |
|
|
||||||
|------|------|------------|------|------|
|
|
||||||
| Brand logo | `<img>` | 40×40 px,左側 padding 16 | static | 沿用 `frontend/icon.png` |
|
|
||||||
| Product name | `<h1>` | 16 px / SemiBold | static | 文字:`visionA-local` |
|
|
||||||
| Version tag | `<span>` | 12 px / muted-foreground | static | 文字:`v{major}.{minor}.{patch}`,右上角 |
|
|
||||||
| Status indicator | `<span>` + `<svg>` | 圓點 8 px | 見 §5 狀態機 | 顏色綁 semantic tokens |
|
|
||||||
| Status text | `<span>` | 14 px / Medium | 見 §5 | 例:`Running · Browser opened` |
|
|
||||||
| Server meta | `<dl>` | 12 px / muted | 6 個欄位 | Port / Uptime / PID(Uptime 每秒刷新) |
|
|
||||||
|
|
||||||
### 4.2 Primary controls(高度 48 px)
|
|
||||||
|
|
||||||
| 按鈕 | 變體 | 大小 | 啟用條件 | 備註 |
|
|
||||||
|------|------|------|---------|------|
|
|
||||||
| **Open in Browser** | `primary` filled | `md` | 僅 `Running` | 最左、最顯眼,附 🌐 icon |
|
|
||||||
| **Start** | `outline` | `md` | `Stopped` / `Error` | 附 ▶ icon |
|
|
||||||
| **Manage ▾** | `outline` + dropdown | `md` | `Running` | 展開後包含 `Stop server`、`Restart server` |
|
|
||||||
|
|
||||||
**Manage overflow menu 內容**:
|
|
||||||
```
|
|
||||||
┌──────────────────────────┐
|
|
||||||
│ Stop server │ ← destructive 色彩提示
|
|
||||||
│ Restart server │ ← 普通
|
|
||||||
├──────────────────────────┤
|
|
||||||
│ Open log folder │ ← 重複項(方便直接存取)
|
|
||||||
└──────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**為什麼 Primary CTA 是 "Open in Browser"**(Design Rationale):
|
|
||||||
- R5-4 決定首次啟動會自動開瀏覽器一次
|
|
||||||
- 使用者後續可能關 browser tab(環境整理、記憶體、誤關)
|
|
||||||
- 「關了想重開」是日常第二高頻操作(第一高頻是雙擊 app 本身,已被 auto-open 覆蓋)
|
|
||||||
- Start/Stop/Restart 只在出事時才點
|
|
||||||
- 結論:**Open in Browser 保留為 primary**(沿用第一輪 B3 提案)
|
|
||||||
|
|
||||||
**Stop 放進 overflow 的原因**:
|
|
||||||
- 避免誤按導致 server 中斷 + Web UI 爆掉
|
|
||||||
- Stop 放在 dropdown 多一個「點擊 > 選擇」保護,等效輕度確認
|
|
||||||
- 不做「你確定要 Stop?」modal,減少 UX 摩擦
|
|
||||||
|
|
||||||
### 4.3 Log controls(高度 40 px)
|
|
||||||
|
|
||||||
| 元素 | 類型 | 預設 | 行為 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `Follow tail` | `<checkbox>` | ✅ ON | 使用者往上捲動時**自動關閉**,捲到最底自動重啟。附提示 `Jump to latest` pill |
|
|
||||||
| `Show timestamps` | `<checkbox>` | ✅ ON | 關閉後 log 行去掉時間戳 |
|
|
||||||
| `Filter` | `<input type="search">` | 空 | 即時字串過濾,無 regex;`⌘F` / `Ctrl+F` 聚焦 |
|
|
||||||
|
|
||||||
### 4.4 Log panel(高度剩餘 flex-grow)
|
|
||||||
|
|
||||||
| 屬性 | 值 |
|
|
||||||
|------|---|
|
|
||||||
| 字體 | `font.mono`(SF Mono / Consolas / Menlo) |
|
|
||||||
| 字級 | `12 px` |
|
|
||||||
| 行高 | `1.5` |
|
|
||||||
| 背景 | `color.surface-1`(Light:`oklch(0.99 0 0)`;Dark:`oklch(0.18 0 0)`) |
|
|
||||||
| 選取背景 | `color.primary/20` |
|
|
||||||
| **最大行數** | **2000**(ring buffer,超過舊的 drop;對齊 TDD v2 Go server 常數,~400KB 記憶體可忽略) |
|
|
||||||
| 寫檔 | **無**(TDD v2 採 in-memory ring buffer,log 不落地;使用者若需保存用 `Export log` 手動匯出) |
|
|
||||||
| 滑入動畫 | 60 ms fade-in(`prefers-reduced-motion` 時關閉) |
|
|
||||||
| 選取冰結 | 使用者拖選文字時自動暫停 auto-scroll |
|
|
||||||
|
|
||||||
**為什麼取消落地寫檔與 rotate**:
|
|
||||||
- TDD v2 決定 Go server 採 in-memory ring buffer(容量 2000 行)統一管理 log,**不落地滾動檔案**
|
|
||||||
- rotate 7 天 / 10MB 需要 `lumberjack.v2` 或自刻定時掃描 + size 比較,非 M8 scope 且會增加技術債
|
|
||||||
- 使用者如需保存 log → `Export log` 按鈕(§4.5)原生 save dialog 匯出當下 buffer 內容
|
|
||||||
- 使用者如需檢視/清理 → `Open log folder` 保留,指向 `<dataDir>/logs/`(若未來重新啟用落地再用;目前該資料夾可能為空)
|
|
||||||
- 未來若有落地需求 → 放 M9+ 迭代,不影響 v2.1 交付
|
|
||||||
|
|
||||||
**等級著色**(和 Web UI semantic token 一致):
|
|
||||||
|
|
||||||
| Level | Token | Light 範例 | Dark 範例 |
|
|
||||||
|-------|-------|-----------|----------|
|
|
||||||
| DEBUG | `color.muted-foreground` | `#6b7280` | `#9ca3af` |
|
|
||||||
| INFO | `color.foreground` | `#111827` | `#e5e7eb` |
|
|
||||||
| WARN | `color.warning` | `#b45309` | `#fbbf24` |
|
|
||||||
| ERROR | `color.destructive` | `#b91c1c` | `#f87171` |
|
|
||||||
|
|
||||||
### 4.5 Log actions(高度 40 px)
|
|
||||||
|
|
||||||
| 按鈕 | 類型 | 功能 |
|
|
||||||
|------|------|------|
|
|
||||||
| `Clear` | `ghost` small | 清空畫面 log(不動檔案),二次確認(toast「Log cleared」,5 秒內可 undo) |
|
|
||||||
| `Copy` | `ghost` small | 複製全部可見 log 到剪貼簿,首次點擊時提示「Log 可能包含檔名與裝置資訊」一次 |
|
|
||||||
| `Export log` | `ghost` small | 原生 save dialog,預設檔名 `visiona-local-{yyyyMMdd-HHmmss}.log` |
|
|
||||||
| `Open log folder` | `ghost` small | 呼叫 OS 開 `~/Library/Application Support/visiona-local/logs/` |
|
|
||||||
|
|
||||||
### 4.6 Footer(高度 32 px)
|
|
||||||
|
|
||||||
| 元素 | 位置 | 文字 / 樣式 |
|
|
||||||
|------|------|-----------|
|
|
||||||
| 行數統計 | 左 | `Lines: {current} / 2000`,12px muted |
|
|
||||||
| 關閉提示 | 右 | `⚠ Closing this window will stop the server.`,12px muted |
|
|
||||||
|
|
||||||
**持久提示為什麼不彈 modal**(R5-2 解釋):
|
|
||||||
- 使用者已明確決策「關閉 = 結束 server」
|
|
||||||
- 每次關都彈 modal 只會煩,且使用者按過幾次就會盲目點「確定」失去意義
|
|
||||||
- 持久 footer 文字是「被動告知」而非「主動打斷」,符合 Jakob Nielsen 錯誤預防原則
|
|
||||||
- 使用者如果真的不想關,看到 footer 提示就會改按最小化
|
|
||||||
- 若使用者仍誤關,下次開啟 (R5-4 自動起 server + 自動開瀏覽器) 只需 3-5 秒就回到原狀,損失可控
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Server 狀態機(五態視覺化)
|
|
||||||
|
|
||||||
### 5.1 狀態定義(v2.1 修訂)
|
|
||||||
|
|
||||||
| State | 觸發條件 | 持續時間 |
|
|
||||||
|-------|---------|---------|
|
|
||||||
| `Starting` | 控制台剛開啟 / 使用者按 Start / Restart 過程中 | **通常 4-15 秒,上限 60 秒**(R5-E1) |
|
|
||||||
| `Running` | 6 階段全部完成(含 WebSocket 連上,R5-E6) | 主要 state |
|
|
||||||
| `Stopping` | 使用者按 Stop / 視窗關閉中 | 通常 <2 秒 |
|
|
||||||
| `Stopped` | Stop 完成、尚未 Restart | 空 state |
|
|
||||||
| `Error` | **啟動階段**:任一階段超時 20 秒進 Retry 提示;**總計超過 60 秒**(R5-E4)仍未就緒 → Error<br>**運行階段**:`/api/health` 連續失敗達閾值 / sidecar crash 超過 auto-restart 上限 | 停留直到使用者介入 |
|
|
||||||
|
|
||||||
### 5.2 視覺對照表(v2.1 修訂)
|
|
||||||
|
|
||||||
| State | 圓點顏色 | Icon | Status text 範例 | 附加元素 |
|
|
||||||
|-------|---------|------|----------------|---------|
|
|
||||||
| **Starting** | `color.warning` 琥珀 | 旋轉 spinner | `Starting · Stage {n}/6` | **log panel 上方浮出「啟動進度面板」**(見 `v2/startup-progress.md`)<br>Primary controls 全部 disabled |
|
|
||||||
| Running | `color.success` 綠 | — | `Running` 或 `Running · Browser opened`(Toggle ON 首次顯示 10 秒) | 啟動進度面板 fade-out;Open in Browser enabled |
|
|
||||||
| Running(自動開瀏覽器瞬間)| `color.success` 綠 | → 淡入 ✓ icon 2 秒 → fade out | `Running · Browser opened` 持續 10 秒後自動變回 `Running` | — |
|
|
||||||
| Stopping | `color.warning` 琥珀 | 旋轉 spinner | `Stopping...` | 所有 primary controls disabled |
|
|
||||||
| Stopped | `color.muted-foreground` 灰 | — | `Stopped` | 只有 `Start` 按鈕可按 |
|
|
||||||
| Error | `color.destructive` 紅 | ⚠ | `Error: {簡短原因}` | **見 §6 錯誤面板**;若從啟動階段進入 Error,啟動進度面板切換為 Error 狀態(見 `startup-progress.md §5`) |
|
|
||||||
|
|
||||||
### 5.3 狀態轉場動畫
|
|
||||||
|
|
||||||
- 圓點顏色過渡:300 ms ease-out
|
|
||||||
- Spinner 旋轉:1 s linear infinite
|
|
||||||
- `Running · Browser opened` 出現:fade + slide-in-left 200 ms,停留 10 秒,fade-out 200 ms
|
|
||||||
- `prefers-reduced-motion: reduce` → 全部動畫降為 0 ms 跳變
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Error State 面板(R5 共識)
|
|
||||||
|
|
||||||
當 Server 進入 `Error` 時,控制台 log panel 上方(介於 log controls 和 log panel 中間)**浮出一個 error banner**,log 面板不消失。
|
|
||||||
|
|
||||||
### 6.1 Wireframe
|
|
||||||
|
|
||||||
```
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
│ ☑ Follow tail ☑ Show timestamps 🔍 [ Filter ... ] │
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ ⚠ Server failed to start │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Python sidecar exited with code 1 after 3 retries. │ │
|
|
||||||
│ │ Last error: ModuleNotFoundError: kneron_plus │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ [ Restart Server ] [ View log details ↓ ] [ Report... ] │ │
|
|
||||||
│ └───────────────────────────────────────────────────────────────┘ │
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
│ 10:24:12 ERROR python sidecar exited code=1 │ ← Log panel 照常顯示
|
|
||||||
│ 10:24:13 ERROR ... │
|
|
||||||
│ ... │
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 元件
|
|
||||||
|
|
||||||
| 元素 | 類型 | 細節 |
|
|
||||||
|------|------|------|
|
|
||||||
| Banner 容器 | `<div role="alert">` | 背景 `color.destructive/10`,邊框 1px `color.destructive/30`,圓角 `radius.md`,padding 16 |
|
|
||||||
| 警告 icon | `<svg>` | 20×20,`color.destructive` |
|
|
||||||
| 標題 | `<strong>` | 14 px SemiBold,`color.destructive` |
|
|
||||||
| 說明 | `<p>` | 13 px,`color.foreground`,最多 2 行,溢出 `…` |
|
|
||||||
| `Restart Server` | Button `primary` `sm` | 點擊 → 呼叫內部 restart,banner 轉為 Starting state |
|
|
||||||
| `View log details ↓` | Button `ghost` `sm` | 點擊 → 自動捲動 log panel 到最後一條 ERROR 行並 flash 2 次 |
|
|
||||||
| `Report...` | Button `ghost` `sm` | **【hold】** 現階段**先不實作**。待 PM 提供 GitHub Issue repo URL 後再恢復。Design 原意:開預設瀏覽器到 GitHub Issue 新增頁面,預填錯誤摘要 + 環境資訊(version、OS、最後 20 行 log,**不含檔名 / 裝置 serial**) |
|
|
||||||
|
|
||||||
**R5-D1 落地(OS 原生通知並存)**:
|
|
||||||
|
|
||||||
Error state 進入時,除了控制台 log panel 上方的 Error banner(`role="alert"` 由 Wails WebView 內部顯示)**另外發送一次 OS 原生 non-blocking 通知**。這是 R5-D1 使用者決策:控制台可能被最小化、或在另一個桌面 / 虛擬桌面,使用者不一定會看到 banner,OS 通知作為次要冗餘提醒仍有價值。
|
|
||||||
|
|
||||||
| 平台 | 通知機制 | Fallback |
|
|
||||||
|------|---------|---------|
|
|
||||||
| macOS | `osascript -e 'display notification "..." with title "visionA-local"'`(toast 非 dialog) | — |
|
|
||||||
| Windows | `wailsRuntime.SendNotification`(優先) | `msg *` 命令列 |
|
|
||||||
| Linux | `notify-send` | `zenity --notification` |
|
|
||||||
|
|
||||||
**行為細節**:
|
|
||||||
- 通知內容:`標題 = visionA-local Server Error` / `內文 = {error.title}: {error.description 前 60 字}`
|
|
||||||
- **non-blocking**:不阻塞 UI,不彈 modal;與先前 v1 的 `showNativeError()`(給 startup 致命錯誤用的 modal dialog)區分
|
|
||||||
- **不重複發**:同一次 Error state 只發一次通知,使用者按 Restart Server 或 State 變回 Starting 後才重置「本次已發」flag
|
|
||||||
- **技術實作**:新增 Go 檔案 `visiona-local/notify.go`,函式 `sendCrashNotification(title, body string) error`;對應 TDD v2 `control-panel.md §4.7`
|
|
||||||
|
|
||||||
### 6.3 Dismiss 條件
|
|
||||||
|
|
||||||
Banner **不可手動關閉**(避免使用者忽略問題)。只有下列條件自動消失:
|
|
||||||
- 使用者按 `Restart Server` 且 server 成功進入 `Running`
|
|
||||||
- 使用者手動修復環境後按 Start 成功
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 啟動行為(對應 R5-4 / R5-D3 / R5-E)
|
|
||||||
|
|
||||||
### 7.1 預設流程(v2.1 修訂)
|
|
||||||
|
|
||||||
**v2.1 重要變更**:Starting 狀態下控制台顯示**階段化啟動進度面板**(見 `v2/startup-progress.md`),不是只有一顆 spinner。下述「step」對應進度面板的 6 個階段。
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 使用者雙擊 visionA-local.app
|
|
||||||
↓
|
|
||||||
2. 控制台視窗開(螢幕中央 / 上次位置)
|
|
||||||
↓
|
|
||||||
3. 控制台進入 Starting 狀態,log panel 上方顯示「啟動進度面板」
|
|
||||||
→ 階段 1:初始化控制台
|
|
||||||
→ 階段 2:檢查 Python runtime 與驅動
|
|
||||||
→ 階段 3:啟動本機伺服器(等 /api/health 200)
|
|
||||||
→ 階段 4:偵測 Kneron 裝置
|
|
||||||
↓
|
|
||||||
4. Server ready(階段 3 完成訊號 = /api/health 200)
|
|
||||||
↓
|
|
||||||
5. 【每次 / Settings 為 ON(macOS/Windows 預設;Linux 預設 OFF)】
|
|
||||||
階段 5「開啟瀏覽器」觸發 OS open browser
|
|
||||||
- macOS: `open http://127.0.0.1:3721/`
|
|
||||||
- Windows: `start http://127.0.0.1:3721/`
|
|
||||||
- Linux: `xdg-open http://127.0.0.1:3721/`
|
|
||||||
【Toggle OFF 時】階段 5 標記為「跳過(依偏好設定)」,不執行 OS open,但仍推進
|
|
||||||
↓
|
|
||||||
6. 階段 6:等待 Web UI 連線
|
|
||||||
(等 WebSocket hub 收到第一個 client 連線,R5-E6 決策)
|
|
||||||
【Toggle OFF 時】此階段改為「等待使用者手動點擊『在瀏覽器開啟』」
|
|
||||||
↓
|
|
||||||
7. 所有 6 階段完成
|
|
||||||
→ 啟動進度面板淡出(fade-out 200 ms)
|
|
||||||
→ Status: Running
|
|
||||||
→ Status text 顯示 `Running · Browser opened` 10 秒(Toggle ON 時)
|
|
||||||
或純 `Running`(Toggle OFF 時,使用者尚未手動 Open)
|
|
||||||
控制台留在背景(不最小化、不關閉)
|
|
||||||
↓
|
|
||||||
8. 瀏覽器 tab 進入 Next.js First-Run wizard(見 v2.4)
|
|
||||||
```
|
|
||||||
|
|
||||||
**R5-D3 重點**:**每次**啟動(每次 Wails App process 新啟動)都會跑完整 6 階段流程並觸發 OS open,不是只有首次。**Restart Server**(同一個 Wails process 內重啟 server)不會重開瀏覽器 tab — 由 Offline Overlay 的自動重連處理(見 `v2/server-offline-overlay.md`)。
|
|
||||||
|
|
||||||
### 7.2 視覺回饋
|
|
||||||
|
|
||||||
第 6 步的 `Running · Browser opened` 是使用者看到控制台第一個確認 server 就緒的訊號。具體視覺:
|
|
||||||
|
|
||||||
- Status dot 綠色
|
|
||||||
- Status text 後方 fade-in 一個 ✓ icon(`color.success`)
|
|
||||||
- Text 改為 `Running · Browser opened`
|
|
||||||
- 10 秒後 ✓ icon 淡出,text 縮為 `Running`
|
|
||||||
|
|
||||||
### 7.3 例外情境
|
|
||||||
|
|
||||||
| 情境 | 控制台行為 |
|
|
||||||
|------|----------|
|
|
||||||
| Server `Starting` 超過 5 秒 | 進入 Error state(見 §6),**不開瀏覽器** |
|
|
||||||
| Port 3721 被佔 | Server fallback 到 3722 / 3723,Header 顯示 `Port: 3722 (default 3721 in use)`,瀏覽器開的 URL 同步換 |
|
|
||||||
| Settings「自動開瀏覽器」= OFF | Server `Running` 後不做 auto-open,使用者需手動點 `Open in Browser` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 深色模式處理
|
|
||||||
|
|
||||||
控制台深色模式**與 Web UI 同步**,機制:
|
|
||||||
|
|
||||||
- 讀取 OS 偏好:控制台是 Wails WebView,直接用 CSS `prefers-color-scheme`
|
|
||||||
- CSS 變數切換:和 `frontend/src/app/globals.css` 用一樣的 `:root` / `[data-theme='dark']` block
|
|
||||||
- 不提供手動切換(v1 決策延續)
|
|
||||||
|
|
||||||
**Dark 下額外考量**:
|
|
||||||
- Log panel 背景 `oklch(0.18 0 0)`(比 surface 再暗 5%,模仿 terminal)
|
|
||||||
- ERROR 紅色在 dark 下用 `oklch(0.72 0.19 25)`(避免過亮刺眼)
|
|
||||||
- 圓點狀態色全部用 dark variant,確保 4.5:1 對比(R4-3 降為盡力而為,但狀態色這種 critical 信號仍維持嚴格)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. i18n key 清單(新元件)
|
|
||||||
|
|
||||||
控制台文字走 `desktop-control` namespace,**獨立於** Next.js Web UI 的 i18n 檔(但抽自同一份辭典,避免兩處維護)。
|
|
||||||
|
|
||||||
### 9.1 新增 key(zh-TW / en)
|
|
||||||
|
|
||||||
| Key | zh-TW | en |
|
|
||||||
|-----|-------|----|
|
|
||||||
| `control.title` | visionA-local · 伺服器控制台 | visionA-local · Server Control |
|
|
||||||
| `control.status.starting` | 啟動中... | Starting... |
|
|
||||||
| `control.status.running` | 執行中 | Running |
|
|
||||||
| `control.status.runningBrowserOpened` | 執行中 · 已開啟瀏覽器 | Running · Browser opened |
|
|
||||||
| `control.status.stopping` | 停止中... | Stopping... |
|
|
||||||
| `control.status.stopped` | 已停止 | Stopped |
|
|
||||||
| `control.status.error` | 錯誤:{reason} | Error: {reason} |
|
|
||||||
| `control.meta.port` | 連接埠 | Port |
|
|
||||||
| `control.meta.portFallback` | 連接埠:{port}(預設 {default} 被佔用) | Port: {port} (default {default} in use) |
|
|
||||||
| `control.meta.uptime` | 執行時間 | Uptime |
|
|
||||||
| `control.meta.pid` | 程序 ID | PID |
|
|
||||||
| `control.meta.version` | 版本 | Version |
|
|
||||||
| `control.action.openBrowser` | 在瀏覽器開啟 | Open in Browser |
|
|
||||||
| `control.action.start` | 啟動 | Start |
|
|
||||||
| `control.action.manage` | 管理 | Manage |
|
|
||||||
| `control.action.stopServer` | 停止伺服器 | Stop server |
|
|
||||||
| `control.action.restartServer` | 重新啟動伺服器 | Restart server |
|
|
||||||
| `control.log.followTail` | 自動跟隨最新 | Follow tail |
|
|
||||||
| `control.log.showTimestamps` | 顯示時間戳 | Show timestamps |
|
|
||||||
| `control.log.filterPlaceholder` | 過濾 log... | Filter... |
|
|
||||||
| `control.log.jumpToLatest` | 跳到最新 | Jump to latest |
|
|
||||||
| `control.log.clear` | 清空 | Clear |
|
|
||||||
| `control.log.clearToast` | 已清空 log(可復原) | Log cleared (undo) |
|
|
||||||
| `control.log.copy` | 複製 | Copy |
|
|
||||||
| `control.log.copyPrivacyHint` | Log 可能包含檔名與裝置資訊,請注意分享對象 | Log may contain filenames and device info. Share with care. |
|
|
||||||
| `control.log.export` | 匯出 log | Export log |
|
|
||||||
| `control.log.openFolder` | 開啟 log 資料夾 | Open log folder |
|
|
||||||
| `control.log.lines` | 行數:{current} / {max} | Lines: {current} / {max} |
|
|
||||||
| `control.footer.closeWarning` | ⚠ 關閉此視窗會停止伺服器 | ⚠ Closing this window will stop the server |
|
|
||||||
| `control.error.title` | 伺服器無法啟動 | Server failed to start |
|
|
||||||
| `control.error.description` | {具體原因} | {reason} |
|
|
||||||
| `control.error.restartButton` | 重新啟動伺服器 | Restart Server |
|
|
||||||
| `control.error.viewLogDetails` | 檢視 log 詳情 | View log details |
|
|
||||||
| `control.error.reportButton` | 回報問題... | Report... |
|
|
||||||
|
|
||||||
### 9.2 刪除 key(從現有 Next.js i18n 砍)
|
|
||||||
|
|
||||||
見 `v2/source-selector-update.md §3.2`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 無障礙考量
|
|
||||||
|
|
||||||
| 項目 | 設計 |
|
|
||||||
|------|------|
|
|
||||||
| Keyboard navigation | 所有 interactive 元素 `tabindex` 合理序列:Open in Browser → Start/Manage → Follow tail → Show timestamps → Filter → (log panel 可選取) → Clear → Copy → Export → Open folder |
|
|
||||||
| Focus ring | 沿用 Web UI token `ring.2 · color.primary`,2 px outline-offset |
|
|
||||||
| Keyboard shortcut | `⌘F` / `Ctrl+F` 聚焦 filter;`⌘C` / `Ctrl+C` 複製選取 log;`⌘W` / `Ctrl+W` 關視窗(R5-2:結束 server) |
|
|
||||||
| `⌘Q` | macOS 原生結束 app:停 server + quit |
|
|
||||||
| Screen reader | Status indicator `<span role="status" aria-live="polite">`;Error banner `<div role="alert">`;log panel `<output aria-live="polite" aria-atomic="false">`(新行 append) |
|
|
||||||
| ARIA label | 所有 icon button 有 `aria-label`(例如 Follow tail checkbox 的 trailing spinner 有 `aria-label="Auto-scrolling enabled"`) |
|
|
||||||
| 色彩對比 | Status dot / ERROR level log / Error banner 強制 ≥ 4.5:1(即使 A11y 整體降級為「盡力而為」,critical 信號不妥協) |
|
|
||||||
| Reduced motion | `prefers-reduced-motion: reduce` → 關閉 spinner 旋轉(改為靜態點)、關閉 log 滑入動畫、關閉 Browser opened fade |
|
|
||||||
| 字級可縮放 | 使用 `rem` 而非 `px` 定義字級,支援 OS 字級偏好 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 與 v1(`design-analysis-round2-refactor.md`)的差異
|
|
||||||
|
|
||||||
| 面向 | v1 分析稿 | v2 正式規格 |
|
|
||||||
|------|----------|------------|
|
|
||||||
| 視窗職責 | 三方尚在討論 | 確定為雙 UI(R5-1) |
|
|
||||||
| 關閉行為 | 待 D1 決策 | **關閉 = 結束 server**,footer 持久提示(R5-2) |
|
|
||||||
| Tray | 建議復活 tray | **不做**(R5-3) |
|
|
||||||
| 首次啟動 | 建議自動開瀏覽器 | 採納自動開瀏覽器(R5-4) |
|
|
||||||
| Primary controls | 4 顆 | 3 顆(Stop/Restart 併入 Manage 下拉) |
|
|
||||||
| Header 狀態列 | 固定 `Running` | 首次啟動後動態 `Running · Browser opened` 10 秒 |
|
|
||||||
| Error 狀態視覺 | 未設計 | 新增 Error banner(§6) |
|
|
||||||
| Mock 切換 | 未納入 scope | **明確砍除**(R5-5a) |
|
|
||||||
| Log 上限 | 1000 行 | 1000 行(維持) |
|
|
||||||
| Log 寫檔 | 7 天 / 10MB rotate | 維持 |
|
|
||||||
| 語系 | 跟隨系統 | 跟隨系統(維持) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. v2 → v2.1 Diff(2026-04-14)
|
|
||||||
|
|
||||||
| # | 位置 | v2 | v2.1 | 來源 |
|
|
||||||
|---|------|----|----|------|
|
|
||||||
| 1 | §4.4 Log panel 最大行數 | 1000 行 | **2000 行**(對齊 TDD v2 ring buffer) | Architect Review Minor m-1 |
|
|
||||||
| 2 | §4.4 Log 寫檔 | rotate 7 天 / 10 MB | **無落地寫檔**(in-memory ring buffer,使用者透過 Export log 手動匯出) | Architect Review Minor m-12 |
|
|
||||||
| 3 | §4.6 Footer 行數顯示 | `Lines: {current} / 1000` | `Lines: {current} / 2000` | Architect Review Minor m-1 |
|
|
||||||
| 4 | §5 狀態機 | Starting 只有 spinner,1-5 秒 | Starting 顯示**階段化啟動進度面板**,4-15 秒(上限 60 秒,R5-E1) | R5-E |
|
|
||||||
| 5 | §6.2 Error banner | `Report...` 正常按鈕 | **【hold】** 待 PM 提供 GitHub Issue repo URL 後再恢復 | Architect Review Minor m-11 / G-3 |
|
|
||||||
| 6 | §6.2 新增 | — | **R5-D1 OS 原生通知並存**(Error state 發 non-blocking toast notification,不是 modal) | R5-D1 / Architect Review Minor m-4 |
|
|
||||||
| 7 | §7.1 第 5 步 | 「首次 / Settings 為 ON」 | **「每次 / Settings 為 ON」**,新增 Linux 預設 OFF 說明,流程改為 6 階段化 | R5-D3 + R5-E |
|
|
||||||
| 8 | §7.1 新增 | — | 引用新檔 `v2/startup-progress.md`(R5-E 階段化啟動進度面板) | R5-E |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**下一步**:交 M8-5 Frontend Agent 實作(Wails 控制台 + 啟動進度面板),交 Reviewer 審查 control-panel.md + startup-progress.md 整體一致性。
|
|
||||||
@ -1,340 +0,0 @@
|
|||||||
# v2.4 — First-Run 流程重定義
|
|
||||||
|
|
||||||
> 本章對應 R5-4(首次啟動自動開瀏覽器)+ R5-5a(Mock 模式完全砍除)。
|
|
||||||
> 取代 v1 `spec/04-first-run.md` 的「歡迎 → 模式選擇 → 硬體偵測」三步流程。
|
|
||||||
> 上層索引:`../design-spec-v2.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 背景與變更點
|
|
||||||
|
|
||||||
v1 First-Run 流程:
|
|
||||||
|
|
||||||
```
|
|
||||||
雙擊 app → Wails splash → Next.js 主 UI 打開 First-Run wizard
|
|
||||||
Step 1: 歡迎 / 產品介紹
|
|
||||||
Step 2: 模式選擇(真實硬體 / Mock)
|
|
||||||
Step 3: 硬體偵測
|
|
||||||
→ Dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
R5 兩個決策直接改寫這個流程:
|
|
||||||
|
|
||||||
1. **R5-5a 完全砍除 Mock 模式** → Step 2 整步消失
|
|
||||||
2. **R5-4 首次啟動自動開瀏覽器** → 雙擊 app 看到的是 Wails 控制台,First-Run wizard 跑在瀏覽器裡(不是 Wails 裡)
|
|
||||||
|
|
||||||
結果:First-Run wizard 從**三步**變成**兩步**,且入口從「Wails splash」變成「瀏覽器」。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 新的 First-Run 流程圖
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 新 First-Run 完整流程 │
|
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
使用者雙擊 visionA-local.app
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌────────────────────────────┐
|
|
||||||
│ Wails Server Control │
|
|
||||||
│ Panel 開啟 │
|
|
||||||
│ 狀態: Starting... │
|
|
||||||
└────────────┬───────────────┘
|
|
||||||
│ 內部自動 startServer()
|
|
||||||
▼
|
|
||||||
┌────────────────────────────┐
|
|
||||||
│ Server ready │
|
|
||||||
│ 狀態: Running │
|
|
||||||
└────────────┬───────────────┘
|
|
||||||
│ [Settings.autoOpenBrowser === true]
|
|
||||||
│ (預設 ON,首次啟動一定 ON)
|
|
||||||
▼
|
|
||||||
┌────────────────────────────┐
|
|
||||||
│ 控制台呼叫 OS open │
|
|
||||||
│ http://127.0.0.1:3721/ │
|
|
||||||
└────────────┬───────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌────────────────────────────────────────────────────────────┐
|
|
||||||
│ 瀏覽器打開 → Next.js 啟動 │
|
|
||||||
│ 檢查 localStorage.firstRunCompleted │
|
|
||||||
│ │
|
|
||||||
│ ├── false → 導向 /onboarding │
|
|
||||||
│ └── true → 導向 / (Dashboard) │
|
|
||||||
└────────────────────────────────────────────────────────────┘
|
|
||||||
│ firstRunCompleted === false
|
|
||||||
▼
|
|
||||||
┌────────────────────────────────────────────────────────────┐
|
|
||||||
│ First-Run Wizard │
|
|
||||||
│ │
|
|
||||||
│ Step 1: 歡迎 ─────────────────────────┐ │
|
|
||||||
│ ┌──────────────────────────────────┐ │ │
|
|
||||||
│ │ 歡迎使用 visionA-local │ │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ 這是一個本機 Edge AI 推論工具 │ │ │
|
|
||||||
│ │ 你可以用它在自己的電腦上跑模型 │ │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ [Gatekeeper 警告說明] │ │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ [ 下一步 ] │ │ │
|
|
||||||
│ │ [ 略過 ] │ │ │
|
|
||||||
│ └──────────────────────────────────┘ │ │
|
|
||||||
│ ▼ │
|
|
||||||
│ Step 2: 硬體偵測 ────────────────── │
|
|
||||||
│ ┌──────────────────────────────────┐ │
|
|
||||||
│ │ 正在掃描 Kneron 裝置... │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ┌─ 偵測結果 ────────────────┐ │ │
|
|
||||||
│ │ │ 情境 A: 找到 1+ 裝置 │ │ │
|
|
||||||
│ │ │ ✓ Kneron KL520 x1 │ │ │
|
|
||||||
│ │ │ [ 進入 Dashboard ] │ │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ │ 情境 B: 未找到裝置 │ │ │
|
|
||||||
│ │ │ ⚠ 未偵測到 Kneron 裝置 │ │ │
|
|
||||||
│ │ │ 請插入 Kneron 裝置後 │ │ │
|
|
||||||
│ │ │ 重新整理,或略過直接進入 │ │ │
|
|
||||||
│ │ │ Dashboard(可隨時重掃) │ │ │
|
|
||||||
│ │ │ [ 重新掃描 ] [ 略過 ] │ │ │
|
|
||||||
│ │ └─────────────────────────────┘ │ │
|
|
||||||
│ └──────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ▼ │
|
|
||||||
│ firstRunCompleted = true │
|
|
||||||
│ │ │
|
|
||||||
│ ▼ │
|
|
||||||
│ Dashboard │
|
|
||||||
└────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Step-by-Step 規格
|
|
||||||
|
|
||||||
### 3.1 Step 1 — 歡迎
|
|
||||||
|
|
||||||
**目的**:介紹產品、安撫 Gatekeeper 警告焦慮、讓使用者按一個按鈕就進入下一步。
|
|
||||||
|
|
||||||
**結構**:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ [LOGO] │
|
|
||||||
│ │
|
|
||||||
│ 歡迎使用 visionA-local │
|
|
||||||
│ │
|
|
||||||
│ 這是一個本機 Edge AI 推論工具, │
|
|
||||||
│ 你可以在自己的電腦上跑模型,完全離線。 │
|
|
||||||
│ │
|
|
||||||
│ ──────────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ 🔒 你可能剛才看到系統安全性警告? │
|
|
||||||
│ │
|
|
||||||
│ 因為這是內部工具,未購買 Apple/Microsoft │
|
|
||||||
│ 憑證簽章。你看到的警告是正常的, │
|
|
||||||
│ 我們已經驗證此版本的完整性。 │
|
|
||||||
│ │
|
|
||||||
│ [ 下一步 ] [ 略過 Onboarding ] │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**元件**:
|
|
||||||
- Logo(80×80)
|
|
||||||
- 標題 `<h1>` 28 px Bold
|
|
||||||
- 簡介 `<p>` 15 px
|
|
||||||
- Gatekeeper 安撫段 `<section>`(灰底卡片 `color.muted/40`)
|
|
||||||
- `[ 下一步 ]` Button primary lg
|
|
||||||
- `[ 略過 Onboarding ]` Button ghost sm(右下角)
|
|
||||||
|
|
||||||
**「略過」行為**:直接設 `firstRunCompleted = true` 並跳 Dashboard,不經過 Step 2。
|
|
||||||
|
|
||||||
### 3.2 Step 2 — 硬體偵測
|
|
||||||
|
|
||||||
**目的**:掃描 Kneron 裝置,讓使用者知道硬體 ready 與否,但 **不強迫** 插裝置。
|
|
||||||
|
|
||||||
**結構**(含三種子狀態):
|
|
||||||
|
|
||||||
#### 3.2.1 Scanning 子狀態(進入 Step 2 的首個瞬間)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ [LOGO] │
|
|
||||||
│ │
|
|
||||||
│ 硬體偵測 │
|
|
||||||
│ │
|
|
||||||
│ ⟳ 正在掃描 Kneron 裝置... │
|
|
||||||
│ │
|
|
||||||
│ (平均 2-5 秒) │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2.2 Found 子狀態(找到 ≥ 1 個裝置)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ ✓ 偵測到 Kneron 裝置 │
|
|
||||||
│ │
|
|
||||||
│ ┌────────────────────────────┐ │
|
|
||||||
│ │ Kneron KL520 · USB 001 │ │
|
|
||||||
│ │ 韌體 v2.1.0 │ │
|
|
||||||
│ └────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 偵測到 1 個裝置,已就緒 │
|
|
||||||
│ │
|
|
||||||
│ [ 進入 Dashboard ] [ 重新掃描 ] │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2.3 Not Found 子狀態(R5-5a 新行為:沒找到就空白)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ ⚠ 未偵測到 Kneron 裝置 │
|
|
||||||
│ │
|
|
||||||
│ ┌────────────────────────────────────┐ │
|
|
||||||
│ │ 可能的原因: │ │
|
|
||||||
│ │ • 裝置尚未插入 USB │ │
|
|
||||||
│ │ • 驅動程式未安裝 │ │
|
|
||||||
│ │ • 裝置被其他程式占用 │ │
|
|
||||||
│ └────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 你可以現在插入裝置後「重新掃描」, │
|
|
||||||
│ 或略過直接進入 Dashboard,稍後 │
|
|
||||||
│ 從 Devices 頁面再掃描。 │
|
|
||||||
│ │
|
|
||||||
│ [ 重新掃描 ] [ 略過並進入 Dashboard ] │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**R5-5a 的關鍵決策**:沒插硬體**不會**停在這個畫面逼使用者插,使用者可以按「略過並進入 Dashboard」,Dashboard / Devices 頁面自己會顯示「無裝置」空狀態。
|
|
||||||
|
|
||||||
### 3.3 Step 2 的「進入 Dashboard」規則
|
|
||||||
|
|
||||||
| 情境 | 按鈕 | 行為 |
|
|
||||||
|------|------|------|
|
|
||||||
| Found | `[ 進入 Dashboard ]` primary | `firstRunCompleted = true` → Dashboard |
|
|
||||||
| Not Found | `[ 略過並進入 Dashboard ]` secondary | 同上 |
|
|
||||||
| Found,但使用者按 `[ 重新掃描 ]` | — | 重跑掃描,回到 Scanning 子狀態 |
|
|
||||||
| Not Found,使用者插入裝置後按 `[ 重新掃描 ]` | — | 重跑掃描,成功的話進入 Found 子狀態 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 「沒有 Mock 的替代心智模型」
|
|
||||||
|
|
||||||
R5-5a 砍掉 Mock 後,使用者如果沒插硬體就**什麼都看不到推論結果**。這對「想先玩玩看」的新手是個空狀態問題。
|
|
||||||
|
|
||||||
**設計回應**:
|
|
||||||
|
|
||||||
- Dashboard 空狀態:顯示「插入 Kneron 裝置開始推論」的**友善引導**,而不是冷冰冰的「無資料」
|
|
||||||
- Devices 頁面空狀態:顯示「如何取得 Kneron 裝置」的連結 + 「重新掃描」按鈕
|
|
||||||
- Workspace 入口:如果沒有裝置,sidebar 的 Workspace 項目 **disabled** 並 tooltip `請先在 Devices 頁面連接裝置`
|
|
||||||
|
|
||||||
這些空狀態細節沿用 v1 `spec/08-states.md`,但**把原本寫「或切換到 Mock 模式試用」的句子全部刪除**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. i18n key 變動
|
|
||||||
|
|
||||||
### 5.1 新增 key(zh-TW / en)
|
|
||||||
|
|
||||||
| Key | zh-TW | en |
|
|
||||||
|-----|-------|----|
|
|
||||||
| `firstRun.step1.title` | 歡迎使用 visionA-local | Welcome to visionA-local |
|
|
||||||
| `firstRun.step1.description` | 這是一個本機 Edge AI 推論工具,你可以在自己的電腦上跑模型,完全離線。 | A local edge AI inference tool. Run models on your own computer, fully offline. |
|
|
||||||
| `firstRun.step1.gatekeeperTitle` | 你可能剛才看到系統安全性警告? | Did you see a system security warning? |
|
|
||||||
| `firstRun.step1.gatekeeperDescription` | 因為這是內部工具,未購買 Apple/Microsoft 憑證簽章。你看到的警告是正常的,我們已經驗證此版本的完整性。 | This is an internal tool without Apple/Microsoft code signing certificates. The warning is expected, and this version has been verified for integrity. |
|
|
||||||
| `firstRun.step1.next` | 下一步 | Next |
|
|
||||||
| `firstRun.step1.skip` | 略過 Onboarding | Skip onboarding |
|
|
||||||
| `firstRun.step2.title` | 硬體偵測 | Hardware detection |
|
|
||||||
| `firstRun.step2.scanning` | 正在掃描 Kneron 裝置... | Scanning for Kneron devices... |
|
|
||||||
| `firstRun.step2.found` | 偵測到 Kneron 裝置 | Kneron device detected |
|
|
||||||
| `firstRun.step2.foundSummary` | 偵測到 {count} 個裝置,已就緒 | {count} device(s) detected and ready |
|
|
||||||
| `firstRun.step2.notFound` | 未偵測到 Kneron 裝置 | No Kneron device detected |
|
|
||||||
| `firstRun.step2.notFoundReasons` | 可能的原因:• 裝置尚未插入 USB • 驅動程式未安裝 • 裝置被其他程式占用 | Possible reasons: • Device not plugged in • Driver not installed • Device occupied by another app |
|
|
||||||
| `firstRun.step2.notFoundHelp` | 你可以現在插入裝置後「重新掃描」,或略過直接進入 Dashboard,稍後從 Devices 頁面再掃描。 | Plug in a device and rescan, or skip to the Dashboard and scan later from Devices. |
|
|
||||||
| `firstRun.step2.rescan` | 重新掃描 | Rescan |
|
|
||||||
| `firstRun.step2.enterDashboard` | 進入 Dashboard | Enter Dashboard |
|
|
||||||
| `firstRun.step2.skipToDashboard` | 略過並進入 Dashboard | Skip to Dashboard |
|
|
||||||
|
|
||||||
### 5.2 刪除 key(原 Step 2「模式選擇」的 key 全砍)
|
|
||||||
|
|
||||||
**所有** `firstRun.mode.*` / `firstRun.step2.*`(若原 step2 = 模式選擇)相關 key 全部刪除。這包含但不限於:
|
|
||||||
|
|
||||||
| Key | 理由 |
|
|
||||||
|-----|------|
|
|
||||||
| `firstRun.mode.title` | 模式選擇整步消失 |
|
|
||||||
| `firstRun.mode.real.title` | 同上 |
|
|
||||||
| `firstRun.mode.real.description` | 同上 |
|
|
||||||
| `firstRun.mode.mock.title` | 同上 |
|
|
||||||
| `firstRun.mode.mock.description` | 同上 |
|
|
||||||
| `firstRun.mode.recommend` | 同上 |
|
|
||||||
| `firstRun.mode.switchLater` | 同上 |
|
|
||||||
|
|
||||||
(實際 key 名以現有 i18n 檔為準,grep `firstRun.mode` 全刪)
|
|
||||||
|
|
||||||
### 5.3 重編號注意
|
|
||||||
|
|
||||||
v1 的 `firstRun.step2.*`(模式選擇)被砍,v1 的 `firstRun.step3.*`(硬體偵測)在 v2 變成 `firstRun.step2.*`。**需要 rename**:`firstRun.step3.*` → `firstRun.step2.*`,或者乾脆用意義化命名:`firstRun.welcome.*`、`firstRun.hardware.*`。建議後者,後續維護更清楚。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 無障礙考量
|
|
||||||
|
|
||||||
| 項目 | 設計 |
|
|
||||||
|------|------|
|
|
||||||
| 步驟指示 | Header 顯示 `Step 1 of 2` / `Step 2 of 2`,對 screen reader friendly |
|
|
||||||
| Keyboard | `Tab` 循序、`Enter` 觸發 primary button、`Esc` 不關閉(防止誤按) |
|
|
||||||
| Focus 初始 | 每步的 primary button 預設聚焦 |
|
|
||||||
| ARIA | `<section role="region" aria-labelledby="step-title">`;掃描 spinner `<span role="status" aria-live="polite">` |
|
|
||||||
| Reduced motion | 關閉步驟間 slide transition |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 與 v1 差異對照
|
|
||||||
|
|
||||||
| 面向 | v1(三步) | v2(兩步) |
|
|
||||||
|------|----------|----------|
|
|
||||||
| 入口 | Wails splash → 跳 Next.js | Wails 控制台 → 自動開瀏覽器 → Next.js |
|
|
||||||
| 步驟數 | 3(歡迎 / 模式選擇 / 硬體偵測) | **2**(歡迎 / 硬體偵測) |
|
|
||||||
| 模式選擇 | 有(真實硬體 / Mock) | **無**(R5-5a 砍 Mock) |
|
|
||||||
| 硬體未偵測到的引導 | 「或切換到 Mock 模式試用」 | 「略過並進入 Dashboard,稍後重掃」 |
|
|
||||||
| 略過按鈕位置 | 每步右上 | Step 1 右下、Step 2 主按鈕之一 |
|
|
||||||
| 完成標記 | `localStorage.firstRunCompleted` | 同(不變) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 邊界情境
|
|
||||||
|
|
||||||
| 情境 | 處理 |
|
|
||||||
|------|------|
|
|
||||||
| 使用者在 Step 1 關閉瀏覽器 tab | `firstRunCompleted` 不變,下次打開還是從 Step 1 開始 |
|
|
||||||
| 使用者在 Step 2 Scanning 中關閉 | 同上 |
|
|
||||||
| 使用者在 Step 2 按「略過」 | `firstRunCompleted = true`,下次打開直達 Dashboard |
|
|
||||||
| 使用者清 localStorage 後重開 | 重新跑 First-Run(這是 feature 不是 bug) |
|
|
||||||
| 首次自動開瀏覽器失敗(xdg-open 在極簡 Linux) | 瀏覽器沒開 → 使用者會看到控制台「Running · Browser opened」但瀏覽器沒動 → 使用者手動點 `[Open in Browser]` → 瀏覽器打開 → 進 First-Run |
|
|
||||||
| 使用者在 Wails 控制台 Settings 關了 auto-open,下次雙擊 | 進 Step 1 的前提需要使用者**手動**點 Open in Browser。控制台開著等使用者動作 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 給 PM 的提醒
|
|
||||||
|
|
||||||
- PRD 原本定義 First-Run 是 3 步,`02-prd/` 子檔需要更新
|
|
||||||
- Persona 流程描述 v1 寫「使用者被引導到模式選擇」,要改
|
|
||||||
- 「Mock 模式是 MVP 功能」若曾寫進 PRD,須移到「非目標 / removed features」
|
|
||||||
- `feature-inventory.md` 若列出 Mock 模式為 feature,需標記為 removed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**下一步**:
|
|
||||||
- 給 PM Agent 更新 PRD 對應段落(Orchestrator 自行更新或轉交 PM)
|
|
||||||
- 給 Frontend Agent 按本 spec 實作 onboarding 頁面
|
|
||||||
- 給 Testing Agent 設計 E2E:雙擊 app → 看控制台 → 自動開瀏覽器 → 跑 First-Run → 進 Dashboard 的完整 flow
|
|
||||||
@ -1,275 +0,0 @@
|
|||||||
# v2.2 — Server Offline Overlay 設計規格
|
|
||||||
|
|
||||||
> 本章對應 R5-2(關閉 Wails 視窗 = 結束 server,瀏覽器端顯示離線 Overlay)。
|
|
||||||
> 上層索引:`../design-spec-v2.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 背景與目的
|
|
||||||
|
|
||||||
R5-2 決定**關閉 Wails 控制台 = 結束 server**,但瀏覽器端不知道這件事發生。如果使用者一手把控制台關掉、另一手還停留在瀏覽器 tab:
|
|
||||||
|
|
||||||
- 下一次發 fetch → 連線被拒(`ECONNREFUSED`)
|
|
||||||
- 正在 stream 的 MJPEG / WebSocket → 突然斷線
|
|
||||||
- 使用者體感:「我只是關掉那個 log 視窗,為什麼網頁壞了?」
|
|
||||||
|
|
||||||
**Server Offline Overlay** 是當瀏覽器端偵測到 server 不可達時,**全螢幕蓋住整個 Web UI**的硬阻斷畫面,明確告訴使用者「server 真的沒了」,並提供重試 / 重開 app 的自助路徑。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 視覺 Wireframe
|
|
||||||
|
|
||||||
```
|
|
||||||
╔═══════════════════════════════════════════════════════════════════╗
|
|
||||||
║ ║
|
|
||||||
║ ║
|
|
||||||
║ ║
|
|
||||||
║ ┌─────────┐ ║
|
|
||||||
║ │ ⚠ │ ║
|
|
||||||
║ │ │ ║
|
|
||||||
║ └─────────┘ ║
|
|
||||||
║ ║
|
|
||||||
║ Local Server 已離線 ║
|
|
||||||
║ ║
|
|
||||||
║ visionA-local 已結束或崩潰,請重新開啟應用程式 ║
|
|
||||||
║ ║
|
|
||||||
║ ║
|
|
||||||
║ ┌────────────────────────┐ ║
|
|
||||||
║ │ 重試連線 │ ║
|
|
||||||
║ └────────────────────────┘ ║
|
|
||||||
║ ║
|
|
||||||
║ 了解更多 ↓ ║
|
|
||||||
║ ║
|
|
||||||
║ ║
|
|
||||||
║ ┌──────────────────────────────────────────────────┐ ║
|
|
||||||
║ │ 如何重新啟動 visionA-local: │ ║
|
|
||||||
║ │ │ ║
|
|
||||||
║ │ 1. 前往應用程式資料夾或 Dock │ ║
|
|
||||||
║ │ 2. 雙擊 visionA-local 圖示 │ ║
|
|
||||||
║ │ 3. 控制台會自動啟動伺服器並重新開啟瀏覽器 │ ║
|
|
||||||
║ │ │ ║
|
|
||||||
║ │ 如果問題持續發生,請檢查 Log 或回報問題 │ ║
|
|
||||||
║ └──────────────────────────────────────────────────┘ ║
|
|
||||||
║ ║
|
|
||||||
║ ║
|
|
||||||
╚═══════════════════════════════════════════════════════════════════╝
|
|
||||||
↑ 半透明黑色背景覆蓋整個 viewport,底下原 Web UI 仍隱約可見但不可互動
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.1 尺寸與位置
|
|
||||||
|
|
||||||
| 元素 | 規格 |
|
|
||||||
|------|------|
|
|
||||||
| Overlay 背景 | `position: fixed; inset: 0; z-index: 9999` |
|
|
||||||
| 背景色(Light) | `rgba(0, 0, 0, 0.55)` + `backdrop-filter: blur(8px)` |
|
|
||||||
| 背景色(Dark) | `rgba(0, 0, 0, 0.72)` + `backdrop-filter: blur(8px)` |
|
|
||||||
| 內容卡片寬度 | `clamp(320px, 42vw, 480px)` |
|
|
||||||
| 內容卡片背景 | `color.surface`(Light:近白;Dark:近黑) |
|
|
||||||
| 內容卡片圓角 | `radius.xl`(16 px) |
|
|
||||||
| 內容卡片 padding | 40 px |
|
|
||||||
| 內容卡片 shadow | `shadow.xl`(v1 token) |
|
|
||||||
|
|
||||||
### 2.2 元件規格
|
|
||||||
|
|
||||||
| 元素 | 類型 | 規格 | 文字 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| Icon 容器 | `<div>` | 80×80,圓形 `color.destructive/10` 背景 | — |
|
|
||||||
| Icon | `<svg>` 警告三角 | 40×40,`color.destructive` | — |
|
|
||||||
| 標題 | `<h1>` | 24 px Bold `color.foreground` 置中 | `Local Server 已離線` |
|
|
||||||
| 副標 | `<p>` | 15 px Regular `color.muted-foreground` 置中 | `visionA-local 已結束或崩潰,請重新開啟應用程式` |
|
|
||||||
| 重試按鈕 | Button `primary` `lg` | 寬度 240 px 置中 | `重試連線` |
|
|
||||||
| 了解更多 | Button `ghost` `sm` | toggle 展開下方 help text | `了解更多 ↓` / `收起 ↑` |
|
|
||||||
| Help text 區 | `<div>` 展開 | padding 16 inline-block,背景 `color.muted/30`,圓角 `radius.md` | 見 §5 |
|
|
||||||
|
|
||||||
### 2.3 Dark Mode
|
|
||||||
|
|
||||||
- 背景半透明更深(`rgba(0,0,0,0.72)`)
|
|
||||||
- 卡片背景改為 `color.surface-2`(Dark 模式下比 surface 淺一階,視覺上「浮起」)
|
|
||||||
- Icon 圓圈背景改為 `color.destructive/15`
|
|
||||||
- 所有文字對比保持 ≥ 4.5:1
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 觸發條件
|
|
||||||
|
|
||||||
### 3.1 首次載入
|
|
||||||
|
|
||||||
使用者首次在瀏覽器打開 `http://127.0.0.1:{port}/` 時:
|
|
||||||
|
|
||||||
```
|
|
||||||
頁面載入
|
|
||||||
↓
|
|
||||||
前端 boot sequence: fetch GET /api/health
|
|
||||||
↓
|
|
||||||
├── 2xx → 正常顯示 Web UI,不顯示 Overlay
|
|
||||||
└── 網路錯誤 / 非 2xx → 立即顯示 Overlay
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 使用中偵測
|
|
||||||
|
|
||||||
當瀏覽器 Web UI 已經在跑時,透過以下兩個管道偵測 server 斷線:
|
|
||||||
|
|
||||||
#### 3.2.1 定期 Health Check(主動)
|
|
||||||
|
|
||||||
- Polling `/api/health`,間隔 `10 秒`
|
|
||||||
- 連續 **2 次** 失敗(20 秒)→ 顯示 Overlay
|
|
||||||
- **為什麼是 2 次而非 3 次**:Local 127.0.0.1 幾乎不可能有網路抖動,2 次已足夠避免誤報;3 次會拖到 30 秒,使用者早就察覺異常
|
|
||||||
|
|
||||||
#### 3.2.2 WebSocket / SSE 斷線(被動)
|
|
||||||
|
|
||||||
- 現有的 `camera-store` 使用 SSE / WebSocket 接收 inference 結果
|
|
||||||
- 任何 `onclose` / `onerror` event → **立即** 觸發一次 `/api/health` 驗證
|
|
||||||
- 如果 health check 也失敗 → 直接顯示 Overlay(不等 10 秒 polling)
|
|
||||||
- 如果 health check 成功(代表只是單一 stream 斷線)→ 由 camera-store 自己處理 reconnect,不顯示 Overlay
|
|
||||||
|
|
||||||
### 3.3 首次載入特殊情境
|
|
||||||
|
|
||||||
如果使用者把瀏覽器書籤設成 `http://127.0.0.1:3721/workspace/xxx`,但 visionA-local 還沒啟動 → 首次 fetch 就會失敗 → Overlay 立即顯示。這個情境比「中途斷線」更常見,必須處理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Dismiss 條件
|
|
||||||
|
|
||||||
| 條件 | 行為 |
|
|
||||||
|------|------|
|
|
||||||
| 按「重試連線」按鈕 | 立即呼叫一次 `/api/health`,成功 → 自動 dismiss;失敗 → 按鈕顯示 loading 500 ms,然後 shake 動畫 + toast「仍無法連線,請檢查 visionA-local 是否執行中」 |
|
|
||||||
| 背景 polling 成功 | **自動 dismiss**(覆蓋層淡出 200 ms) |
|
|
||||||
| 使用者手動 reload 頁面 | 頁面重載 → 首次載入流程 → 若 server 仍掛,Overlay 再次出現 |
|
|
||||||
| 使用者按 ESC / 點背景 | **不 dismiss**(這是硬阻斷,不允許偷偷略過) |
|
|
||||||
|
|
||||||
### 4.1 自動重連的細節
|
|
||||||
|
|
||||||
Overlay 顯示期間,仍維持 polling `/api/health`(間隔 `3 秒`,比正常時更積極)。這是為了**使用者雙擊 app 重開 → 等 3-6 秒 → 不需手動點重試就自動恢復**,接近 Ollama 「server 回來就立刻恢復」的體驗。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 文案(雙語完整版)
|
|
||||||
|
|
||||||
### 5.1 主文案
|
|
||||||
|
|
||||||
| 位置 | zh-TW | en |
|
|
||||||
|------|-------|----|
|
|
||||||
| 標題 | Local Server 已離線 | Local Server is offline |
|
|
||||||
| 副標 | visionA-local 已結束或崩潰,請重新開啟應用程式 | visionA-local has stopped or crashed. Please restart the app. |
|
|
||||||
| Primary CTA | 重試連線 | Retry connection |
|
|
||||||
| Secondary CTA(展開前) | 了解更多 ↓ | Learn more ↓ |
|
|
||||||
| Secondary CTA(展開後) | 收起 ↑ | Hide ↑ |
|
|
||||||
| Retry 失敗 toast | 仍無法連線,請檢查 visionA-local 是否執行中 | Still cannot connect. Please check if visionA-local is running. |
|
|
||||||
|
|
||||||
### 5.2 展開的 Help text(繁中)
|
|
||||||
|
|
||||||
```
|
|
||||||
如何重新啟動 visionA-local:
|
|
||||||
|
|
||||||
1. 前往應用程式資料夾或 Dock
|
|
||||||
2. 雙擊 visionA-local 圖示
|
|
||||||
3. 控制台會自動啟動伺服器並重新開啟瀏覽器
|
|
||||||
|
|
||||||
如果問題持續發生,請檢查 Log 或回報問題。
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 展開的 Help text(英文)
|
|
||||||
|
|
||||||
```
|
|
||||||
How to restart visionA-local:
|
|
||||||
|
|
||||||
1. Go to your Applications folder or Dock
|
|
||||||
2. Double-click the visionA-local icon
|
|
||||||
3. The control panel will automatically start the server and reopen the browser
|
|
||||||
|
|
||||||
If the issue persists, please check the logs or report the problem.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 動畫與 Transition
|
|
||||||
|
|
||||||
| 事件 | 動畫 | 時長 |
|
|
||||||
|------|------|------|
|
|
||||||
| Overlay 顯示 | 背景 `opacity 0 → 1` + backdrop-filter `blur(0) → blur(8)`;卡片 `opacity 0 → 1 + translateY(20px → 0)` | 250 ms ease-out |
|
|
||||||
| Overlay 消失 | 反向 | 200 ms ease-in |
|
|
||||||
| 重試按鈕 click | Button scale `0.98` → `1` | 150 ms |
|
|
||||||
| 重試失敗 shake | 卡片 `translateX ±4px` 3 次 | 400 ms |
|
|
||||||
| Help text 展開 / 收起 | height auto transition + opacity | 200 ms ease-out |
|
|
||||||
| `prefers-reduced-motion` | 全部動畫降為 0 ms 跳變 | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 與 Toast 系統的差異
|
|
||||||
|
|
||||||
| 面向 | Toast | Server Offline Overlay |
|
|
||||||
|------|-------|----------------------|
|
|
||||||
| 呈現 | 螢幕上方 / 下方滑入的小卡片 | 全螢幕半透明覆蓋層 |
|
|
||||||
| 可關閉 | 可(點 ✕ 或自動 3-5 秒消失) | **不可關閉**(只能靠重試成功 / reload) |
|
|
||||||
| 可互動底層 UI | 可(Toast 不阻擋) | **不可**(底層全部 pointer-events: none) |
|
|
||||||
| 用途 | 成功 / 失敗的短暫通知 | 結構性錯誤,使用者必須知道且必須行動 |
|
|
||||||
| 觸發頻率 | 高 | 極低(只有 server 斷線) |
|
|
||||||
|
|
||||||
**設計原則**:Server 斷線代表「整個應用程式沒了」,這不是「提示」等級,是「必須正視」等級。Toast 無法傳達嚴重程度。Overlay 是唯一合理的呈現方式。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 無障礙考量
|
|
||||||
|
|
||||||
| 項目 | 設計 |
|
|
||||||
|------|------|
|
|
||||||
| ARIA role | `<div role="alertdialog" aria-modal="true" aria-labelledby="offline-title" aria-describedby="offline-desc">` |
|
|
||||||
| Focus trap | Overlay 顯示時焦點強制落在「重試連線」按鈕上,Tab 只能在重試 / 了解更多 兩顆按鈕之間循環 |
|
|
||||||
| ESC 鍵 | **不 dismiss**(硬阻斷),但不阻止 ESC(避免破壞 browser 原生 ESC 行為,例如退出全螢幕) |
|
|
||||||
| Screen reader | Overlay 顯示瞬間 announce 標題 + 副標(`aria-live="assertive"`) |
|
|
||||||
| 色彩對比 | 標題、副標、按鈕文字全部 ≥ 4.5:1(在半透明背景上也要達標,因此卡片背景必須是純色,不是半透明) |
|
|
||||||
| 鍵盤可達 | 只有兩個 interactive 元素(重試、了解更多),都可 Tab 聚焦 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 實作注意事項(給 Frontend)
|
|
||||||
|
|
||||||
### 9.1 放置位置
|
|
||||||
|
|
||||||
建議在 `frontend/src/app/layout.tsx` 掛一個全域 Provider:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<ServerHealthProvider>
|
|
||||||
<ServerOfflineOverlay />
|
|
||||||
{children}
|
|
||||||
</ServerHealthProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
`ServerHealthProvider` 負責:
|
|
||||||
- 啟動 10 秒 interval polling `/api/health`
|
|
||||||
- 暴露 `useServerHealth()` hook 給任何需要知道 server 狀態的元件
|
|
||||||
- 監聽 SSE / WebSocket 錯誤事件
|
|
||||||
|
|
||||||
`ServerOfflineOverlay` 訂閱 hook,狀態變 `offline` 時 render,變 `online` 時 unmount。
|
|
||||||
|
|
||||||
### 9.2 不要在 First-Run 前就觸發 Overlay 的誤報
|
|
||||||
|
|
||||||
使用者首次載入網頁時,First-Run wizard 還沒跑。如果 health check 剛好在 fetch 其他 `/api/...` 之前觸發,而那些 API 需要 DB 初始化,可能出現:health check 成功、但其他 API 還沒 ready。
|
|
||||||
|
|
||||||
**解法**:Overlay 只依賴 `/api/health` 的結果,不依賴其他 API。`/api/health` 在 server 啟動後立即 200,不等任何 DB init。
|
|
||||||
|
|
||||||
### 9.3 Restart Server 場景的特殊處理
|
|
||||||
|
|
||||||
雖然 R5-2 選了「關閉視窗 = 結束 server」,但 R5 三方共識仍保留「Restart Server」按鈕(控制台 Manage menu)。當使用者按 Restart 時:
|
|
||||||
|
|
||||||
1. Server 短暫 `Stopping` → `Starting` → `Running`,中間可能有 2-5 秒斷線
|
|
||||||
2. 瀏覽器偵測到 health check 失敗 → Overlay 顯示
|
|
||||||
3. 3 秒後 polling 發現 server 回來 → Overlay 自動消失
|
|
||||||
4. **使用者體感**:短暫看到 Overlay → 自動恢復 → 繼續用
|
|
||||||
|
|
||||||
這個行為**符合預期**,不需要特別處理。Overlay 對「暫時斷線」和「永久斷線」的區別透過 `polling 自動恢復 vs 使用者手動重開 app`自然分化。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 與 v1 的差異
|
|
||||||
|
|
||||||
| 面向 | v1 | v2 |
|
|
||||||
|------|----|----|
|
|
||||||
| Server 斷線 UX | **未定義** | 全螢幕 Overlay(新增) |
|
|
||||||
| 使用者教育 | 無 | 展開式 Help text,教重開 |
|
|
||||||
| 自動恢復 | 無 | 3 秒 polling,server 回來自動 dismiss |
|
|
||||||
| 文案 | 無 | 中英雙語完整版 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**下一步**:交 Frontend Agent 實作 `ServerHealthProvider` + `ServerOfflineOverlay` 元件。
|
|
||||||
@ -1,239 +0,0 @@
|
|||||||
# v2.5 — Settings 頁更新
|
|
||||||
|
|
||||||
> 本章對應 R5-4(Settings 新增自動開瀏覽器 toggle)+ R5-5a(砍 Mock 模式相關設定)+ R5-D2(Linux 預設 OFF)+ R5-D3(每次啟動都自動開)+ R5-E(階段化啟動進度)。
|
|
||||||
> 上層索引:`../design-spec-v2.md`
|
|
||||||
> 版本:**v2.1** · 更新日期:2026-04-14
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 變更摘要
|
|
||||||
|
|
||||||
Settings 維持 v1 的 **4 個分頁** 結構(R4 第四輪決策確認):
|
|
||||||
|
|
||||||
| Tab | v1 內容 | v2 變更 |
|
|
||||||
|-----|---------|---------|
|
|
||||||
| 一般 | 語言、深色模式狀態(唯讀) | **新增**「首次啟動時自動開啟瀏覽器」toggle |
|
|
||||||
| 硬體 | 真實 / Mock 切換、裝置掃描策略 | **刪除** Mock 相關,只保留裝置掃描策略 |
|
|
||||||
| 模型 | 預置模型清單、上傳、自訂 | 不變 |
|
|
||||||
| 進階 | 資料目錄、Python 雙策略、清 log 等 | 不變 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 「一般」分頁新增項目
|
|
||||||
|
|
||||||
### 2.1 結構
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─ Settings > 一般 ───────────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ 語言 │
|
|
||||||
│ ┌───────────────────────────┐ │
|
|
||||||
│ │ 繁體中文 ▾ │ │
|
|
||||||
│ └───────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 深色模式 │
|
|
||||||
│ 跟隨系統(目前:深色) │
|
|
||||||
│ │
|
|
||||||
│ ── 啟動行為 ──────────────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ 首次啟動時自動開啟瀏覽器 [■□] ON │
|
|
||||||
│ 啟動 visionA-local 時自動在預設瀏覽器開啟 Web UI。 │
|
|
||||||
│ 關閉此選項後,你需要手動點控制台的「在瀏覽器開啟」按鈕。 │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 Toggle 規格
|
|
||||||
|
|
||||||
| 屬性 | 值 |
|
|
||||||
|------|----|
|
|
||||||
| 元件類型 | `<Switch>`(shadcn) |
|
|
||||||
| **預設值(依平台)** | **macOS / Windows = `ON`**;**Linux = `OFF`**(R5-D2) |
|
|
||||||
| i18n key | `settings.general.autoOpenBrowser.label` / `settings.general.autoOpenBrowser.description` |
|
|
||||||
| 落地檔案 | **`preferences.json` 位於 `<dataDir>/`**(對齊 TDD v2)<br>路徑:<br>• macOS `~/Library/Application Support/visiona-local/preferences.json`<br>• Windows `%APPDATA%\visiona-local\preferences.json`<br>• Linux `~/.local/share/visiona-local/preferences.json` |
|
|
||||||
| 儲存機制 | **Go server 端負責讀寫**(`visiona-local/preferences.go`,非 Wails 內建機制 — Wails v2 沒有 settings store)<br>JSON 格式:`{"openBrowserOnStart": true}`<br>原子寫入:**write-rename pattern**(`os.WriteFile(path+".tmp", ...)` → `os.Rename(path+".tmp", path)`),POSIX / Windows 均原子 |
|
|
||||||
| 讀取失敗 fallback | 呼叫 `DefaultPreferences()`,依 `runtime.GOOS` 回傳平台預設:<br>`Preferences{OpenBrowserOnStart: runtime.GOOS != "linux"}` |
|
|
||||||
| 生效時機 | 立即生效,不需 restart app |
|
|
||||||
| 影響對象 | 控制台的「每次啟動自動開瀏覽器」邏輯(見 `v2/control-panel.md §7`) |
|
|
||||||
|
|
||||||
**Linux 首次使用說明(R5-D2 落地)**:
|
|
||||||
|
|
||||||
Linux 使用者第一次開 Wails 控制台時,Settings > 一般 的「啟動時自動開啟瀏覽器」toggle **預設是關的**。原因是 Linux 桌面環境差異大,`xdg-open` 在無頭環境 / i3 / xmonad / 極簡 WM 下行為可能異常(例如無預設瀏覽器、或開到錯的 DE session)。Linux 使用者若確認自己的環境可以正常 `xdg-open`,可手動打開此 toggle,下次啟動就會自動開瀏覽器。
|
|
||||||
|
|
||||||
關閉此 toggle 時,**啟動進度面板仍然會顯示**(見 `v2/startup-progress.md`),只是第 5 階段「開啟瀏覽器」會被標記為 `跳過(依偏好設定)/ Skipped (per preference)`,第 6 階段「等待 Web UI 連線」改為「等待使用者手動點擊『在瀏覽器開啟』」— 視覺上仍會收尾至 Running state,只是由使用者主動觸發第 5 階段(對齊 R5-E 階段化體驗)。
|
|
||||||
|
|
||||||
### 2.3 重要細節(R5-D3 已定案)
|
|
||||||
|
|
||||||
**這個 toggle 同時影響「首次」和「每次」啟動嗎?**
|
|
||||||
|
|
||||||
**R5-D3 決策**:**每次**啟動(含每次 `StartServer` 成功後)都會自動開瀏覽器,不是只有首次。R5-4 原字面「首次啟動」已於 R5-D3 修正。精確行為:
|
|
||||||
|
|
||||||
- **Toggle ON(macOS / Windows 預設)**:每次雙擊 app 或每次 Start Server 成功後,控制台自動呼叫 OS open browser
|
|
||||||
- **Toggle OFF(Linux 預設)**:啟動時只開控制台,不開瀏覽器;使用者自行點控制台的「在瀏覽器開啟」按鈕
|
|
||||||
- **Restart Server 情境**:因為 Restart 是同一個 Wails App process 內重啟(R5 雙 UI 架構下 Wails 不關),且啟動進度面板只在 Wails 剛啟動時顯示一次;Restart 時瀏覽器 tab 應由 Offline Overlay 自動重連,不會再開新 tab(避免開一堆重複 tab)
|
|
||||||
|
|
||||||
**Label 定版**:「啟動時自動開啟瀏覽器」/ "Auto-open browser on startup"(去掉「首次」二字)
|
|
||||||
|
|
||||||
| 版本 | label | description |
|
|
||||||
|------|-------|-------------|
|
|
||||||
| 原 R5-4 字面 | 首次啟動時自動開啟瀏覽器 | — |
|
|
||||||
| **最終採用(R5-D3 定案)** | 啟動時自動開啟瀏覽器 | 啟動 visionA-local 時自動在預設瀏覽器開啟 Web UI。關閉此選項後,你需要手動點控制台的「在瀏覽器開啟」按鈕。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 「硬體」分頁變更
|
|
||||||
|
|
||||||
### 3.1 刪除項目(R5-5a)
|
|
||||||
|
|
||||||
**所有 Mock 模式相關設定全砍**:
|
|
||||||
|
|
||||||
| v1 項目 | 說明 | v2 處理 |
|
|
||||||
|---------|------|---------|
|
|
||||||
| `模式:真實硬體 / Mock` radio | 切換整個 app 推論來源 | **刪除** |
|
|
||||||
| `Mock 模式描述` 說明文字 | 介紹 Mock 用途 | **刪除** |
|
|
||||||
| `Mock 模式假資料速率` slider | 調 Mock 輸出速率 | **刪除** |
|
|
||||||
| `Mock 模式裝置名稱` input | 自訂假裝置名 | **刪除** |
|
|
||||||
| `切換模式會重啟推論 session` 警告 | 切換時的提醒 | **刪除** |
|
|
||||||
|
|
||||||
### 3.2 保留項目
|
|
||||||
|
|
||||||
| 項目 | 說明 |
|
|
||||||
|------|------|
|
|
||||||
| 裝置自動掃描頻率 | 每次啟動時 / 手動 / 關閉 |
|
|
||||||
| USB 熱插拔偵測 toggle | 自動偵測新裝置插入 |
|
|
||||||
| Kneron KL520 / KL720 韌體版本顯示 | 唯讀 |
|
|
||||||
|
|
||||||
### 3.3 新增項目
|
|
||||||
|
|
||||||
無。R5-5a 砍 Mock 後這個 tab 變得更簡潔。
|
|
||||||
|
|
||||||
### 3.4 Tab 是否保留?
|
|
||||||
|
|
||||||
因為「硬體」tab 被清空後仍有內容(掃描頻率、熱插拔、韌體版本),**tab 結構保留**,不合併也不刪除。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 「模型」與「進階」分頁
|
|
||||||
|
|
||||||
**完全不動**。R5 沒有觸及這兩個 tab 的內容。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. i18n key 異動
|
|
||||||
|
|
||||||
### 5.1 新增 key
|
|
||||||
|
|
||||||
| Key | zh-TW | en |
|
|
||||||
|-----|-------|----|
|
|
||||||
| `settings.general.sectionStartup` | 啟動行為 | Startup behavior |
|
|
||||||
| `settings.general.autoOpenBrowser.label` | 啟動時自動開啟瀏覽器 | Auto-open browser on startup |
|
|
||||||
| `settings.general.autoOpenBrowser.description` | 啟動 visionA-local 時自動在預設瀏覽器開啟 Web UI。關閉此選項後,你需要手動點控制台的「在瀏覽器開啟」按鈕。 | Automatically open the Web UI in your default browser when visionA-local starts. When off, you need to manually click "Open in Browser" in the Control Panel. |
|
|
||||||
|
|
||||||
### 5.2 刪除 key
|
|
||||||
|
|
||||||
執行 grep 找出所有 mock 相關 key:
|
|
||||||
|
|
||||||
```
|
|
||||||
grep -rE "mock|Mock" frontend/src/lib/i18n/
|
|
||||||
```
|
|
||||||
|
|
||||||
預期刪除(實際清單以 grep 為準):
|
|
||||||
|
|
||||||
| Key | 原值(zh-TW) |
|
|
||||||
|-----|-------------|
|
|
||||||
| `settings.hardware.mode.label` | 模式 |
|
|
||||||
| `settings.hardware.mode.real` | 真實硬體 |
|
|
||||||
| `settings.hardware.mode.mock` | Mock |
|
|
||||||
| `settings.hardware.mode.description` | 選擇 visionA-local 的執行模式 |
|
|
||||||
| `settings.hardware.mock.rate.label` | Mock 速率 |
|
|
||||||
| `settings.hardware.mock.deviceName.label` | Mock 裝置名稱 |
|
|
||||||
| `settings.hardware.modeSwitchWarning` | 切換模式會重啟推論 session |
|
|
||||||
| `firstRun.mode.*` | 全部(見 `v2/first-run-update.md §5.2`) |
|
|
||||||
| `devices.mockBadge` | Mock | (若存在於 Devices 頁面的 Mock 標籤)
|
|
||||||
| `dashboard.tryMockHint` | 或切換到 Mock 模式試用 | (若 Dashboard 空狀態有這句)
|
|
||||||
|
|
||||||
### 5.3 type 檔同步
|
|
||||||
|
|
||||||
`frontend/src/lib/i18n/types.ts` 對應 interface 欄位全砍,確保 TypeScript 編譯不留 dangling reference。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 清理殘留的 Mock 程式碼
|
|
||||||
|
|
||||||
Settings 改完後,前後端其他地方可能仍有 Mock 殘留:
|
|
||||||
|
|
||||||
### 6.1 前端
|
|
||||||
- `frontend/src/stores/` 任何 `mock` state / action
|
|
||||||
- `frontend/src/components/` 任何 `Mock` badge / pill
|
|
||||||
- `frontend/src/app/devices/` 頁面右上的「真實 / Mock」切換(v1 `design-spec.md §第三輪修訂後仍待確認 4.` 提到)
|
|
||||||
|
|
||||||
### 6.2 後端
|
|
||||||
- Go server 任何 mock handler、假資料 generator
|
|
||||||
- Python sidecar 的 mock inference path
|
|
||||||
|
|
||||||
**這部分非 Design 的 scope**,但 Design 提醒 Orchestrator:**R5-5a 的「完全砍除」不只是 UI 層面,後端也要同步砍**。交 Architect Agent 評估實作影響。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 遷移策略(Migration)
|
|
||||||
|
|
||||||
**使用者情境**:v1 版本沒有 `preferences.json`;Mock 模式相關設定(若存在)是記在前端 localStorage 或 v1 舊式 config,不在 Go 端的 preferences 檔裡。v2 改用 Go 端 `preferences.json` 管理「是否啟動時自動開瀏覽器」,是**全新的檔案**。
|
|
||||||
|
|
||||||
**v2 首次讀取 preferences.json 流程**:
|
|
||||||
|
|
||||||
1. 檢查 `<dataDir>/preferences.json` 是否存在
|
|
||||||
2. **不存在** → 呼叫 `DefaultPreferences()`(依 `runtime.GOOS` 回傳平台預設:macOS/Windows `OpenBrowserOnStart=true`,Linux `OpenBrowserOnStart=false`)
|
|
||||||
3. **存在但解析失敗**(JSON 毀損等)→ 同樣 fallback 到 `DefaultPreferences()`,並發 log WARN(不擋啟動)
|
|
||||||
4. **存在且解析成功** → 使用使用者設定
|
|
||||||
5. 使用者在 Settings 點擊 toggle → write-rename atomic pattern 寫回 `preferences.json`
|
|
||||||
|
|
||||||
**v1 → v2 無 migration 成本**:因為 v1 沒有對應檔案,v2 啟動時若 `preferences.json` 不存在就建立新檔(或延遲到使用者第一次改 toggle 才寫),行為是**靜默**的,使用者不需要知道。
|
|
||||||
|
|
||||||
**Mock 模式殘留**:v1 localStorage 可能有 mock 相關 key,Frontend 清理見 §6.1;與 `preferences.json` 無關。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 無障礙考量
|
|
||||||
|
|
||||||
| 項目 | 設計 |
|
|
||||||
|------|------|
|
|
||||||
| Toggle 元件 | 沿用 shadcn `<Switch>`,已內建 `role="switch"` / `aria-checked` |
|
|
||||||
| Tab 切換 | 沿用現有 Tabs 元件,`role="tablist"` / `role="tab"` / `role="tabpanel"` |
|
|
||||||
| Keyboard | `←` / `→` 切換 tab、`Tab` 進入 tab panel 內部 |
|
|
||||||
| 分區標題 | 「啟動行為」區用 `<h3>` 讓 screen reader 感知結構 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 與 v1 差異對照
|
|
||||||
|
|
||||||
| 面向 | v1 | v2 |
|
|
||||||
|------|----|----|
|
|
||||||
| 一般 tab 項目數 | 2(語言、深色模式) | **3**(新增 auto-open browser) |
|
|
||||||
| 硬體 tab 項目數 | 5+(含 Mock 相關) | 3(只剩真實硬體相關) |
|
|
||||||
| Mock 模式 UI | Settings + Devices pill + First-Run Step | **全砍** |
|
|
||||||
| 模型 / 進階 tab | — | 不變 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. v2 → v2.1 Diff(2026-04-14)
|
|
||||||
|
|
||||||
| # | 位置 | v2 | v2.1 | 來源 |
|
|
||||||
|---|------|----|----|------|
|
|
||||||
| 1 | §2.2 落地檔案 | `settings.json` + 「走 Wails 既有 settings store」 | **`preferences.json` @ `<dataDir>/`** + Go server 讀寫 + write-rename atomic | Architect Review Major 1 |
|
|
||||||
| 2 | §2.2 預設值 | 三平台一致 `ON` | **macOS/Windows = `ON`,Linux = `OFF`**(依 `DefaultPreferences()` 依 `runtime.GOOS`) | R5-D2 / Architect Review Major 2 |
|
|
||||||
| 3 | §2.2 新增 | — | Linux 首次使用說明(toggle 預設關、關閉時啟動進度面板仍顯示、第 5/6 階段跳過)| R5-D2 + R5-E |
|
|
||||||
| 4 | §2.3 標題 | 「重要細節」待 confirm | **「R5-D3 已定案」每次啟動都開瀏覽器**,label 定版 | R5-D3 |
|
|
||||||
| 5 | §7 遷移策略 | 假設 v1 有 `settings.json` + mock key 遷移 | 澄清 `preferences.json` 是**全新檔**,無 v1 migration 成本 | Architect Review Major 1 衍生 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 給 Orchestrator 的提醒
|
|
||||||
|
|
||||||
1. ~~**R5-4 的 label 字面歧義**~~ → **已於 R5-D3 定案**(每次啟動都開,label 去掉「首次」)
|
|
||||||
2. **後端 Mock 清理**不在 Design scope,需轉給 Architect / Backend Agent
|
|
||||||
3. **Devices 頁面的 Mock pill**(v1 `design-spec.md` 第三輪修訂 §4)要一併砍,此變更未在本文件細寫,但屬 R5-5a 範圍
|
|
||||||
4. **Linux 預設 OFF 對 E2E 測試的影響**:testing Agent 在 Linux CI 上跑自動化時要注意,啟動後不會 auto-open,需手動模擬點擊「在瀏覽器開啟」或在測試前將 `preferences.json` 改為 `{"openBrowserOnStart": true}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**下一步**:交 Frontend Agent 更新 Settings 頁面。
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
# v2.3 — source-selector 修改規格
|
|
||||||
|
|
||||||
> 本章對應 R5(砍 URL 推論)+ R5-6(ffmpeg decoder 支援 mp4/avi/mov/mpeg/mpg)。
|
|
||||||
> 影響檔案:`frontend/src/components/camera/source-selector.tsx`、`frontend/src/stores/camera-store.ts`、`frontend/src/lib/i18n/zh-TW.ts`、`frontend/src/lib/i18n/en.ts`、`frontend/src/lib/i18n/types.ts`
|
|
||||||
> 上層索引:`../design-spec-v2.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 修改範圍摘要
|
|
||||||
|
|
||||||
**砍**:
|
|
||||||
- video tab 下的 `URL mode`(`videoMode === 'url'` 分支整塊)
|
|
||||||
- `videoMode` state 本身(因為 video tab 只剩單一 `file` 模式)
|
|
||||||
- `videoUrl` state、`handleUrlSubmit` handler
|
|
||||||
- `startFromUrl` store action 呼叫端與 store 內實作
|
|
||||||
- 「正在解析影片連結,YouTube 影片可能需要 10-30 秒…」的 hard-coded 紅字提示
|
|
||||||
- YouTube / Vimeo / RTSP 相關文案
|
|
||||||
- i18n keys:`camera.uploadFile`、`camera.pasteUrl`、`camera.urlPlaceholder`、`camera.urlHelpText`
|
|
||||||
|
|
||||||
**改**:
|
|
||||||
- `<input accept>` 從 `.mp4,.avi,.mov` → `.mp4,.avi,.mov,.mpeg,.mpg`
|
|
||||||
- dropzone filter(若有影片 drop 支援)同步
|
|
||||||
- i18n key `camera.mp4AviMov` 值從 `MP4, AVI, MOV` → `MP4, AVI, MOV, MPEG`
|
|
||||||
- Video tab 內只剩一個「選擇影片」按鈕 + 格式說明文字,無 sub-mode 切換
|
|
||||||
|
|
||||||
**保留**:
|
|
||||||
- `isUploading` state(上傳大檔仍需 loading)
|
|
||||||
- `uploadVideo` action
|
|
||||||
- `sourceFilename` 顯示
|
|
||||||
- Camera / Image tabs 的所有邏輯完全不動
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 新的 video tab UI 狀態表
|
|
||||||
|
|
||||||
### 2.1 結構(ASCII)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─ Video tab ────────────────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ [ 選擇影片 ] MP4, AVI, MOV, MPEG │
|
|
||||||
│ │
|
|
||||||
│ (可選)dropzone:拖放影片檔到此 │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 狀態對照表
|
|
||||||
|
|
||||||
| 狀態 | 條件 | 可見元素 | 按鈕狀態 |
|
|
||||||
|------|------|--------|---------|
|
|
||||||
| Idle(預設) | 非 streaming、非 uploading | `[選擇影片]` button + 格式說明 `MP4, AVI, MOV, MPEG` | enabled |
|
|
||||||
| Uploading | `isUploading === true` | `[上傳中...]` button(disabled)+ 格式說明 | disabled |
|
|
||||||
| Streaming | `isStreaming === true`(由父元件處理,此 tab 的內容被 `[停止影片]` 按鈕取代) | `[停止影片]` + 檔名 | — |
|
|
||||||
|
|
||||||
**注意**:Video tab 不再有 `Upload file` / `Paste URL` 兩顆 sub-mode 切換按鈕;使用者點 tab 後**直接就是上傳區**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. i18n key 異動清單
|
|
||||||
|
|
||||||
### 3.1 新增 key
|
|
||||||
|
|
||||||
| Key | zh-TW | en | 說明 |
|
|
||||||
|-----|-------|----|------|
|
|
||||||
| `camera.mp4AviMovMpeg` | MP4, AVI, MOV, MPEG | MP4, AVI, MOV, MPEG | 取代舊的 `mp4AviMov` |
|
|
||||||
|
|
||||||
(也可以選擇直接改舊 key 的值,見 §3.3 的建議)
|
|
||||||
|
|
||||||
### 3.2 刪除 key(zh-TW / en / types 三檔都要刪)
|
|
||||||
|
|
||||||
| Key | 原值(zh-TW) | 刪除理由 |
|
|
||||||
|-----|-------------|---------|
|
|
||||||
| `camera.uploadFile` | 上傳檔案 | Video tab 不再有 sub-mode 切換,`camera.selectVideo` 已足夠 |
|
|
||||||
| `camera.pasteUrl` | 貼上連結 | URL mode 砍除 |
|
|
||||||
| `camera.urlPlaceholder` | `https://example.com/video.mp4` | 同上 |
|
|
||||||
| `camera.urlHelpText` | 支援 YouTube、直接影片 URL(.mp4 等)及 RTSP 串流。 | 同上 |
|
|
||||||
|
|
||||||
### 3.3 修改 key(保留但更新值)
|
|
||||||
|
|
||||||
| Key | 原值(zh-TW / en) | 新值(zh-TW / en) |
|
|
||||||
|-----|-----------------|-----------------|
|
|
||||||
| `camera.mp4AviMov` | `MP4, AVI, MOV` / `MP4, AVI, MOV` | `MP4, AVI, MOV, MPEG` / `MP4, AVI, MOV, MPEG` |
|
|
||||||
|
|
||||||
**注意**:`key 名`雖然寫 `mp4AviMov`,但值改為包含 `MPEG`。保留 key 名避免 rename 造成其他地方引用失效。或者也可以 rename 為 `camera.supportedVideoFormats`,若 rename,type 檔也要同步。**建議 rename**,更語義化。
|
|
||||||
|
|
||||||
### 3.4 `camera.selectVideo` 保留
|
|
||||||
|
|
||||||
文字「選擇影片 / Select video」不變,仍用於 video tab 的主 CTA。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 程式碼變更預期(示意,實作以 Frontend Agent 為準)
|
|
||||||
|
|
||||||
### 4.1 `source-selector.tsx` diff 摘要
|
|
||||||
|
|
||||||
**刪除**(大約 50 行):
|
|
||||||
- `const [videoMode, setVideoMode] = useState<'file' | 'url'>('file');`
|
|
||||||
- `const [videoUrl, setVideoUrl] = useState('');`
|
|
||||||
- `const handleUrlSubmit = async () => { ... }`
|
|
||||||
- 從 store 解構 `startFromUrl`
|
|
||||||
- `{activeTab === 'video' && (...)}` 內的 sub-mode 切換按鈕 `[上傳檔案] / [貼上連結]`
|
|
||||||
- URL mode 的整個 JSX 分支(輸入框 + 「正在解析」提示 + `camera.urlHelpText`)
|
|
||||||
- `import { Input }` 如果其他地方沒用,連 import 一起砍
|
|
||||||
|
|
||||||
**修改**:
|
|
||||||
- Video tab 的結構從 `<div className="flex flex-col gap-2 w-full">` 內含兩個 mode 分支,改為直接 render file upload 區
|
|
||||||
- `<input accept=".mp4,.avi,.mov">` → `<input accept=".mp4,.avi,.mov,.mpeg,.mpg">`
|
|
||||||
- 格式說明文字改為 `t('camera.supportedVideoFormats')`(或維持 `mp4AviMov` key)
|
|
||||||
|
|
||||||
**最終 video tab JSX 預期樣貌**:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
{activeTab === 'video' && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
onClick={() => videoFileRef.current?.click()}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
{isUploading ? t('common.uploading') : t('camera.selectVideo')}
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t('camera.supportedVideoFormats')}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
ref={videoFileRef}
|
|
||||||
type="file"
|
|
||||||
accept=".mp4,.avi,.mov,.mpeg,.mpg"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleVideoSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 `camera-store.ts` diff 摘要
|
|
||||||
|
|
||||||
**刪除**:
|
|
||||||
- interface 中的 `startFromUrl: (url: string, deviceId: string) => Promise<void>;`
|
|
||||||
- 實作中的 `startFromUrl: async (url, deviceId) => { ... }` 整塊
|
|
||||||
|
|
||||||
**保留**:
|
|
||||||
- `uploadVideo` 不動
|
|
||||||
- `isUploading` state 不動
|
|
||||||
|
|
||||||
### 4.3 `i18n/zh-TW.ts`、`i18n/en.ts`、`i18n/types.ts`
|
|
||||||
|
|
||||||
三檔同步:
|
|
||||||
- 刪 `uploadFile`、`pasteUrl`、`urlPlaceholder`、`urlHelpText`
|
|
||||||
- 新增 `supportedVideoFormats`(或修改 `mp4AviMov` 值)
|
|
||||||
- types 檔移除對應 type 定義
|
|
||||||
|
|
||||||
### 4.4 其他可能殘留
|
|
||||||
|
|
||||||
執行 grep 檢查:
|
|
||||||
- `grep -r "startFromUrl" frontend/src/`
|
|
||||||
- `grep -r "pasteUrl" frontend/src/`
|
|
||||||
- `grep -r "youtube\|vimeo\|RTSP" frontend/src/`
|
|
||||||
|
|
||||||
若有殘留,一併清理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 無障礙考量
|
|
||||||
|
|
||||||
Video tab 變為單一按鈕後:
|
|
||||||
|
|
||||||
| 項目 | 設計 |
|
|
||||||
|------|------|
|
|
||||||
| Tab order | camera → image → video → (內部) 選擇影片 button |
|
|
||||||
| Button aria-label | `aria-label="選擇影片檔案,支援 MP4 AVI MOV MPEG"` |
|
|
||||||
| Focus ring | 沿用 Button 元件預設 focus ring |
|
|
||||||
| Screen reader | Button 點擊開啟 native file picker,macOS VoiceOver / Windows Narrator 會自動 announce file dialog |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 回歸測試重點(給 Testing Agent)
|
|
||||||
|
|
||||||
- [ ] Video tab 點擊後**直接**看到 `[選擇影片]` 按鈕,無 sub-mode 切換
|
|
||||||
- [ ] 支援上傳 `.mp4`、`.avi`、`.mov`、`.mpeg`、`.mpg` 五種副檔名
|
|
||||||
- [ ] 不支援上傳 `.mkv`、`.webm`、`.flv`(ffmpeg LGPL minimal build 不含這些 decoder)
|
|
||||||
- [ ] 上傳中顯示 `上傳中...`,按鈕 disabled
|
|
||||||
- [ ] 上傳完成後開始 inference,`sourceFilename` 顯示正確檔名
|
|
||||||
- [ ] 全站 grep 不到任何 `startFromUrl` / `pasteUrl` / `youtube` / `vimeo` 字串
|
|
||||||
- [ ] 兩個語系(zh-TW / en)都沒有 dead keys
|
|
||||||
- [ ] 舊版 i18n key 被刪後,TypeScript 編譯無 dangling reference
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 與 v1 差異對照
|
|
||||||
|
|
||||||
| 面向 | v1 | v2 |
|
|
||||||
|------|----|----|
|
|
||||||
| Video 來源類型 | file + URL(YouTube / Vimeo / RTSP / 直接 .mp4) | 僅 file |
|
|
||||||
| 支援副檔名 | `.mp4, .avi, .mov` | `.mp4, .avi, .mov, .mpeg, .mpg` |
|
|
||||||
| Sub-mode 切換 | 有(2 顆切換按鈕) | 無 |
|
|
||||||
| URL 輸入框 | 有 | **無** |
|
|
||||||
| 解析 URL 提示 | 有(「YouTube 影片可能需要 10-30 秒…」) | **無** |
|
|
||||||
| i18n key 數 | 含 4 個 URL 相關 key | 砍 4 個、新增 / 修改 1 個 |
|
|
||||||
| `startFromUrl` store action | 有 | **無** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**下一步**:交 Frontend Agent 按上述 spec 實作,完成後交 Reviewer 審查、Testing Agent 做回歸測試。
|
|
||||||
@ -1,417 +0,0 @@
|
|||||||
# v2.6 — 啟動進度面板(Startup Progress Panel)
|
|
||||||
|
|
||||||
> 本章對應 **R5-E1 ~ R5-E6**(perceived performance 階段化啟動)。
|
|
||||||
> 上層索引:`../design-spec-v2.md`
|
|
||||||
> 相關:`v2/control-panel.md §5`(狀態機 Starting state)、`v2/control-panel.md §7`(啟動流程)
|
|
||||||
> 版本:**v2.1**(新增章節)· 建立日期:2026-04-14
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 定位與動機
|
|
||||||
|
|
||||||
**問題**:v2 第一版把 AC-1.3 定為「10 秒上限」硬指標,但 Architect §11-2 分析估計樂觀 ~4 秒 / 悲觀 ~8 秒 / Windows + Defender 首次掃描最壞 ~11 秒。強求 10 秒會卡 Windows 使用者。
|
|
||||||
|
|
||||||
**使用者決策(R5-E)**:把問題從「要多快」翻轉成「**讓使用者感覺進度有在推動**」,採 Nielsen Norman *perceived performance* 原則 —
|
|
||||||
> 使用者能忍受 60 秒,只要每一秒都有視覺反饋。使用者不能忍受 10 秒,如果其中 8 秒是白畫面。
|
|
||||||
|
|
||||||
**本章職責**:設計 Starting state 時浮在 log panel 上方的**階段化啟動進度面板**,讓使用者知道:
|
|
||||||
- 現在在做什麼(階段編號 + 名稱 + 描述)
|
|
||||||
- 進度到哪裡(6 階段的哪一階,視覺進度條)
|
|
||||||
- 快 OK 了 vs 卡住了(超時提示)
|
|
||||||
- 出錯了怎麼辦(Error state 三個救援按鈕)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Wireframe
|
|
||||||
|
|
||||||
### 2.1 正常啟動中(階段 3「啟動本機伺服器」進行中)
|
|
||||||
|
|
||||||
```
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
│ ☑ Follow tail ☑ Show timestamps 🔍 [ Filter ... ] │
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 正在啟動 visionA-local · Starting visionA-local │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ✅ 1 · 初始化控制台 完成 │ │
|
|
||||||
│ │ Initializing control panel │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ✅ 2 · 檢查 Python 執行環境 完成 │ │
|
|
||||||
│ │ Checking Python runtime │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 🔄 3 · 啟動本機伺服器 (spinner) │ │
|
|
||||||
│ │ Starting local server... │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ⏳ 4 · 偵測 Kneron 裝置 等待中 │ │
|
|
||||||
│ │ Detecting Kneron devices │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ⏳ 5 · 開啟瀏覽器 等待中 │ │
|
|
||||||
│ │ Opening browser │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ⏳ 6 · 等待 Web UI 連線 等待中 │ │
|
|
||||||
│ │ Waiting for Web UI to connect │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 進度 3 / 6 │ │
|
|
||||||
│ └───────────────────────────────────────────────────────────────┘ │
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
│ 10:23:40 INFO HTTP server binding on 127.0.0.1:3721 │ ← log panel
|
|
||||||
│ 10:23:41 INFO wails ipc ready │ 照常顯示
|
|
||||||
│ ... │
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 階段卡超過 20 秒(Retry Hint,R5-E3)
|
|
||||||
|
|
||||||
```
|
|
||||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 正在啟動 visionA-local · Starting visionA-local │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ✅ 1 · 初始化控制台 完成 │ │
|
|
||||||
│ │ ✅ 2 · 檢查 Python 執行環境 完成 │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 🔄 3 · 啟動本機伺服器 (spinner) │ │
|
|
||||||
│ │ Starting local server... │ │
|
|
||||||
│ │ ⚠ 這個步驟花的時間比預期久,正在重試... │ │
|
|
||||||
│ │ This step is taking longer than expected, retrying... │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ⏳ 4 · 偵測 Kneron 裝置 等待中 │ │
|
|
||||||
│ │ ... │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 進度 3 / 6 · 已等待 22 秒 │ │
|
|
||||||
│ └───────────────────────────────────────────────────────────────┘ │
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 Error 狀態(R5-E4:60 秒總上限或任一階段失敗)
|
|
||||||
|
|
||||||
```
|
|
||||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ ❌ 啟動失敗 · Startup failed │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 啟動時間超過 60 秒,可能是系統環境異常或網路中斷。 │ │
|
|
||||||
│ │ Startup exceeded 60 seconds. Your environment may have │ │
|
|
||||||
│ │ issues or the network is interrupted. │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 失敗階段:3 · 啟動本機伺服器 │ │
|
|
||||||
│ │ Failed stage: 3 · Starting local server │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ [ 🔄 重試 Retry ] [ 📋 檢視 log View Log ] │ │
|
|
||||||
│ │ [ 🐞 回報問題 Report Issue (hold) ] │ │
|
|
||||||
│ └───────────────────────────────────────────────────────────────┘ │
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 元件規格
|
|
||||||
|
|
||||||
### 3.1 Panel 容器
|
|
||||||
|
|
||||||
| 屬性 | 值 |
|
|
||||||
|------|----|
|
|
||||||
| Root element | `<section role="progressbar" aria-valuemin="0" aria-valuemax="6" aria-valuenow="{currentStage}" aria-label="{i18n:startup.panel.ariaLabel}">` |
|
|
||||||
| 位置 | log controls 下方、log panel 上方(和 Error banner 同一個 slot) |
|
|
||||||
| 背景 | `color.surface-1` |
|
|
||||||
| 邊框 | 1 px `color.border` |
|
|
||||||
| 圓角 | `radius.md`(沿用 tokens) |
|
|
||||||
| Padding | 16 px |
|
|
||||||
| Max width | 控制台 content 寬度(約 688 px) |
|
|
||||||
| 進入動畫 | fade-in 200 ms(`prefers-reduced-motion` 時跳變) |
|
|
||||||
| 收尾動畫 | 所有階段完成後 fade-out 200 ms,之後 unmount |
|
|
||||||
|
|
||||||
### 3.2 Panel header
|
|
||||||
|
|
||||||
| 元素 | 類型 | 樣式 |
|
|
||||||
|------|------|------|
|
|
||||||
| 標題 | `<h2>` | 14 px SemiBold,`color.foreground` |
|
|
||||||
| 文字(zh-TW) | `正在啟動 visionA-local` | |
|
|
||||||
| 文字(en) | `Starting visionA-local` | |
|
|
||||||
|
|
||||||
**i18n key**:`startup.panel.title.zh` / `startup.panel.title.en`
|
|
||||||
|
|
||||||
### 3.3 階段列 `<StageItem>`
|
|
||||||
|
|
||||||
每個階段是一個獨立列,包含:
|
|
||||||
|
|
||||||
| 子元素 | 類型 | 內容 | 狀態變化 |
|
|
||||||
|------|------|------|---------|
|
|
||||||
| Icon(圓圈) | 20×20 px `<span>` | ✅(完成)/ 🔄(進行中,旋轉 spinner)/ ⏳(等待)/ ❌(失敗) | 見 §3.4 狀態對照 |
|
|
||||||
| 階段編號 | `<span>` | `{n}` `·` | 14 px Medium,muted-foreground |
|
|
||||||
| Label(雙語併呈) | `<div>` | 第一行:zh-TW 14 px;第二行:en 12 px muted-foreground | 見 §4 文案 |
|
|
||||||
| 狀態標籤 | `<span>` | 完成 / 進行中 / 等待中 / 失敗 | 右對齊 |
|
|
||||||
|
|
||||||
**範例結構**:
|
|
||||||
```html
|
|
||||||
<div class="stage-item" data-state="running">
|
|
||||||
<span class="stage-icon" aria-hidden="true">
|
|
||||||
<Spinner />
|
|
||||||
</span>
|
|
||||||
<span class="stage-number">3 ·</span>
|
|
||||||
<div class="stage-label">
|
|
||||||
<div class="stage-label-primary">啟動本機伺服器</div>
|
|
||||||
<div class="stage-label-secondary">Starting local server</div>
|
|
||||||
</div>
|
|
||||||
<span class="stage-status">進行中</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 階段狀態對照
|
|
||||||
|
|
||||||
| 資料狀態 | Icon | Icon 顏色 | Label 顏色 | 狀態文字(zh / en) |
|
|
||||||
|---------|------|----------|-----------|------------------|
|
|
||||||
| `pending` | ⏳ (outline circle) | `color.muted-foreground` | `color.muted-foreground` | `等待中` / `Waiting` |
|
|
||||||
| `running` | 🔄 旋轉 spinner 16 px | `color.primary` | `color.foreground` Bold | `進行中` / `Running` |
|
|
||||||
| `running-slow` | 🔄 旋轉 spinner + ⚠ 小圖示 | `color.warning` | `color.foreground` Bold | `正在重試...` / `Retrying...` |
|
|
||||||
| `done` | ✅ filled check | `color.success` | `color.muted-foreground`(略淡) | `完成` / `Done` |
|
|
||||||
| `failed` | ❌ filled cross | `color.destructive` | `color.destructive` Bold | `失敗` / `Failed` |
|
|
||||||
| `skipped` | ⏭ filled skip | `color.muted-foreground` | `color.muted-foreground` 斜體 | `跳過(依偏好設定)` / `Skipped (per preference)` |
|
|
||||||
|
|
||||||
**動畫**:
|
|
||||||
- pending → running:spinner fade-in 150 ms
|
|
||||||
- running → done:spinner → check icon 交叉淡入 200 ms;整行 label 漸變淡
|
|
||||||
- running → running-slow:⚠ icon slide-in-left 200 ms
|
|
||||||
- running → failed:spinner → ❌ icon,整行背景 `color.destructive/5` 淡入
|
|
||||||
- `prefers-reduced-motion: reduce` → 全部 fade 降為 0 ms 跳變,spinner 改為靜態點
|
|
||||||
|
|
||||||
### 3.5 進度條
|
|
||||||
|
|
||||||
| 屬性 | 值 |
|
|
||||||
|------|----|
|
|
||||||
| 類型 | 6 格離散進度條(不是連續 bar),每格對應一階段 |
|
|
||||||
| 已完成格 | `color.success` 填滿 |
|
|
||||||
| 進行中格 | `color.primary` 填滿 + 脈衝動畫(opacity 0.6 ↔ 1.0, 1.5 s) |
|
|
||||||
| 等待中格 | `color.border` 空格 |
|
|
||||||
| 失敗格 | `color.destructive` 填滿 |
|
|
||||||
| 高度 | 6 px |
|
|
||||||
| 格間距 | 2 px |
|
|
||||||
| 附加文字 | 右側 `進度 {current} / 6` + 卡超 20 秒時附 `· 已等待 {elapsed} 秒` |
|
|
||||||
|
|
||||||
ARIA:`<div role="progressbar" aria-valuenow="{current}" aria-valuemax="6" aria-label="啟動進度">`
|
|
||||||
|
|
||||||
### 3.6 Retry Hint(R5-E3)
|
|
||||||
|
|
||||||
當任一階段 `running` 狀態超過 **20 秒**未變 `done`,該 StageItem 下方浮出 hint line:
|
|
||||||
|
|
||||||
| 屬性 | 值 |
|
|
||||||
|------|----|
|
|
||||||
| 觸發 | `stage.state === 'running' && (now - stage.startedAt) > 20_000ms` |
|
|
||||||
| 隱藏 | 階段狀態變 `done` 或 `failed` 後隨 StageItem 一起消失 |
|
|
||||||
| 顏色 | `color.warning` text |
|
|
||||||
| Icon | ⚠ 14×14 |
|
|
||||||
| 雙語 | 第一行中文、第二行英文(12 px muted) |
|
|
||||||
|
|
||||||
**文案**:
|
|
||||||
- zh:`這個步驟花的時間比預期久,正在重試...`
|
|
||||||
- en:`This step is taking longer than expected, retrying...`
|
|
||||||
|
|
||||||
**i18n key**:`startup.timeout.message.zh` / `startup.timeout.message.en`
|
|
||||||
|
|
||||||
### 3.7 Error State(R5-E4)
|
|
||||||
|
|
||||||
任一階段 `failed` 或總計超過 **60 秒** → Panel 整體換為 Error mode:
|
|
||||||
|
|
||||||
- StageItem 列表隱藏(只保留失敗的那一階段顯示為 ❌)
|
|
||||||
- 進度條換成 Error 樣式(整條 `color.destructive/20` 背景)
|
|
||||||
- 大標題 `啟動失敗 / Startup failed`
|
|
||||||
- 說明文字(雙語)
|
|
||||||
- 三顆按鈕:
|
|
||||||
|
|
||||||
| 按鈕 | 類型 | 行為 |
|
|
||||||
|------|------|------|
|
|
||||||
| 🔄 重試 / Retry | Button `primary` `md` | 重置進度面板,重新跑階段 1 |
|
|
||||||
| 📋 檢視 log / View Log | Button `ghost` `md` | 收起 panel,focus 到 log panel 最後一條 ERROR 行,flash 2 次 |
|
|
||||||
| 🐞 回報問題 / Report Issue **【hold】** | Button `ghost` `md` | **現階段不實作**(待 PM 提供 GitHub Issue repo URL) |
|
|
||||||
|
|
||||||
**R5-D1 OS 原生通知並存**:進入 Error state 時同時呼叫 `sendCrashNotification()` 發 OS non-blocking toast(和 `control-panel.md §6.2` 的 Error banner 一致)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 6 階段文字定版(R5-E5 定稿)
|
|
||||||
|
|
||||||
| # | 階段 | Label (zh-TW) | Label (en) | Description (zh-TW) | Description (en) | 完成條件(技術訊號) |
|
|
||||||
|---|------|-------------|------------|--------------------|------------------|------------------|
|
|
||||||
| 1 | 初始化控制台 | 初始化控制台 | Initializing control panel | 準備 visionA-local 桌面環境 | Preparing visionA-local desktop | Wails `OnStartup` 完成、i18n 載入、面板 mount |
|
|
||||||
| 2 | 檢查 Python 執行環境 | 檢查 Python 執行環境 | Checking Python runtime | 首次啟動可能需要較長時間 | First launch may take longer | `ensurePythonRuntime()` 回傳 + 驅動檢查通過 |
|
|
||||||
| 3 | 啟動本機伺服器 | 啟動本機伺服器 | Starting local server | 在 127.0.0.1:3721 啟動服務 | Starting service on 127.0.0.1:3721 | `/api/health` 回 200(首次成功) |
|
|
||||||
| 4 | 偵測 Kneron 裝置 | 偵測 Kneron 裝置 | Detecting Kneron devices | 掃描已連接的硬體 | Scanning connected hardware | Go server 回傳 devices scan 結果(不論有無裝置都算成功) |
|
|
||||||
| 5 | 開啟瀏覽器 | 開啟瀏覽器 | Opening browser | 在預設瀏覽器開啟 Web UI | Opening the Web UI in your default browser | `OpenInBrowser()` 呼叫完成(不等瀏覽器實際開好) |
|
|
||||||
| 6 | 等待 Web UI 連線 | 等待 Web UI 連線 | Waiting for Web UI to connect | 正在與瀏覽器建立即時連線 | Establishing realtime connection with the browser | **WebSocket hub 收到第一個 client 連線**(R5-E6) |
|
|
||||||
|
|
||||||
### 4.1 特殊文案
|
|
||||||
|
|
||||||
**階段 2 首次啟動提示(常態)**:
|
|
||||||
- Description 固定顯示「首次啟動可能需要較長時間 / First launch may take longer」
|
|
||||||
- 這不是超時 hint,是預設的 description,讓使用者看到就不焦慮(即使只花 0.5 秒也顯示)
|
|
||||||
|
|
||||||
**階段 5 Linux / Settings OFF 情境**:
|
|
||||||
- 狀態直接從 `pending` 跳到 `skipped`
|
|
||||||
- 狀態文字顯示「跳過(依偏好設定)/ Skipped (per preference)」
|
|
||||||
- Icon 用 ⏭
|
|
||||||
- 進度條該格仍算推進(不當失敗,不擋住階段 6)
|
|
||||||
|
|
||||||
**階段 6 Settings OFF 情境**:
|
|
||||||
- 狀態從 `pending` 跳到 `running`,label description 改為
|
|
||||||
- zh:`請點擊控制台的「在瀏覽器開啟」按鈕`
|
|
||||||
- en:`Please click "Open in Browser" in the Control Panel`
|
|
||||||
- 當使用者手動點 Open in Browser 並成功建立 WebSocket 連線後 → `done`
|
|
||||||
- **不套 20 秒 retry hint**(因為是等待人為動作,不是系統卡住)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 成功狀態收尾(Running state 轉場)
|
|
||||||
|
|
||||||
當階段 6 狀態變 `done`:
|
|
||||||
|
|
||||||
1. 進度條最後一格填滿 `color.success`,脈衝動畫停止
|
|
||||||
2. 停留 500 ms 讓使用者看到「全綠」
|
|
||||||
3. 整個 Panel fade-out 200 ms
|
|
||||||
4. Panel unmount
|
|
||||||
5. 控制台 Status 區域變 `Running · Browser opened`(Toggle ON 首次)或純 `Running`
|
|
||||||
6. Primary controls 啟用(Open in Browser 等)
|
|
||||||
|
|
||||||
**總轉場時間**:約 700 ms(500 ms 停留 + 200 ms fade)。
|
|
||||||
|
|
||||||
**`prefers-reduced-motion: reduce`**:省略 500 ms 停留 + 200 ms fade,直接 unmount。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 無障礙考量
|
|
||||||
|
|
||||||
| 項目 | 設計 |
|
|
||||||
|------|------|
|
|
||||||
| **Role** | Panel root `<section role="progressbar" aria-valuemin="0" aria-valuemax="6" aria-valuenow="{current}">` |
|
|
||||||
| **Live region** | Panel 下加 `<div class="sr-only" aria-live="polite" aria-atomic="true">` 宣告階段變化:`階段 {n}:{label},{status}`(zh)或 `Stage {n}: {label}, {status}`(en) |
|
|
||||||
| **Focus trap** | Panel 顯示期間**不 trap focus**(Starting state 使用者不應該需要操作 panel 以外的元素,但允許使用者切換視窗或捲 log panel) |
|
|
||||||
| **Keyboard** | `⌘0` / `Ctrl+0` focus 進度面板第一個可聚焦元素(Retry 按鈕或 panel root)<br>`Esc`:若 panel 已進入 Error state → 不動作(避免誤關);若進度已跑完正在 fade-out → 立即 unmount |
|
|
||||||
| **色彩對比** | Stage label / Description / 狀態文字 ≥ 4.5:1;failed / running-slow hint ≥ 4.5:1(critical 信號不妥協,對齊 control-panel §10) |
|
|
||||||
| **Reduced motion** | 所有 fade / spinner / 脈衝動畫降為 0 ms 跳變;spinner 改為靜態點;Retry hint 直接顯示不滑入 |
|
|
||||||
| **字級可縮放** | 使用 `rem` 定義字級 |
|
|
||||||
| **Icon 替代文字** | 每個 icon 有 `aria-hidden="true"`(狀態透過 live region 宣告),或 `role="img" aria-label="..."` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. i18n key 清單
|
|
||||||
|
|
||||||
全部加到 `desktop-control` namespace 的 `startup.*` 子樹:
|
|
||||||
|
|
||||||
| Key | zh-TW | en |
|
|
||||||
|-----|-------|----|
|
|
||||||
| `startup.panel.title` | 正在啟動 visionA-local | Starting visionA-local |
|
|
||||||
| `startup.panel.ariaLabel` | 啟動進度:階段 {current} / {max} | Startup progress: stage {current} / {max} |
|
|
||||||
| `startup.progressLabel` | 進度 {current} / {max} | Progress {current} / {max} |
|
|
||||||
| `startup.progressWithElapsed` | 進度 {current} / {max} · 已等待 {elapsed} 秒 | Progress {current} / {max} · {elapsed}s elapsed |
|
|
||||||
| `startup.stage.1.label` | 初始化控制台 | Initializing control panel |
|
|
||||||
| `startup.stage.1.description` | 準備 visionA-local 桌面環境 | Preparing visionA-local desktop |
|
|
||||||
| `startup.stage.2.label` | 檢查 Python 執行環境 | Checking Python runtime |
|
|
||||||
| `startup.stage.2.description` | 首次啟動可能需要較長時間 | First launch may take longer |
|
|
||||||
| `startup.stage.3.label` | 啟動本機伺服器 | Starting local server |
|
|
||||||
| `startup.stage.3.description` | 在 127.0.0.1:{port} 啟動服務 | Starting service on 127.0.0.1:{port} |
|
|
||||||
| `startup.stage.4.label` | 偵測 Kneron 裝置 | Detecting Kneron devices |
|
|
||||||
| `startup.stage.4.description` | 掃描已連接的硬體 | Scanning connected hardware |
|
|
||||||
| `startup.stage.5.label` | 開啟瀏覽器 | Opening browser |
|
|
||||||
| `startup.stage.5.description` | 在預設瀏覽器開啟 Web UI | Opening the Web UI in your default browser |
|
|
||||||
| `startup.stage.5.skipped.label` | 跳過(依偏好設定) | Skipped (per preference) |
|
|
||||||
| `startup.stage.6.label` | 等待 Web UI 連線 | Waiting for Web UI to connect |
|
|
||||||
| `startup.stage.6.description` | 正在與瀏覽器建立即時連線 | Establishing realtime connection with the browser |
|
|
||||||
| `startup.stage.6.manualHint` | 請點擊控制台的「在瀏覽器開啟」按鈕 | Please click "Open in Browser" in the Control Panel |
|
|
||||||
| `startup.status.pending` | 等待中 | Waiting |
|
|
||||||
| `startup.status.running` | 進行中 | Running |
|
|
||||||
| `startup.status.done` | 完成 | Done |
|
|
||||||
| `startup.status.failed` | 失敗 | Failed |
|
|
||||||
| `startup.status.skipped` | 跳過(依偏好設定) | Skipped (per preference) |
|
|
||||||
| `startup.timeout.message` | 這個步驟花的時間比預期久,正在重試... | This step is taking longer than expected, retrying... |
|
|
||||||
| `startup.error.title` | 啟動失敗 | Startup failed |
|
|
||||||
| `startup.error.description.timeout` | 啟動時間超過 60 秒,可能是系統環境異常或網路中斷。 | Startup exceeded 60 seconds. Your environment may have issues or the network is interrupted. |
|
|
||||||
| `startup.error.description.stageFailed` | 階段「{stageLabel}」執行失敗。 | Stage "{stageLabel}" failed. |
|
|
||||||
| `startup.error.failedStage` | 失敗階段:{n} · {label} | Failed stage: {n} · {label} |
|
|
||||||
| `startup.error.retry` | 重試 | Retry |
|
|
||||||
| `startup.error.viewLog` | 檢視 log | View Log |
|
|
||||||
| `startup.error.report` | 回報問題 | Report Issue |
|
|
||||||
| `startup.liveRegion.stageUpdate` | 階段 {n}:{label},{status} | Stage {n}: {label}, {status} |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 資料模型(交給 Architect / Frontend 參考)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type StageState =
|
|
||||||
| 'pending'
|
|
||||||
| 'running'
|
|
||||||
| 'running-slow' // UI 派生狀態:running 且 elapsed > 20s
|
|
||||||
| 'done'
|
|
||||||
| 'failed'
|
|
||||||
| 'skipped';
|
|
||||||
|
|
||||||
interface Stage {
|
|
||||||
id: 1 | 2 | 3 | 4 | 5 | 6;
|
|
||||||
state: StageState;
|
|
||||||
startedAt: number | null; // epoch ms
|
|
||||||
finishedAt: number | null;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StartupProgressState {
|
|
||||||
currentStage: 1 | 2 | 3 | 4 | 5 | 6 | 'done' | 'error';
|
|
||||||
stages: Record<1 | 2 | 3 | 4 | 5 | 6, Stage>;
|
|
||||||
startedAt: number; // 整個啟動流程開始時間
|
|
||||||
totalElapsedMs: number; // 從 startedAt 算起
|
|
||||||
errorReason?: 'timeout' | 'stageFailed';
|
|
||||||
failedStageId?: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**訊號來源(給 Architect 看)**:
|
|
||||||
- 階段 1 完成:Wails `OnStartup` 回呼結束 + 面板 mount 事件
|
|
||||||
- 階段 2 完成:Go `ensurePythonRuntime()` 回傳 + driver check OK
|
|
||||||
- 階段 3 完成:`/api/health` 首次回 200
|
|
||||||
- 階段 4 完成:Go server 裝置掃描回傳(有 / 無 / 錯誤都算)
|
|
||||||
- 階段 5 完成:`OpenInBrowser()` 呼叫 return(或 `skipped` 若 toggle 關)
|
|
||||||
- 階段 6 完成:WebSocket hub 收到第一個 client 連線事件(**R5-E6**)
|
|
||||||
|
|
||||||
每個階段的 `startedAt` 由前端 React 在「前一階段變 done 時」設定當下時間。`totalElapsedMs` 每秒更新用於超時判斷。
|
|
||||||
|
|
||||||
**60 秒總計時 timer**:
|
|
||||||
- Panel mount 時 `setTimeout(fireError, 60_000)`
|
|
||||||
- 每個階段 `done` 時不清 timer
|
|
||||||
- 只要最後階段(6)`done` 前 timer 觸發 → 進 Error mode(reason: `timeout`)
|
|
||||||
- 階段 6 `done` 時 `clearTimeout`
|
|
||||||
|
|
||||||
**20 秒階段卡頓 timer**:
|
|
||||||
- 每個階段變 `running` 時 `setTimeout(markSlow, 20_000)`
|
|
||||||
- 階段變 `done` 或 `failed` 時 `clearTimeout`
|
|
||||||
- 觸發時 `stage.state` 不真的改成 `running-slow`(仍是 `running`),而是 UI 層根據 `now - startedAt > 20_000` 派生出 `running-slow` 樣式和 hint line
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 開發備忘(交給 Frontend / Architect)
|
|
||||||
|
|
||||||
1. **技術實作位置**:本面板跑在 Wails 控制台前端(vanilla HTML/JS/CSS,非 Next.js),路徑約 `visiona-local/frontend/` 下
|
|
||||||
2. **狀態同步**:Go 端每個階段完成時透過 `wailsRuntime.EventsEmit('startup:stage', {id, state})`;前端 listen 後 setState 並更新 panel
|
|
||||||
3. **Go 端需新增事件**:
|
|
||||||
- `startup:stage` payload `{id: 1..6, state: 'running'|'done'|'failed'|'skipped', errorMessage?: string}`
|
|
||||||
- 最終 `startup:complete` 或 `startup:error` 作為 panel 淡出訊號
|
|
||||||
4. **階段 6 WebSocket 訊號**:需要在 Go WebSocket hub 的 `OnConnect` 事件中,若是**這個 process 生命週期的第一次 client 連線**,emit `startup:stage {id: 6, state: 'done'}`;後續連線不再 emit
|
|
||||||
5. **階段 5 Linux 跳過時**:Go 端若讀到 `Preferences.OpenBrowserOnStart == false`,emit `startup:stage {id: 5, state: 'skipped'}`
|
|
||||||
6. **Architect 待補 TDD**:本面板需 TDD 對應章節(建議位於 `04-architecture/v2/startup-progress.md` 或併入 `control-panel.md`)落地事件協議 + Go 端 stage emitter 位置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 與 v2 Starting state 的差異對照
|
|
||||||
|
|
||||||
| 面向 | v2(第一版) | v2.1 |
|
|
||||||
|------|-------------|------|
|
|
||||||
| Starting 視覺 | 只有 header 一顆 spinner + `Starting...` 文字 | 浮出 6 階段進度面板,每階段有 label + description + 狀態 icon |
|
|
||||||
| 時間上限 | 5 秒超時進 Error(v1 寫死) | **60 秒總上限**(R5-E1),任一階段 20 秒進 Retry hint(R5-E3) |
|
|
||||||
| 使用者可見性 | 零(只有「啟動中」三字) | 每階段明確的中英雙語 label + description |
|
|
||||||
| Error state 入口 | 一律從 watchServer 失敗 | 新增「60 秒 timeout」與「階段失敗」兩條路徑,都走 Error mode |
|
|
||||||
| 無障礙 | 基本 `aria-live` | 新增 `role="progressbar"` + `aria-valuenow` + live region 逐階段宣告 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 懸而未決問題(交 Orchestrator)
|
|
||||||
|
|
||||||
1. **20 秒 retry hint 的「正在重試」字面**:目前文案寫「正在重試...」(retrying),但實際上 Go 端不一定真的在 retry,可能只是單純慢。是否改為較中性的「正在處理中,請稍候... / Still working, please wait...」?Design 建議維持「正在重試」—— 使用者對「retry」比「still working」更有信心感,即使技術上沒在 retry,心理預期是「系統知道有問題、在努力中」。待使用者最終確認。
|
|
||||||
2. **階段 6 WebSocket 連線訊號失敗的情境**:若 OS open browser 成功但使用者的預設瀏覽器被某些安全軟體攔截(Windows Defender SmartScreen 或家長控制),階段 6 永遠等不到 WebSocket → 60 秒後進 Error mode。此時 Error 說明是否應該特別提示「請檢查瀏覽器是否被安全軟體攔截」?Design 建議:不做特殊偵測,通用說明 + 看 log details 即可,否則要辨識各種安全軟體失敗 pattern,過度設計。
|
|
||||||
3. **階段卡頓超過 20 秒仍未完成,使用者按 Retry 的語意**:是「重置整個啟動流程」(從階段 1 開始)還是「重試當前階段」(從當前階段重跑)?Design 建議:**重置整個啟動流程**(簡單明確、使用者心智模型一致),Go 端只需要暴露一個 `RestartStartupSequence()` API。待 Architect 確認可行。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**下一步**:交 Architect 審閱技術落地(事件協議、WebSocket 訊號、Go 端 stage emitter 位置)、交 M8-5 Frontend Agent 實作 Wails 控制台啟動進度面板。
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
# TDD v2 — visionA-local(Round-2 refactor 後)
|
|
||||||
|
|
||||||
> 作者:Architect Agent
|
|
||||||
> 版本:**v2.1**
|
|
||||||
> 日期:2026-04-14(v2.0 → v2.1 吸收 PM 審閱 + R5-D + R5-E)
|
|
||||||
> 狀態:Draft(v2.1 由 Architect 根據 PM 互審 + R5-D/E 新決策產出,待三方再次 cross-review + 使用者確認)
|
|
||||||
> 取代:`TDD.md` v1.0(2026-04-11,四輪修訂 + Plan B 補件)
|
|
||||||
> 決策來源:`progress.md` §「R5 第五輪使用者決策」+ 「R5-D 補充決策」+「R5-E 階段化啟動新指標」(2026-04-14)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 版本資訊與變更摘要
|
|
||||||
|
|
||||||
### 0.0 v2.0 → v2.1 差異速覽(2026-04-14 本次更新)
|
|
||||||
|
|
||||||
| # | 變更面向 | v2.0 | v2.1 | 觸發 |
|
|
||||||
|---|---------|------|------|------|
|
|
||||||
| 1 | **AC-1.3 啟動時間** | 10 秒硬指標 | **60 秒 + 6 階段進度顯示**(20 s soft timeout / 60 s hard timeout)| R5-E1~E6 |
|
|
||||||
| 2 | **自動開瀏覽器行為** | 首次啟動開一次(`autoOpenedThisSession` flag 控制 per-session-once)| **每次 Start/Restart 成功都開**(砍 flag;R5-D3)| R5-D3 |
|
|
||||||
| 3 | **AutoOpenBrowser 預設值** | 三平台統一 true | **macOS/Windows true、Linux false**(分平台 default)| R5-D2 |
|
|
||||||
| 4 | **Server 崩潰通知** | 僅控制台 Error banner | **Error banner + OS 原生通知並存**(`notify.go` 三平台實作)| R5-D1 |
|
|
||||||
| 5 | **Shutdown grace period** | 5 s → 10 s(Architect Q4 提議)| **7 s + 1 s 顯示「停止中…」modal**(PM Q4 定案)| PM Q4 |
|
|
||||||
| 6 | **Shutdown race condition** | 靠 polling 3 次失敗(0.5-15 s 後顯示 overlay) | **WebSocket `server:shutdown-imminent` 廣播**(秒內觸發 overlay)| PM Minor 4 |
|
|
||||||
| 7 | **Restart port 處理** | 允許 fallback(3721 → 3722 …)| **強制保留舊 port**,被佔用進 Error state | Architect F-2 |
|
|
||||||
| 8 | **Preferences 持久化** | 提及 atomic write 但未定位置 | **`<dataDir>/preferences.json` + write-rename**(完整 spec)| PM §11-1 |
|
|
||||||
| 9 | **`videoIsURL` field 處置** | 「視情況保留或刪除」 | **明確刪除**(grep 證實是 dead code)| PM Minor 5 |
|
|
||||||
| 10 | **idle RAM 450 MB 目標** | 未澄清範圍 | **澄清不含瀏覽器 tab**(只計 Wails + Go server + Python)| PM §11-3 |
|
|
||||||
| 11 | **日常啟動時間估算** | 無 | **新增估算 ~3.8 s**,符合 AC-2.1 ≤ 5 s | PM Minor 2 |
|
|
||||||
| 12 | **boot-id 生成方式** | 建議 google/uuid | **用 `crypto/rand` 16 bytes → hex**(不引依賴)| Architect Q3 |
|
|
||||||
| 13 | **navigator.language fallback** | 基本 startsWith('zh') | **強化:處理 C/POSIX/空字串 + hardcoded 英文 fallback** | Architect Q5 |
|
|
||||||
| 14 | **milestone 數量** | M8-1 ~ M8-10(10 個)| **+M8-4b 階段化啟動**(11 個) | R5-E |
|
|
||||||
| 15 | **總工時估算** | ~10 人天 | **~12 人天**(R5-E +1 天、R5-D1 +0.3 天、PM Q4 +0.2 天、Minor 4 +0.3 天 + 其他 0.2 天)| 以上 |
|
|
||||||
| 16 | **新增檔案** | — | **`v2/startup-pipeline.md`**(R5-E 實作細節) | R5-E |
|
|
||||||
|
|
||||||
**未變更**(v2.0 其餘設計保持不動):Wails 視窗 = 控制台 UI、瀏覽器 tab 載業務、yt-dlp/Mock 砍除、ffmpeg LGPL 方案 B、CORS 白名單、boot-id 機制、LogBuffer 2000 行 ring buffer、state machine 5 個狀態。
|
|
||||||
|
|
||||||
### 0.1 v1 → v2 差異速覽
|
|
||||||
|
|
||||||
| 面向 | v1(2026-04-11) | v2(2026-04-14) | 觸發決策 |
|
|
||||||
|------|-----------------|-----------------|---------|
|
|
||||||
| **Wails 視窗載入內容** | Splash → `window.location.replace` 跳到 Next.js 主 UI(M7-B)| Splash 路徑完全移除,Wails 永遠停在「控制台 UI」:server 狀態 / log panel / 啟停控制 / Open in Browser | R5-1 A+B+G |
|
|
||||||
| **使用者業務 UI 承載者** | Wails WebView(M7-B 是純 HTTP,但仍在 WebView 內)| **瀏覽器 tab**(Chrome / Safari / Edge)`http://127.0.0.1:<port>/` | R5-1 |
|
|
||||||
| **yt-dlp 全套** | 保留(M6 vendor 35 MB + handler + 前端 URL tab)| **完全砍除**(vendor + handler + frontend UI + i18n + bootstrap + installer payload) | R5-7 前置;M8-1 |
|
|
||||||
| **Mock 模式** | 保留(`--mock` flag + `VISIONA_MOCK` env + UI hint + 兩份 i18n) | **完全砍除**(Go mock driver / mock camera / env var / flag / UI 元件 / i18n keys) | R5-5a |
|
|
||||||
| **ffmpeg 授權** | GPL(evermeet / BtbN / johnvansickle),`VISIONA_ALLOW_GPL_FFMPEG=1` release blocker | **LGPL 方案 B(混合)**:Win/Linux 換 BtbN LGPL;macOS 自 build decoder-only ~20 MB,binary commit 到 `vendor/ffmpeg/macos/` | R5-6 / R5-6a / R5-6b |
|
|
||||||
| **ffprobe** | 未打包 | **三平台都一起包**(ffmpeg + ffprobe) | R5-6c |
|
|
||||||
| **Tray** | 第三輪 Q-A 砍掉 | 維持砍,不復議 | R5-3 |
|
|
||||||
| **關閉視窗行為** | Q7=B 傳統式(關 = 結束 app)| 維持 Q7=B,但新增:關閉前先 `StopServer()` 優雅結束;瀏覽器 tab 偵測 server 離線後顯示全域 Offline Overlay | R5-2 |
|
|
||||||
| **Server 控制 bindings** | 只有 `GetServerStatus` / `GetServerURL` / `OpenBrowser`(隱式 start)| 補齊 `StartServer` / `StopServer` / `RestartServer` / `GetRecentLogs` / `ClearLogs` / `GetSystemInfo` 及完整 state machine | R5-1 |
|
|
||||||
| **watchServer 失敗行為** | 3 次失敗 → `reportFatal` + `os.Exit(1)`(app 一起死) | 3 次失敗 → 切 `ServerStateError`,Wails app 保留讓使用者手動 Restart 或查 log | 三方共識 #10 |
|
|
||||||
| **自動開瀏覽器** | — | 首次 server 就緒自動開一次;Settings 的 `openBrowserOnStart` 可關 | R5-4 |
|
|
||||||
| **CORS 政策** | 寬鬆(`Access-Control-Allow-Origin: <任意 Origin>`)| 嚴格 whitelist `http://127.0.0.1:*` + `http://localhost:*`;其他 Origin → 不回 ACAO header / OPTIONS 405 | 三方共識 #5 |
|
|
||||||
| **綁定 interface** | `--host 127.0.0.1` | 維持不動(不做 LAN) | R5-1 |
|
|
||||||
| **上傳影片副檔名** | `.mp4 / .avi / .mov` | `.mp4 / .avi / .mov / .mpeg / .mpg`(瀏覽器能吃 + Kneron pipeline 吃得到的交集) | 三方共識 #11 |
|
|
||||||
| **Boot-ID 機制** | 無 | 新增 `GET /api/system/boot-id`,server 啟動時產生 UUID;瀏覽器每 5 s poll,boot-id 變更 → force reload | 三方共識 #14 |
|
|
||||||
| **控制台 UI 技術選型** | — | vanilla HTML/JS/CSS,從現有 `visiona-local/frontend/` splash 改寫 | 三方共識 #7 |
|
|
||||||
| **沿用率** | — | **85-95%**(詳見 `v2/code-reuse-v2.md`) | 三方共識 #1 |
|
|
||||||
| **總工時** | — | **~10 人天**,拆成 10 個 milestone(詳見 `v2/milestone-plan.md`) | 三方共識 #1 |
|
|
||||||
|
|
||||||
### 0.2 v1 未變的決策(v2 繼續沿用)
|
|
||||||
|
|
||||||
- 三層程序模型(Wails 殼 + Go server 子行程 + Python sidecar)— D1
|
|
||||||
- Python runtime 雙策略(bundled / system / auto)— D2
|
|
||||||
- 完全放棄程式碼簽章(macOS ad-hoc、Windows 無 Authenticode、Linux 無簽章)— D3
|
|
||||||
- x86_64 only,三平台都不做 ARM — D4
|
|
||||||
- 預置模型全部打包(~73 MB),不做 auto-update、不收 telemetry — D5
|
|
||||||
- 資料目錄位置、single-instance lock、舊資料目錄遷移、IPC raise 機制全部保留
|
|
||||||
- 首次安裝 ≤ 5 分鐘、首次推論 ≤ 30 s / 回訪 15 s 等 NFR 目標不變
|
|
||||||
- Ubuntu 與 Windows 打包流程(AppImage / Inno Setup)不變
|
|
||||||
- 中英雙語(前端)機制不變,控制台 UI 使用同一份 `en-US` / `zh-TW` JSON(詳見 `v2/control-panel.md`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 新架構總覽
|
|
||||||
|
|
||||||
### 1.1 三層進程模型
|
|
||||||
|
|
||||||
```
|
|
||||||
Wails 殼(桌面控制台 UI)
|
|
||||||
└─spawn─▶ Go server 子行程(Gin HTTP + WebSocket on 127.0.0.1:random_port)
|
|
||||||
├─spawn─▶ python3 kneron_bridge.py(KneronPLUS SDK,sidecar)
|
|
||||||
└─spawn─▶ ffmpeg / ffprobe(on-demand 解碼)
|
|
||||||
|
|
||||||
Wails 殼 ←── IPC / Wails bindings ── Wails 視窗內的控制台 UI(vanilla HTML/JS/CSS)
|
|
||||||
Go server ←── HTTP / WebSocket over loopback ── 瀏覽器 tab(Next.js Web UI,業務操作全在這裡)
|
|
||||||
```
|
|
||||||
|
|
||||||
**關鍵差別於 v1**:Wails 視窗**不**載入業務 UI,它是獨立的控制台(status / log / start-stop / open-in-browser / preferences)。業務 UI 在瀏覽器 tab 跑 `http://127.0.0.1:<port>/` 的 Next.js SPA。
|
|
||||||
|
|
||||||
完整 ASCII 架構圖詳見:`v2/control-panel.md` §3(控制台 UI wireframe)、`architect-analysis-round2-refactor.md` §A1(v1→v2 資料流對照)。
|
|
||||||
|
|
||||||
### 1.2 ServerController State Machine
|
|
||||||
|
|
||||||
五個狀態:`Stopped / Starting / Running / Stopping / Error`。轉換由 `ServerController.txMu + mu` 雙 mutex 保護,不可跳過中間狀態。
|
|
||||||
|
|
||||||
- **Start**:`Stopped|Error → Starting → Running`(失敗走 Error)
|
|
||||||
- **Stop**:`Running → Stopping → Stopped`
|
|
||||||
- **Restart**:`Running → Stopping → Stopped → Starting → Running`
|
|
||||||
- **watchServer 3 次失敗**:`Running → Error`(v1 是 `os.Exit`,v2 改為 Error state 讓使用者手動復原)
|
|
||||||
|
|
||||||
完整細節見 `v2/server-lifecycle.md` §5。
|
|
||||||
|
|
||||||
### 1.3 資料流摘要
|
|
||||||
|
|
||||||
| 情境 | 簡述 |
|
|
||||||
|------|------|
|
|
||||||
| **冷啟動 + R5-4 自動開瀏覽器** | Wails `OnStartup` → 常規 seed / lock / IPC → `ServerController.Start()` → spawn server + logPump × 2 → 健康檢查 → `state = Running` → 若 `openBrowserOnStart` 且本 session 首次 → `OpenInBrowser("")` |
|
|
||||||
| **Log 推送到控制台** | server stdout/stderr → `cmd.StdoutPipe/StderrPipe` → `logPump` goroutine(bufio scanner + 10 ms micro-batch)→ 同時 (1) 寫 `logs/server.{stdout,stderr}.log` + (2) append 到 `LogBuffer`(ring 2000 行)+ (3) `EventsEmit("log:append", []LogLine)` → Wails JS 訂閱 `EventsOn('log:append', ...)` → log panel 增量 render |
|
|
||||||
| **Restart 期間瀏覽器 tab 自動重連** | 使用者按 Restart → Stop → Start → server 新 boot-id → 瀏覽器 polling `/api/system/boot-id`(5 s interval,Page Visibility API)偵測到 id 變 → `window.location.reload()` |
|
|
||||||
| **關 Wails 視窗 (R5-2)** | `OnBeforeClose` return false → `OnShutdown` → watchCancel → `ServerController.Stop()`(SIGTERM → 10 s → SIGKILL)→ releaseLock → Wails quit。瀏覽器 tab 的 polling 連續 3 次失敗(15 s)→ `<ServerOfflineOverlay>` 顯示「Server 已離線」 |
|
|
||||||
|
|
||||||
詳細時序與 Go 實作見 `v2/server-lifecycle.md` §2-9;瀏覽器端實作見 `v2/web-ui-offline-overlay.md`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 子檔案地圖
|
|
||||||
|
|
||||||
| # | 子檔 | 目的 | 對應 R5 決策 / M8 milestone |
|
|
||||||
|---|------|------|-----------------------------|
|
|
||||||
| 2.1 | [`v2/control-panel.md`](./v2/control-panel.md) | Wails 控制台 UI + Go App bindings + LogBuffer + log pump + 狀態機 + Preferences(R5-D2/D3)+ OS 通知觸發點 | R5-1, R5-5, R5-D1/D2/D3, R5-E;M8-4, M8-4b, M8-5 |
|
|
||||||
| 2.2 | [`v2/ffmpeg-lgpl.md`](./v2/ffmpeg-lgpl.md) | 三平台 LGPL ffmpeg vendor 策略(Makefile patch + macOS build script + 授權檔管理) | R5-6, R5-6a, R5-6b, R5-6c;M8-3 |
|
|
||||||
| 2.3 | [`v2/server-lifecycle.md`](./v2/server-lifecycle.md) | Server 生命週期細節:state machine、port 分配(F-2 強制保留)、pipe 捕捉、7+1 秒 graceful shutdown、boot-id、OS 通知(§10)、Preferences 持久化(§11) | R5-2, R5-4, R5-D1, PM Q4, F-2, PM §11-1/11-3;M8-4, M8-9 |
|
|
||||||
| 2.4 | [`v2/cors-security.md`](./v2/cors-security.md) | CORS whitelist middleware、WS origin check、資料驗證邊界 | 三方共識 #5;M8-8 |
|
|
||||||
| 2.5 | [`v2/deletions.md`](./v2/deletions.md) | yt-dlp / Mock 模式全清單(檔案 / 函式 / 行號 / i18n key / vendor / installer);v2.1 修正 `videoIsURL` / `NewVideoSourceFromURL` 明確刪除 | R5-5a, R5-7, PM Minor 5;M8-1, M8-2 |
|
|
||||||
| 2.6 | [`v2/web-ui-offline-overlay.md`](./v2/web-ui-offline-overlay.md) | 瀏覽器 tab 的 `<ServerOfflineOverlay>` 實作:polling + **WebSocket shutdown-imminent** 雙管道、重試、SSR 相容 | R5-2 三方共識 #14, PM Minor 4;M8-7 |
|
|
||||||
| 2.7 | [`v2/milestone-plan.md`](./v2/milestone-plan.md) | M8-1 ~ M8-10 + **M8-4b 階段化啟動**;總工時 ~12 人天 | 整體;M8-* |
|
|
||||||
| 2.8 | [`v2/code-reuse-v2.md`](./v2/code-reuse-v2.md) | 逐模組沿用 / 改寫 / 新寫比例表 | 整體 |
|
|
||||||
| 2.9 | [`v2/startup-pipeline.md`](./v2/startup-pipeline.md) | **v2.1 新增**:R5-E 6 階段啟動管線實作(event schema、StartupPipeline struct、watcher goroutine、60 s hard timeout / 20 s soft timeout、前端 startup-panel.js)| R5-E1~E6;M8-4b |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 風險清單(v2 更新)
|
|
||||||
|
|
||||||
繼承 v1 `risks-and-mitigations.md` 全部風險,以下為 v2 新增或升級的條目:
|
|
||||||
|
|
||||||
| # | 風險 | 等級 | 新增/升級 | 緩解 |
|
|
||||||
|---|------|------|----------|------|
|
|
||||||
| R-v2-1 | **M7-B M1 驗收漏看 Wails 視窗** 的教訓 — v2 控制台是全新 UI,重複踩同樣坑的風險 | 🟠 中 | 新增 | M8-10 驗收 checklist 強制三個檢查:(1) 雙擊 .app / .exe / .AppImage 打開後 Wails 視窗顯示的是控制台 UI(不是 splash / wizard / 白畫面);(2) 點 Open in Browser 後瀏覽器確實載入 Next.js;(3) 點 Stop 後瀏覽器 tab 能看到 Offline Overlay。每個平台都要做 |
|
|
||||||
| R-v2-2 | **macOS 自 build ffmpeg 的可重現性** — LGPL 合規稽核時必須能證明 `vendor/ffmpeg/macos/ffmpeg` 是我們在特定 configure flags 下從特定 ffmpeg commit build 出來的 | 🔴 高 | 新增 | `vendor/ffmpeg/macos/BUILD.md` 必須記錄:ffmpeg release tag(如 `n7.1`)、source tarball sha256、完整 `./configure` line、build host(macOS version + Xcode CLT version)、build date、binary sha256。未來升級 ffmpeg 時要重跑並更新 BUILD.md,不能「手改一下再傳」。同步把 configure flags 寫進 `Makefile` 的 `vendor-ffmpeg-macos-build` target(而非埋在 BUILD.md 內)讓其可 reproduce |
|
|
||||||
| R-v2-3 | **Wails EventsEmit 在高頻 stdout 下丟事件或延遲** — server boot 時 Gin / logger 一次可能噴 200+ 行;推論 frame log 若誤進 stdout 會產生秒級 30-100 行 | 🟠 中 | 新增(v1 F-4 升級)| (1) logPump 加 micro-batch:緩存 10 ms window 內的行,一次 emit 一個 `log:append` event(payload 為陣列);(2) server 推論 frame 狀態禁止用 logger.Info,改用 debug level(line-rate 測試會檢查此條);(3) 若 LogBuffer 滿 > 80% 時 logPump 降為只寫檔不 emit event(控制台看到「…(skipped N events)…」提示,使用者可 Clear Logs)。實作細節見 `v2/control-panel.md` §4 |
|
|
||||||
| R-v2-4 | **Wails 關閉視窗 → StopServer 過程中瀏覽器 tab 的 race condition** — 使用者按 × → Wails OnBeforeClose → ServerController.Stop() → SIGTERM → wait → SIGKILL → Wails 退出,**整段 ~0.5-5s 內**瀏覽器 tab 的 polling 可能看到 ECONNREFUSED 但 Overlay 還沒觸發(需連續 3 次失敗,15 s 才顯示) | 🟡 低 | 新增 | 實務上:使用者關了 Wails 視窗通常也會關瀏覽器 tab,race 不構成實際問題。若使用者真的沒關瀏覽器,15 s 後 Overlay 會出現,使用者看到「Server 已離線」訊息即可理解。不做額外優化(例:Wails 關閉前主動告知瀏覽器 — 需要 Wails → Browser 的 push channel,成本太高) |
|
|
||||||
| R-v2-5 | **macOS 自 build 的 ffmpeg 需要 codesign** — Gatekeeper 會擋未簽章的執行檔 | 🟠 中 | 新增 | (1) 在 macOS build script 最後做 `codesign --force --sign - ...`(ad-hoc sign);(2) `wails-macos` target 的 `codesign --force --deep --sign - visiona-local.app` 已覆蓋 Resources/bin 下的執行檔,沿用即可;(3) 驗收時用 `spctl --assess --verbose vendor/ffmpeg/macos/ffmpeg` 確認不會被 Gatekeeper 拒絕 |
|
|
||||||
| R-v2-6 | **boot-id polling 對瀏覽器 tab 休眠的影響** — Chrome 會把背景 tab 的 setInterval 降頻到 1 次/分鐘,可能讓使用者切回 tab 時 60 s 才偵測到 server 重啟 | 🟡 低 | 新增 | 用 Page Visibility API:tab visible → 5 s interval;tab hidden → 停 polling;tab 再次 visible → 立即 probe 一次再恢復 5 s interval。實作細節見 `v2/web-ui-offline-overlay.md` §3 |
|
|
||||||
| R-v2-7 | **砍 Mock 後空白 UI 體驗** — 使用者第一次打開沒插硬體時,Devices 頁會是空的 | 🟡 低 | 新增 | (1) Devices 頁顯示友善 empty state:「未偵測到 Kneron 裝置。請連接 KL520/KL720 後按『掃描』。」附安裝 driver 按鈕(Windows);(2) 這是 R5-5a 明示接受的結果,PRD v2 也會記錄為預期行為 |
|
|
||||||
|
|
||||||
v1 已列、v2 解除的風險:
|
|
||||||
- **ffmpeg GPL release blocker(F-5, R9)** — R5-6 LGPL 方案 B 解除
|
|
||||||
- **F-1 Wails tray 在 Linux GNOME 可能壞掉** — R5-3 維持砍 tray,風險消失
|
|
||||||
- **F-3 使用者找不回 app** — R5-2 維持關閉 = 結束,風險消失
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 審閱紀錄
|
|
||||||
|
|
||||||
| 日期 | 審閱者 | 結論 |
|
|
||||||
|------|-------|------|
|
|
||||||
| 2026-04-14 | Architect Agent | v2.0 Draft 產出 |
|
|
||||||
| 2026-04-14 | PM Agent | v2.0 互審完成(見 `reviews/pm-review-of-tdd-v2.md`)— Major × 4 / Minor × 5 |
|
|
||||||
| 2026-04-14 | Architect Agent | **v2.1** 產出:吸收 PM Major/Minor 修正 + R5-D 三條補充決策 + R5-E 階段化啟動 + Architect 自補清單(F-2 port 保留 / B-1 OS 通知 / Q1/Q3/Q5/Q7 自決)。新增 `v2/startup-pipeline.md`;總工時 ~10 → ~12 人天 |
|
|
||||||
| 2026-04-14 | PM Agent | v2.1 待再次審閱(確認 Major × 4 都已落地 + R5-D/E 理解一致)|
|
|
||||||
| 2026-04-14 | Design Agent | v2.1 待審(重點:R5-E5 啟動階段文案、7+1 秒 stopping modal 文案、startup-panel.js 視覺對齊 Design Spec v2.1)|
|
|
||||||
| 2026-04-14 | 使用者 | 待確認 |
|
|
||||||
@ -1,798 +0,0 @@
|
|||||||
# Architect 第一輪分析 — visionA-local 方向變更(2026-04-14)
|
|
||||||
|
|
||||||
> 作者:Architect Agent
|
|
||||||
> 狀態:Round-2 refactor 的第一輪筆記(非正式 Design Doc / TDD)
|
|
||||||
> 目的:針對使用者提出的方向重大變更做技術可行性評估 + 方案選項 + 風險標記
|
|
||||||
> 前置閱讀:`design-doc.md` 索引 + `architecture-overview.md` + `tray-and-lifecycle.md` + `dependency-bundling.md` + `risks-and-mitigations.md` + `progress.md` + `visiona-local/app.go` + `server/main.go`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 摘要(3 行)
|
|
||||||
|
|
||||||
1. **好消息:技術上非常可行,而且大部分搬遷成本已經被 M7-B 提前付掉**。前端現在已經是純 HTTP,從 Wails WebView 搬到 Browser tab 只差「不把同一個網址 redirect 進 Wails」這一步。
|
|
||||||
2. **砍量顯著**:yt-dlp 整包可丟(vendor 87MB + resolver 程式碼 + frontend URL tab),安裝檔從 220MB 降到 ~135MB;Wails 內嵌的控制台 UI 改寫成 3 頁獨立 shell(log / server 控制 / 開瀏覽器)。
|
|
||||||
3. **但 lifecycle 決策必須復議**:Q-A(砍 tray)與 Q7(關閉視窗=結束 app)都站不住腳了——新方向的核心是「Wails 視窗只是控制台,server 是主角」,控制台關掉不能殺 server,否則瀏覽器 tab 瞬間斷線。這點若不先跟使用者談清楚,後面做出來會有嚴重體驗落差。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 技術影響範圍
|
|
||||||
|
|
||||||
### A1. 新架構圖(ASCII)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ visionA-local.app (Wails Control Console — 桌面殼) │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 控制台 UI(HTML/CSS/JS,go:embed 進 Wails binary) │ │
|
|
||||||
│ │ ┌──────────────┐ ┌───────────────┐ ┌─────────────────┐ │ │
|
|
||||||
│ │ │ Server 狀態 │ │ Log panel │ │ 動作列 │ │ │
|
|
||||||
│ │ │ ● Running │ │ [ring buffer] │ │ [Start] [Stop] │ │ │
|
|
||||||
│ │ │ Port: 3721 │ │ scrollable │ │ [Restart] │ │ │
|
|
||||||
│ │ │ PID: 42131 │ │ filter/search │ │ [Open Browser] │ │ │
|
|
||||||
│ │ │ Python: sys │ │ clear / save │ │ [Reveal Logs] │ │ │
|
|
||||||
│ │ └──────────────┘ └───────────────┘ └─────────────────┘ │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ↑ Wails runtime (IPC) │
|
|
||||||
│ │ - Bindings: StartServer/StopServer/... │
|
|
||||||
│ │ - Events: server:log, server:status, │
|
|
||||||
│ │ server:dead, server:recovered │
|
|
||||||
│ ┌─────────────────┴───────────────────────────────────────────┐ │
|
|
||||||
│ │ App (Go, app.go) │ │
|
|
||||||
│ │ - Lifecycle / single-instance / data-dir migration │ │
|
|
||||||
│ │ - Server process manager (spawn/kill/watch) │ │
|
|
||||||
│ │ - Log pump: stdout/stderr → ring buffer → EventsEmit │ │
|
|
||||||
│ │ - Python runtime 雙策略、driver installer │ │
|
|
||||||
│ └──────────────────────┬──────────────────────────────────────┘ │
|
|
||||||
└───────────────────────────│──────────────────────────────────────────┘
|
|
||||||
│ exec.Command(...) pipe stdout/stderr
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ visiona-local-server (Gin on 127.0.0.1:random_port) │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ REST API (/api/*) + WebSocket (/ws/*) │ │
|
|
||||||
│ │ - /api/devices/* /api/models/* /api/inference/* │ │
|
|
||||||
│ │ - /ws/devices/events /ws/inference/frames /ws/server-logs │ │
|
|
||||||
│ │ Next.js UI (go:embed) —— 使用者業務介面 │ │
|
|
||||||
│ │ - Dashboard / Devices / Models / Workspace / Settings │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │ ▲ │
|
|
||||||
│ │ spawn │ HTTP/WS │
|
|
||||||
│ ▼ │ │
|
|
||||||
│ ┌──────────────────────────────┐ │ │
|
|
||||||
│ │ python3 kneron_bridge.py │ │ │
|
|
||||||
│ │ (KneronPLUS SDK) │ │ │
|
|
||||||
│ └──────────────────────────────┘ │ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌──────────────────────────────┐ │ │
|
|
||||||
│ │ ffmpeg (on demand) │ │ │
|
|
||||||
│ │ (webcam / 上傳影片解碼) │ │ │
|
|
||||||
│ └──────────────────────────────┘ │ │
|
|
||||||
└──────────────────────────────────────┼──────────────────────────────┘
|
|
||||||
│
|
|
||||||
│ HTTP/WS over loopback
|
|
||||||
▼
|
|
||||||
┌────────────────────────────────────────┐
|
|
||||||
│ Browser tab (Chrome / Safari / Edge) │
|
|
||||||
│ http://127.0.0.1:<random_port>/ │
|
|
||||||
│ │
|
|
||||||
│ Next.js SPA (fetch + WebSocket) │
|
|
||||||
│ - 所有業務操作在這裡 │
|
|
||||||
└────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**資料流關鍵差異(vs. 現況)**:
|
|
||||||
|
|
||||||
| 項目 | 現況(M7-B)| 新方向 |
|
|
||||||
|------|-------------|--------|
|
|
||||||
| Wails WebView 載入 | splash → `window.location.replace(http://127.0.0.1:port/)` 跳 Next.js 主 UI | splash 邏輯移除,Wails 永遠停在「控制台 UI」不跳轉 |
|
|
||||||
| 使用者業務操作 | Wails WebView 內(但已經是純 HTTP)| 另開 Browser tab,Wails 不碰業務 UI |
|
|
||||||
| Server stdout/stderr | 寫進 `logs/server.stdout.log` + `server.stderr.log`(file only)| **同時**管到 ring buffer → Wails events 給控制台 log panel |
|
|
||||||
| 關閉 Wails 視窗 | Q7=B:關 = 殺 server + 殺 app | **必須改**:關 = 隱藏到 tray 或 minimize;真正結束需透過「Quit」按鈕 |
|
|
||||||
| yt-dlp pipeline | vendor 87MB + `ResolveWithYTDLP` + `/api/media/url` + 前端 URL tab | **整條砍** |
|
|
||||||
| 瀏覽器端情境 | 使用者若自己連 `http://127.0.0.1:port/` 也可以用(未文件化)| **一級公民**,Wails 的「Open in Browser」會主動導引 |
|
|
||||||
|
|
||||||
**Lifecycle 狀態機**:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────┐
|
|
||||||
│ app launched │
|
|
||||||
└──────┬───────┘
|
|
||||||
▼
|
|
||||||
┌────────────────────────┐
|
|
||||||
│ acquire single-instance│
|
|
||||||
└──────┬─────────────────┘
|
|
||||||
▼
|
|
||||||
┌──────────────────┐
|
|
||||||
│ seed user-data │
|
|
||||||
└──────┬───────────┘
|
|
||||||
▼
|
|
||||||
┌─────────────────────┐
|
|
||||||
│ ensure python │
|
|
||||||
└────┬────────────────┘
|
|
||||||
▼
|
|
||||||
┌──────────────────────┐ ┌───────────────┐
|
|
||||||
│ STATE: Starting │──OK──▶│ STATE: Running │◀──┐
|
|
||||||
│ spawn server │ └──┬─────────────┘ │
|
|
||||||
│ wait /health │ │ │
|
|
||||||
└──┬───────────────────┘ │ user: Stop │
|
|
||||||
│ error ▼ │
|
|
||||||
▼ ┌───────────────┐ │
|
|
||||||
┌──────────────────┐ │ STATE: Stopping│ │
|
|
||||||
│ STATE: Error │ │ SIGTERM+wait │ │
|
|
||||||
│ show error in │ └──┬─────────────┘ │
|
|
||||||
│ console, wait │ ▼ │
|
|
||||||
│ for user action │ ┌───────────────┐ │
|
|
||||||
└──┬───────────────┘ │ STATE: Stopped │ │
|
|
||||||
│ └──┬─────────────┘ │
|
|
||||||
│ user: Retry │ user: Start │
|
|
||||||
▼ └───────────────────────▶│
|
|
||||||
(back to Starting) │
|
|
||||||
│
|
|
||||||
user: Restart ─────────▶ Stop then Start ──────────────┘
|
|
||||||
|
|
||||||
watchServer() 失敗 3 次 ──▶ STATE: Error(非 reportFatal + os.Exit)
|
|
||||||
```
|
|
||||||
|
|
||||||
跟現在最大的不同:**watchServer 觸發 3 次失敗時,不能再直接 `reportFatal + os.Exit(1)`**——那只是 server 死掉,使用者的控制台還有用,應該切到 Error state 等使用者手動 Restart。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A2. Wails 視窗載入什麼(方案比較)
|
|
||||||
|
|
||||||
| 方案 | 描述 | 成本 | 風險 | 推薦? |
|
|
||||||
|------|------|------|------|-------|
|
|
||||||
| **A1:繼續 go:embed 極簡 HTML/JS/CSS**(沿用現有 splash 路徑)| 直接改寫 `visiona-local/frontend/` 的 splash 成真正的控制台 UI,保留 ES module + Wails bindings + events | ⭐ 最低(~200 行 JS、一張 index.html、一份 style.css)| 控制台要長成什麼樣要自己刻,少 shadcn 之類 lib。但這是 3 個 widget 的頁面,手刻 1-2 天完成 | **✅ 推薦** |
|
|
||||||
| **A2:Go server 內的 `/control` 路由**(和主 UI 同 server,瀏覽器看不到)| 把控制台 UI 也塞進 `server/web/out/`,用 Gin 中介層鎖「只有 `X-Wails-Control: 1` header 才能看」 | ⭐⭐⭐ 高(要兩個 SPA 共用 Next.js project + middleware 阻擋瀏覽器存取)| **根本性問題**:server 還沒起來時,Wails 視窗要載什麼?Chicken-and-egg。而且 server 死掉時控制台也跟著死,等於沒控制台可用 | ❌ 否決 |
|
|
||||||
| **A3:獨立的 Vite/Next.js mini project**(專為 Wails 控制台)| 新開一個 `visiona-local/console-ui/` 有自己的 build pipeline | ⭐⭐ 中(新增 node build 依賴、Tailwind/shadcn 可復用、更好看)| 對一個只有 3 個 widget 的頁面,工具鏈太重。build 時間變長(~30s)| ⚠️ 不推薦(除非要做很多控制台 UI) |
|
|
||||||
| **A4:純原生 Wails UI(不 embed 任何網頁)**| 用 Wails 的 native 模式(但 Wails v2 不支援真原生)| — | Wails v2 一律要有 embedded WebView;這方案根本不存在 | ❌ 技術上不可行 |
|
|
||||||
|
|
||||||
**推薦 A1**。理由:
|
|
||||||
- 控制台複雜度低(status text + log scrollview + 4 個按鈕),手刻 vanilla 最直接
|
|
||||||
- 已經有現成 splash 範本(app.js + style.css + index.html),改寫比從零快
|
|
||||||
- 不引入 Next.js / Vite build 依賴,Wails binary 體積不受影響
|
|
||||||
- Log panel 不需要 shadcn,用原生 `<pre>` + CSS 就夠
|
|
||||||
- 未來若真的要加很多功能再重構成 A3
|
|
||||||
|
|
||||||
**必要改動**:
|
|
||||||
```
|
|
||||||
visiona-local/frontend/
|
|
||||||
├── index.html ← 改寫成控制台 layout
|
|
||||||
├── app.js ← 改寫:訂閱 server:log/status events,呼叫 Start/Stop/Restart bindings
|
|
||||||
├── style.css ← 新增 log panel 樣式
|
|
||||||
├── components/ ← 視需要拆出來(log-panel.js / action-bar.js)
|
|
||||||
└── wailsjs/ ← 自動生成,不動
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A3. Log 顯示機制
|
|
||||||
|
|
||||||
**核心技術決策**:伺服器 stdout/stderr 同時寫檔 + 寫 ring buffer + 推送 Wails event。
|
|
||||||
|
|
||||||
#### 現況(app.go 481-516 行)
|
|
||||||
|
|
||||||
目前 `startServer()` 是把 server 的 stdout/stderr 直接 assign 給 `os.File`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
cmd.Stdout = stdoutLog // os.File, 寫到 logs/server.stdout.log
|
|
||||||
cmd.Stderr = stderrLog
|
|
||||||
```
|
|
||||||
|
|
||||||
**限制**:
|
|
||||||
- 沒有任何地方能「讀」這些 log 回來
|
|
||||||
- Wails 前端拿不到 server log
|
|
||||||
- 使用者看 log 必須自己去 `~/Library/Application Support/visiona-local/logs/`
|
|
||||||
|
|
||||||
#### 新方案:多重 writer + 環形緩衝
|
|
||||||
|
|
||||||
```go
|
|
||||||
type LogBuffer struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
lines []LogLine // ring buffer,cap=2000
|
|
||||||
head int
|
|
||||||
size int
|
|
||||||
subs map[uint64]chan LogLine
|
|
||||||
nextSub uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
type LogLine struct {
|
|
||||||
Ts time.Time `json:"ts"`
|
|
||||||
Stream string `json:"stream"` // "stdout" / "stderr"
|
|
||||||
Line string `json:"line"`
|
|
||||||
Level string `json:"level,omitempty"` // 解析過的 level:info/warn/error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每條新 log:
|
|
||||||
// 1. 寫入 logs/server.{stdout,stderr}.log(持久化)
|
|
||||||
// 2. append 進 ring buffer(供初次載入 + "過去 2000 行")
|
|
||||||
// 3. broadcast 給訂閱者(Wails events)
|
|
||||||
func (b *LogBuffer) Write(stream, line string) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `startServer()` 改成:
|
|
||||||
|
|
||||||
```go
|
|
||||||
stdoutPipe, _ := cmd.StdoutPipe()
|
|
||||||
stderrPipe, _ := cmd.StderrPipe()
|
|
||||||
go a.logPump(stdoutPipe, "stdout", stdoutLog)
|
|
||||||
go a.logPump(stderrPipe, "stderr", stderrLog)
|
|
||||||
```
|
|
||||||
|
|
||||||
`logPump` 同時寫檔 + 寫 ring buffer + EventsEmit:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (a *App) logPump(pipe io.ReadCloser, stream string, fileWriter io.Writer) {
|
|
||||||
scanner := bufio.NewScanner(pipe)
|
|
||||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024) // 避免長 log line 爆
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
fmt.Fprintln(fileWriter, line)
|
|
||||||
a.logBuf.Append(stream, line)
|
|
||||||
wailsRuntime.EventsEmit(a.ctx, "server:log", map[string]any{
|
|
||||||
"stream": stream,
|
|
||||||
"line": line,
|
|
||||||
"ts": time.Now().UnixMilli(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 前端訂閱(app.js in Wails frontend)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { EventsOn } from '../wailsjs/runtime/runtime.js';
|
|
||||||
import { GetRecentLogs } from './wailsjs/go/main/App.js';
|
|
||||||
|
|
||||||
// 1. 初次載入:抓 ring buffer 裡已累積的行
|
|
||||||
const initial = await GetRecentLogs(2000);
|
|
||||||
logPanel.append(initial);
|
|
||||||
|
|
||||||
// 2. 持續訂閱
|
|
||||||
EventsOn('server:log', (line) => logPanel.append([line]));
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 關鍵參數與風險
|
|
||||||
|
|
||||||
| 項目 | 值 | 理由 |
|
|
||||||
|------|----|------|
|
|
||||||
| Ring buffer 大小 | 2000 行 | ~200KB 記憶體,足以涵蓋「開啟控制台時看到最近 10 分鐘」 |
|
|
||||||
| 單行最大長度 | 1MB | 防止超長 log(堆疊追蹤)爆 scanner |
|
|
||||||
| Events 批次 | 每行 emit 一次 | 初版先簡單,若壓力大再改 micro-batch(10ms window) |
|
|
||||||
| 持久化 | 仍寫 `logs/server.{stdout,stderr}.log` | 不改現況;控制台 log panel 只是即時視圖 |
|
|
||||||
| Log 檔 rotation | 暫不做(M1 就沒做)| 已在 `tray-and-lifecycle.md` 風險清單。建議跟這次重構一起做:按大小 rotate(10MB × 5 份)|
|
|
||||||
|
|
||||||
**高頻 log 壓力評估**:
|
|
||||||
- 推論一次 frame 若列一行 log → 30 fps × 即時 stream = 30 events/sec
|
|
||||||
- Wails IPC 在 macOS 上實測可輕鬆吞 >1000 events/sec
|
|
||||||
- 不是瓶頸。但**推論的 frame 狀態不應該用 log 傳**——server 應該用 WebSocket `/ws/inference/frames`。
|
|
||||||
- 控制台只看 app 級別 log,不看 per-frame log
|
|
||||||
|
|
||||||
**⚠️ 待確認**:目前 `server/pkg/logger` 是否有把 `per-frame` 塞進 stdout?需要翻一下。若有,必須先降級成 debug-only。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A4. Server 生命週期控制
|
|
||||||
|
|
||||||
#### 目前狀態(`app.go`)
|
|
||||||
|
|
||||||
- `startServer()`:spawn → health check → 寫 ipc-port → 啟動 watchServer
|
|
||||||
- `stopServer()`:cancel watch goroutine → `proc.stop()`(SIGTERM → 5s → SIGKILL)
|
|
||||||
- **沒有 Restart**。要重啟只能重開整個 Wails app
|
|
||||||
- `watchServer()` 3 次失敗 → `reportFatal` → `os.Exit(1)` → 連 Wails app 一起死
|
|
||||||
|
|
||||||
#### 新方案:補齊 Start/Stop/Restart,彼此互斥
|
|
||||||
|
|
||||||
新增 bindings:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Wails bindings
|
|
||||||
func (a *App) StartServer() error // 若 Running,no-op
|
|
||||||
func (a *App) StopServer() error // 若 Stopped,no-op
|
|
||||||
func (a *App) RestartServer() error // stop then start
|
|
||||||
func (a *App) GetServerState() ServerState // Running | Stopping | Starting | Stopped | Error
|
|
||||||
```
|
|
||||||
|
|
||||||
實作約束:
|
|
||||||
1. **互斥**:用 `sync.Mutex` 保護 state 欄位,任何時候只能有一個操作進行中
|
|
||||||
2. **Restart 期間 port 可重用**:`pickPort(preferredPort=<old_port>)`,但若被別的程序搶了就用新 port;ipc-port 檔必須更新
|
|
||||||
3. **Python runtime 不重跑**:`a.pythonBin` / `a.pythonModeR` 已 resolved,Stop 後留著,Start 時直接重用(省 5-10s)
|
|
||||||
4. **Log buffer 不清空**:Stop 時只 reset `server:status` event,log panel 保留既往 log(加個分隔線)
|
|
||||||
|
|
||||||
#### Watch 行為變更(**重大修改**)
|
|
||||||
|
|
||||||
現在:
|
|
||||||
|
|
||||||
```go
|
|
||||||
if failures >= 3 {
|
|
||||||
a.reportFatal("server died", ...) // os.Exit(1) — 連 Wails app 一起死
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
新版:
|
|
||||||
|
|
||||||
```go
|
|
||||||
if failures >= 3 {
|
|
||||||
a.setServerState(ServerStateError, "health check failed 3 times")
|
|
||||||
wailsRuntime.EventsEmit(a.ctx, "server:dead", ...)
|
|
||||||
// 不 os.Exit!讓控制台 UI 顯示錯誤狀態,使用者可手動 Restart / 查看 log
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
這個是 **Q7 決策復議** 的關鍵理由之一(見 C2):
|
|
||||||
- 現況的邏輯假設「沒有 server 就沒有 app」,但新方向的 Wails app 就是要提供「server 死掉後給人看 log + 決定下一步」的地方
|
|
||||||
- 保留 reportFatal 只留給「完全無法啟動」的致命錯誤(例如 data-dir 建不起來、lock 取不到)
|
|
||||||
|
|
||||||
#### Restart 期間的瀏覽器 tab
|
|
||||||
|
|
||||||
這是體驗層的核心問題。Restart 過程(最快 ~3s,含 SIGTERM grace + 新 server spawn + health check):
|
|
||||||
|
|
||||||
```
|
|
||||||
t=0.0s 使用者按 Restart
|
|
||||||
t=0.0s SIGTERM → 舊 server
|
|
||||||
t=0.5s 舊 server 優雅結束
|
|
||||||
t=0.5s spawn 新 server(可能是同 port)
|
|
||||||
t=2.0s health check 通過
|
|
||||||
t=2.0s 寫新 ipc-port(若 port 變更)
|
|
||||||
```
|
|
||||||
|
|
||||||
瀏覽器 tab 在 t=0~2s 期間的請求會 connection refused / ECONNREFUSED。方案:
|
|
||||||
|
|
||||||
| 方案 | 描述 | 推薦? |
|
|
||||||
|------|------|-------|
|
|
||||||
| **R1:前端 retry + toast** | Next.js 全域 fetch interceptor 偵測 ECONNREFUSED,重試 5 次 × 500ms,同時顯示 toast「伺服器重啟中」 | ✅ 推薦,且這不是大工 |
|
|
||||||
| **R2:Wails 代理 proxy** | 讓 Wails app 自己起一個固定 port 做 reverse proxy,backend 切換時透明處理 | ❌ 成本太高,且 Wails 關了瀏覽器更慘 |
|
|
||||||
| **R3:不處理**| 使用者看到 ERR_CONNECTION_REFUSED 錯誤頁,自己按 reload | ❌ 體驗爛 |
|
|
||||||
|
|
||||||
R1 可搭配「port 若變更時 Wails 控制台 emit event,瀏覽器透過 WS 接收後 location.reload()」,但要注意瀏覽器 tab **不可能**透過 Wails bindings 收事件——它就是個 HTTP client。解法:server 啟動後暴露 `/api/system/boot-id` endpoint,前端 poll 比對,不一致就 reload。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A5. Open in Browser 機制
|
|
||||||
|
|
||||||
#### 現況(`platform_*.go`)
|
|
||||||
|
|
||||||
`openBrowser(url string)` 已經實作,`app.go:329` 有 `OpenBrowser` binding。實際定義在 `platform_darwin.go` / `platform_linux.go` / `platform_windows.go`:
|
|
||||||
|
|
||||||
- macOS:`exec.Command("open", url).Start()`
|
|
||||||
- Linux:`exec.Command("xdg-open", url).Start()`
|
|
||||||
- Windows:`rundll32 url.dll,FileProtocolHandler url` 或 `cmd /c start url`
|
|
||||||
|
|
||||||
**可直接沿用,不用重寫**。
|
|
||||||
|
|
||||||
#### 要加的東西
|
|
||||||
|
|
||||||
1. **URL 組合**:`http://127.0.0.1:<actual_port>/`,從 `a.server.port` 拿
|
|
||||||
2. **控制台按鈕**:呼叫 `OpenBrowser(url)` binding
|
|
||||||
3. **自動開啟(選配)**:使用者決定要不要在 server 就緒後自動開瀏覽器
|
|
||||||
- 預設「就緒後自動開一次」(符合 Ollama / Docker Desktop 行為)
|
|
||||||
- Settings 可關閉
|
|
||||||
- 避免每次 Restart 都多開一個 tab(要記錄「這個 session 已自動開過」)
|
|
||||||
4. **多次點擊** :瀏覽器會自動 reuse 同一個 tab(若 URL 相同),我們不用處理
|
|
||||||
|
|
||||||
#### Wails runtime 有沒有 BrowserOpenURL?
|
|
||||||
|
|
||||||
Wails v2 runtime 有 `BrowserOpenURL(ctx, url)` 可用,但它底層也是 shell out。我們現有的 `openBrowser` 已經封裝得跟 Wails 一樣,不必換。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A6. 依賴瘦身
|
|
||||||
|
|
||||||
#### 可以砍的
|
|
||||||
|
|
||||||
| 項目 | 大小 | 來源 | 原因 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `vendor/yt-dlp/` | **87MB** | M6-2 | 新方向完全不用 URL 推論 |
|
|
||||||
| `server/internal/camera/video_source.go` 的 `ResolveWithYTDLP` | <1KB | — | yt-dlp 呼叫點 |
|
|
||||||
| `server/internal/api/handlers/camera_handler.go` 的 `ytdlpHosts` / `classifyVideoURL` / `StartFromURL` | ~100 行 | — | URL classify + resolve |
|
|
||||||
| `server/internal/api/router.go` 的 `api.POST("/media/url", ...)` | 1 行 | — | endpoint |
|
|
||||||
| `server/internal/deps/checker.go` 的 `check("yt-dlp", ...)` | ~3 行 | — | deps 檢查 |
|
|
||||||
| `server/main.go` 的 yt-dlp PATH 注入註解 | — | — | 註解 |
|
|
||||||
| `frontend/src/components/camera/source-selector.tsx` 的 `videoMode === 'url'` 分支 | ~50 行 | — | UI |
|
|
||||||
| `frontend/src/stores/camera-store.ts` 的 `startFromUrl` | ~25 行 | — | store action |
|
|
||||||
| `frontend/src/lib/i18n/{zh-TW,en,types}.ts` 的 `camera.pasteUrl` / `urlPlaceholder` / `urlHelpText` 等 key | ~10 keys × 2 lang | — | i18n |
|
|
||||||
| payload-*.sh 中的 `vendor-ytdlp` 相關 stage 步驟 | — | M6-3 | 打包 |
|
|
||||||
| `Makefile` 的 `vendor-ytdlp` target | — | M6-2 | build |
|
|
||||||
| `server/internal/deps` 中 `VISIONA_BUNDLE_BIN_DIR` 對 yt-dlp 的偵測 | ~10 行 | M6-4 | env 注入 |
|
|
||||||
|
|
||||||
**總砍量**:
|
|
||||||
- Binary size:**~87MB**(dmg 從 220MB 降到 ~135MB)
|
|
||||||
- Source:~200 行 Go + ~100 行 TS/TSX + 10 幾個 i18n keys
|
|
||||||
|
|
||||||
#### 必須保留的
|
|
||||||
|
|
||||||
**ffmpeg 必須留**。新需求:「上傳影片 avi/mpeg/mp4」。這些格式的解碼靠 ffmpeg,Kneron pipeline 吃的是 ffmpeg 產出的 MJPEG frame stream。現狀 `UploadVideo` handler 已支援 `.mp4 / .avi / .mov`,再加個 `.mpeg / .mpg` 即可。
|
|
||||||
|
|
||||||
**但 ffmpeg GPL release blocker 還在**。狀況比之前更尷尬:
|
|
||||||
- 之前 URL 推論是賣點之一,GPL 對使用情境比較 justify
|
|
||||||
- 現在 ffmpeg 的唯一用途是「解碼一個使用者自己選的本地檔」,使用情境非常窄
|
|
||||||
- **機會**:使用情境變窄後,LGPL build 的 feature set 更容易覆蓋(不需要 x264 encoder,只需要 decoder)
|
|
||||||
- **建議**:這次 refactor 是重新談 ffmpeg GPL 的好時機。可以考慮切到 LGPL build 或甚至改用 Go 純粹解碼(例如 `github.com/zergon321/reisen` 包 libav-via-Go,但還是需要 ffmpeg 的 lib...)
|
|
||||||
|
|
||||||
**Camera(webcam)pipeline**:也需要 ffmpeg(macOS 用 AVFoundation device、Windows 用 dshow、Linux 用 v4l2)。這條保留。
|
|
||||||
|
|
||||||
#### 決策點
|
|
||||||
|
|
||||||
- **yt-dlp** — 砍,無爭議
|
|
||||||
- **ffmpeg** — 保留,但 LGPL 切換評估
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A7. 程式碼變更清單(砍哪些 / 改哪些)
|
|
||||||
|
|
||||||
#### 砍(刪整段 / 整檔)
|
|
||||||
|
|
||||||
| 檔案 | 改動 | 行數 |
|
|
||||||
|------|------|------|
|
|
||||||
| `vendor/yt-dlp/` 整個目錄 | 刪 | 87MB |
|
|
||||||
| `Makefile` `vendor-ytdlp` target + `payload-*` 的 yt-dlp stage 段 | 刪 | ~20 行 |
|
|
||||||
| `server/internal/camera/video_source.go` | 砍 `ResolveWithYTDLP` + `friendlyYTDLPError` | L91-L160 ~70 行 |
|
|
||||||
| `server/internal/api/handlers/camera_handler.go` | 砍 `ytdlpHosts` map + `urlKind` enum + `classifyVideoURL` + `StartFromURL` + `stopActivePipeline` 裡的 URL cleanup(若有)| L341-L500 ~160 行 |
|
|
||||||
| `server/internal/api/router.go` | 刪 `api.POST("/media/url", ...)` | L83 1 行 |
|
|
||||||
| `server/internal/deps/checker.go` | 刪 yt-dlp check entry | L30-L32 3 行 |
|
|
||||||
| `server/main.go` | 刪 yt-dlp PATH 相關註解(若 binDir 已放其他東西則 binDir 注入保留)| ~5 行 |
|
|
||||||
| `frontend/src/components/camera/source-selector.tsx` | 砍 `videoMode` 的 `'url'` 分支、`pasteUrl` 按鈕、URL input、helper text | L51, L190-L260 ~70 行 |
|
|
||||||
| `frontend/src/stores/camera-store.ts` | 砍 `startFromUrl` action + 相關 state | L32, L167-L195 ~30 行 |
|
|
||||||
| `frontend/src/lib/i18n/zh-TW.ts` / `en.ts` / `types.ts` | 砍 `camera.pasteUrl` / `urlPlaceholder` / `urlHelpText` / 相關 errors | ~12 行 × 3 |
|
|
||||||
|
|
||||||
#### 砍(新方向要求)
|
|
||||||
|
|
||||||
| 檔案 | 改動 |
|
|
||||||
|------|------|
|
|
||||||
| `frontend/src/stores/model-store.ts`(視 UX 決策)| 確認「上傳模型」對應的只是使用者自訂 .nef 路徑。使用者說「模型除了預設的幾種只能用上傳的」= 保留現況(預置 + 上傳),不砍 |
|
|
||||||
|
|
||||||
#### 改
|
|
||||||
|
|
||||||
| 檔案 | 改動 | 預估行數 |
|
|
||||||
|------|------|---------|
|
|
||||||
| `visiona-local/frontend/index.html` | 改寫成控制台 layout(status / log panel / actions)| ~80 行 |
|
|
||||||
| `visiona-local/frontend/app.js` | 改寫:訂閱 server:log events、呼叫 Start/Stop/Restart、OpenBrowser;**移除** 自動跳轉邏輯 | ~150 行 |
|
|
||||||
| `visiona-local/frontend/style.css` | log panel + buttons + status badge 樣式 | ~150 行 |
|
|
||||||
| `visiona-local/app.go` | 新增 LogBuffer + logPump + StartServer/StopServer/RestartServer bindings + GetRecentLogs + ServerState;watchServer 不再 os.Exit | ~300 行新增 / 20 行刪改 |
|
|
||||||
| `visiona-local/main.go` | 加 EventsEmit 支援(已有);若需要 tray 則補 options.App SystemTray | ~10 行 |
|
|
||||||
| `server/internal/api/handlers/camera_handler.go` `UploadVideo` | 擴充支援的副檔名:`.mp4, .avi, .mov, .mpeg, .mpg`(「瀏覽器能吃的」集合與 Kneron pipeline 吃得到的集合的交集)| L251 1 行改 |
|
|
||||||
| `frontend/src/components/camera/source-selector.tsx` | `accept=".mp4,.avi,.mov,.mpeg,.mpg"` | 1 行 |
|
|
||||||
|
|
||||||
#### 新寫
|
|
||||||
|
|
||||||
| 檔案 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `visiona-local/log_buffer.go` | Ring buffer + pubsub |
|
|
||||||
| `visiona-local/server_control.go` | StartServer / StopServer / RestartServer + state machine |
|
|
||||||
| `visiona-local/frontend/components/log-panel.js` | Log 顯示元件(可選,可與 app.js 合併)|
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A8. 控制台 UI 技術選型(詳細)
|
|
||||||
|
|
||||||
承 A2 推薦 A1(vanilla JS/HTML/CSS),這裡補細節。
|
|
||||||
|
|
||||||
**UI 結構建議**:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ visionA-local │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ Server Status │
|
|
||||||
│ ● Running • http://127.0.0.1:3721 • PID 42131 │
|
|
||||||
│ Python: system (/usr/bin/python3.12) │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ [▶ Start] [■ Stop] [⟲ Restart] [🌐 Open in Browser] │
|
|
||||||
│ [📁 Reveal Logs] │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ Server Log [Pause] [Clear] [Save] │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────────┐│
|
|
||||||
│ │ 14:23:01 [INFO] visiona-local-server starting on :3721 ││
|
|
||||||
│ │ 14:23:01 [INFO] python bridge path: /usr/bin/python3.12 ││
|
|
||||||
│ │ 14:23:02 [INFO] loaded 8 preset models ││
|
|
||||||
│ │ 14:23:02 [INFO] HTTP server listening on 127.0.0.1:3721 ││
|
|
||||||
│ │ 14:23:15 [INFO] GET /api/system/info 200 2.1ms ││
|
|
||||||
│ │ ... ││
|
|
||||||
│ └─────────────────────────────────────────────────────────────┘│
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**元件清單(vanilla)**:
|
|
||||||
- Status card:一個 `<div>` + CSS;data 從 `GetServerStatus()` 輪詢(或更好:`EventsOn('server:status', ...)`)
|
|
||||||
- Action bar:5 顆 button,對應 5 個 bindings
|
|
||||||
- Log panel:`<pre class="log">` with auto-scroll + virtual scrolling(不需要第三方,~100 行 JS 手刻)
|
|
||||||
- Pause button:停止 auto-scroll(方便使用者檢查過去的 log)
|
|
||||||
- Filter:text input,client-side substring filter(M2 再加)
|
|
||||||
- Save:把 ring buffer dump 成 txt
|
|
||||||
|
|
||||||
**Dark mode**:跟隨系統(M1 已有 `prefers-color-scheme: dark`)。
|
|
||||||
|
|
||||||
**i18n**:中英雙語,走 `navigator.language` 判斷 + 硬 code 兩份文字(不用引 i18next)。或直接用現有 `server/web/out` 的 i18n 資料檔(不推薦,兩邊耦合)。
|
|
||||||
|
|
||||||
**預估工作量**:1.5-2 人天。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A9. 資料安全 / 綁定 interface
|
|
||||||
|
|
||||||
#### 現況
|
|
||||||
|
|
||||||
`server/main.go` 的預設 `--host` 是什麼?從 `config.go` 看應是可 flag 控制。`visiona-local/app.go:468` 明寫:
|
|
||||||
|
|
||||||
```go
|
|
||||||
args := []string{
|
|
||||||
"--host", "127.0.0.1",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**目前是強制 127.0.0.1**,瀏覽器只能從同一台機器連。
|
|
||||||
|
|
||||||
#### 新方向的安全情境
|
|
||||||
|
|
||||||
新方向把「可在瀏覽器使用」變成 first-class,會不會有人希望能從區網另一台電腦連?(例如:PM 在 Mac 上跑 visionA-local,工程師想從筆電瀏覽器連進來看推論結果)
|
|
||||||
|
|
||||||
**方案比較**:
|
|
||||||
|
|
||||||
| 方案 | 描述 | 風險 | 推薦 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| **N1:維持 127.0.0.1,不提供選項** | 只能本機連 | 無新風險 | ✅ 預設 |
|
|
||||||
| **N2:Settings 新增「允許區網存取」toggle** | 切 `0.0.0.0` + firewall 提示 | 必須加 auth token,否則區網任何人都能控制裝置 / 上傳模型;macOS/Windows 會跳 firewall 警告 | ⚠️ 有需求再做 |
|
|
||||||
| **N3:LAN mode with bearer token** | N2 + 自動產生 token + Open in Browser 時帶 token query param + Cookie | 成本:全套 auth middleware + session;破壞「純 fetch 沒 auth」的簡潔現況 | ❌ M1-M7 沒這需求 |
|
|
||||||
|
|
||||||
**建議 N1**。若使用者有需求,M8+ 再加 N2/N3。
|
|
||||||
|
|
||||||
#### 127.0.0.1 的隱性安全假設
|
|
||||||
|
|
||||||
- 本機其他 process 可以連上 visiona-local-server(例如惡意的 Electron app)。這是現況就有的風險。
|
|
||||||
- 沒有 CORS 限制(預設 gin 允許 localhost 跨來源)。在瀏覽器情境下 **要確認**:使用者如果在 Chrome 開了其他網站,那些網站能不能 `fetch('http://127.0.0.1:3721/api/models/upload')`?
|
|
||||||
- 答案:預設 **可以送請求**,但 CORS 預檢會擋(若 server 沒回 `Access-Control-Allow-Origin`),讀不到回應
|
|
||||||
- **但 POST 是會被送出去的**(CSRF risk)
|
|
||||||
- **⚠️ 建議**:這次 refactor 時把 CORS 政策明確定義:只允許 `Origin: http://127.0.0.1:*` 和 `Origin: http://localhost:*`。不允許其他 origin。
|
|
||||||
- 這個議題在「Wails WebView 模式」下沒什麼威脅(因為 WebView 的 origin 是 `wails://`,沒人能偽造),但移到瀏覽器後要當真
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 方案選項彙總表
|
|
||||||
|
|
||||||
| 議題 | 選項 | 推薦 | 工時 | 備註 |
|
|
||||||
|------|------|------|------|------|
|
|
||||||
| Wails 視窗載入 | A1 vanilla / A2 server route / A3 mini Vite / A4 native | **A1** | 1.5-2 天 | |
|
|
||||||
| Log 顯示 | stdout→file only / + ring buffer + events / + WebSocket 分支 | **ring buffer + events** | 0.5 天 | |
|
|
||||||
| Server 控制 | 現有(only start)/ 加 Stop/Restart | **加 Stop/Restart + state machine** | 1 天 | |
|
|
||||||
| Open in Browser | 沿用 `openBrowser` / Wails `BrowserOpenURL` | **沿用** | <1h | |
|
|
||||||
| yt-dlp | 保留 / 砍 | **砍** | 0.5 天 | |
|
|
||||||
| ffmpeg | 保留 GPL / 切 LGPL / 純 Go 解碼 | **保留,但安排 LGPL 評估** | — | 跟使用者討論 |
|
|
||||||
| 影片格式 | `.mp4,.avi,.mov` / + `.mpeg,.mpg` / 所有 ffmpeg 能吃 | **`.mp4,.avi,.mov,.mpeg,.mpg`** | <1h | 對齊「瀏覽器能吃」 |
|
|
||||||
| 綁定 interface | 127.0.0.1 / 0.0.0.0 toggle / LAN + token | **127.0.0.1(維持)** | — | |
|
|
||||||
| CORS 政策 | 不設(寬鬆)/ 只允許 localhost | **只允許 127.0.0.1/localhost** | 0.5 天 | 瀏覽器模式下必做 |
|
|
||||||
| Lifecycle Q7 復議 | 維持關閉=結束 / 改為關閉=隱藏 / 加 tray | **復議,見 C2** | — | 需要使用者決策 |
|
|
||||||
| watchServer 失敗行為 | os.Exit / 進 Error state 等使用者 | **Error state** | 0.5 天 | |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. 和既有決策的衝突
|
|
||||||
|
|
||||||
### C1. 砍 tray 決策(Q-A=A3)是否復議
|
|
||||||
|
|
||||||
#### 原本的理由(第三輪決策)
|
|
||||||
|
|
||||||
> Q-A Tray 角色衝突(Q7 選關閉=結束後 tray 價值變低)→ A3 砍掉 tray,省跨平台圖資產與 Wails tray 踩坑。
|
|
||||||
|
|
||||||
原本邏輯是:「Q7 選關閉=結束程式,所以 tray 的核心價值(關視窗後還能找回 app)消失,乾脆砍」。
|
|
||||||
|
|
||||||
#### 新方向下的衝突
|
|
||||||
|
|
||||||
新方向的本質是:**server 是主角,Wails app 只是控制台**。這產生幾個矛盾:
|
|
||||||
|
|
||||||
1. **使用者主要時間在瀏覽器**。若 Wails 控制台關了,使用者還想停 server 或看 log,就沒有入口
|
|
||||||
2. **使用者不關 Wails 控制台**(因為不想斷 server),桌面就一直有個不太用的視窗。體驗像強迫你開著 Docker Desktop 的主視窗
|
|
||||||
3. **瀏覽器 tab 關了之後**,使用者怎麼再開?如果 Wails 控制台也關了,就得重開 app
|
|
||||||
|
|
||||||
**Tray 在新方向下的價值反而提高了**。
|
|
||||||
|
|
||||||
#### 方案比較
|
|
||||||
|
|
||||||
| 方案 | 描述 | 優點 | 缺點 | 推薦 |
|
|
||||||
|------|------|------|------|------|
|
|
||||||
| **T1:維持砍 tray,Wails 視窗必須開著** | 現況 | 不用跨平台 tray 資產、不用處理 Wails systray 踩坑 | 使用者被迫開著一個沒人看的視窗;體驗倒退 | ❌ |
|
|
||||||
| **T2:復活 tray(macOS menu bar / Win systray / Linux indicator)** | tray icon 有 Show / Hide Console / Open in Browser / Start / Stop / Quit 選單;關 Wails 視窗 = minimize to tray | 主場:使用者可關視窗但保留 server;菜單直接提供核心動作 | Wails v2 的 tray 支援有限(macOS OK、Windows OK、Linux 看 DE);要準備 icon 資產(~10 KB);可能會有個別 Linux DE 不支援(KDE/GNOME 沒 StatusNotifierItem 的) | ✅ |
|
|
||||||
| **T3:不做 tray,改「關視窗 = 隱藏視窗」**(app 仍在前景)| Wails 不支援 hide to dock only,macOS 本來就有 `runtime.WindowHide` 但 Dock 圖示仍在 | 不用 tray 資產 | Linux 下完全找不到那個視窗(沒有 Dock);Windows 下工作列會殘留空白按鈕 | ⚠️ 只做 macOS 可接受,跨平台不佳 |
|
|
||||||
| **T4:Wails app 當 daemon,Open in Browser 是唯一入口** | 無視窗,只有 tray | 最極致的 server-as-main | Wails v2 不支援「完全無主視窗」模式;實作困難 | ❌ |
|
|
||||||
|
|
||||||
**推薦 T2(復活 tray)**,但要給使用者決策:
|
|
||||||
- 使用者是否接受跨平台 tray 資產(icon × 3 平台 + light/dark 版)
|
|
||||||
- 是否接受 Linux 下 tray 可能在某些 DE 不 work 的風險(fallback:視窗仍可關/最小化)
|
|
||||||
|
|
||||||
**tray 菜單建議**:
|
|
||||||
|
|
||||||
```
|
|
||||||
visionA-local
|
|
||||||
━━━━━━━━━━━━━━━━━
|
|
||||||
● Server: Running (3721)
|
|
||||||
─────────
|
|
||||||
Show Console ⌘0
|
|
||||||
Open in Browser ⌘B
|
|
||||||
─────────
|
|
||||||
▶ Start Server
|
|
||||||
■ Stop Server
|
|
||||||
⟲ Restart Server
|
|
||||||
─────────
|
|
||||||
Reveal Logs
|
|
||||||
About
|
|
||||||
Quit ⌘Q
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 估工時
|
|
||||||
|
|
||||||
- Wails tray 綁定(參考 [wails tray examples](https://wails.io/docs/reference/runtime/systray)):~0.5 天
|
|
||||||
- Icon 資產(原 edge-ai-platform 的 tray-*.png 可復用或新做):~0.5 天
|
|
||||||
- 菜單行為 + 跨平台測試:~0.5 天
|
|
||||||
- **合計 1.5 天**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### C2. Q7 關閉視窗=結束 app 決策是否復議
|
|
||||||
|
|
||||||
#### 原本的理由(第二輪決策)
|
|
||||||
|
|
||||||
> Q7 視窗關閉行為 → **B** 傳統式(關閉 = 結束程式)
|
|
||||||
|
|
||||||
原本是因為 tray 砍了(Q-A),又要簡化體驗,所以「關視窗 = 結束 app」最直觀。
|
|
||||||
|
|
||||||
#### 新方向下的衝突
|
|
||||||
|
|
||||||
**非常嚴重**。若維持 Q7=B,則:
|
|
||||||
|
|
||||||
```
|
|
||||||
使用者打開 Wails 控制台 → 按 Open in Browser → 瀏覽器 tab 跑得好好
|
|
||||||
使用者覺得控制台占地方 → 關掉 Wails 視窗 → Wails shutdown → server 被 SIGTERM → 瀏覽器 tab 所有請求 ECONNREFUSED → 使用者傻眼
|
|
||||||
```
|
|
||||||
|
|
||||||
這不只是體驗問題,是功能失效。
|
|
||||||
|
|
||||||
#### 方案
|
|
||||||
|
|
||||||
必須改。三個選項:
|
|
||||||
|
|
||||||
| 方案 | 關閉視窗行為 | 真正結束 app 的方式 | 推薦 |
|
|
||||||
|------|------------|---------------------|------|
|
|
||||||
| **Q7-B1:關視窗 = minimize to tray**(配合 T2)| hide window,tray 還在 | tray > Quit / 視窗內 Quit 按鈕 / ⌘Q | **✅ 配套 T2 最順** |
|
|
||||||
| **Q7-B2:關視窗 = hide,無 tray**(配合 T3)| hide window,Dock/taskbar 圖示留著 | macOS:右鍵 Dock > Quit;其他:重開後 Quit | ⚠️ Linux 困難 |
|
|
||||||
| **Q7-B3:關視窗 = confirm dialog 問「也要結束 server 嗎?」** | 使用者選擇 | — | ❌ 太囉嗦 |
|
|
||||||
|
|
||||||
**推薦 Q7-B1**(綁 T2 復活 tray)。
|
|
||||||
|
|
||||||
#### 額外細節
|
|
||||||
|
|
||||||
- **macOS 慣例本來就是**「紅色關閉鈕 = hide」+「⌘Q = quit」。Q7 原本選 B 是反慣例。現在回歸 macOS 慣例反而更好
|
|
||||||
- **Windows 慣例**:× = quit。如果這邊改成 hide,要明確顯示 tray tooltip「visionA-local 仍在執行」,避免使用者以為沒關
|
|
||||||
- **Linux**:看 DE,通常 × = quit,但實務上 GNOME 有些 app 有 tray fallback
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. 遷移策略與沿用率
|
|
||||||
|
|
||||||
### 沿用率評估
|
|
||||||
|
|
||||||
| 模組 | 現況 | 新方向需要 | 沿用率 | 備註 |
|
|
||||||
|------|------|-----------|-------|------|
|
|
||||||
| `server/` 整個 Go 後端 | M1-M7 完成 | 幾乎全部保留 | **~95%** | 砍 yt-dlp 相關 + 加 UploadVideo 格式;CORS 政策調整;預置模型系統不動 |
|
|
||||||
| `server/web/out/` Next.js embedded SPA | 完整業務 UI | 保留,幾乎不變 | **~90%** | 砍 URL tab;upload 支援 extension 擴充;可能加 server restart 時的 reconnect 邏輯 |
|
|
||||||
| `visiona-local/frontend/` splash | 78 行 splash | 重寫成控制台(80+150+150=~400 行)| **結構可沿用,程式砍掉寫新的** | 保留 ES module 載入、wailsjs 串接模式 |
|
|
||||||
| `visiona-local/app.go` startup/shutdown/lifecycle/IPC | 1584 行 | 新增 300 行 server control + log buffer,改 ~30 行 watchServer 行為,其餘不動 | **~85%** | 核心 startup 順序、Python 雙策略、single-instance、driver install、data migration 全保留 |
|
|
||||||
| `visiona-local/payload/` | 完整 | 砍 yt-dlp stage,其餘不變 | **~90%** | |
|
|
||||||
| 安裝器(Inno Setup / dmgbuild / AppImage)| M4/M5/M7-A | 不變 | **100%** | |
|
|
||||||
| Build pipeline / Makefile | 完整 | 砍 vendor-ytdlp target,其餘不變 | **~95%** | |
|
|
||||||
|
|
||||||
**總體**:這次 refactor **不是丟掉重做**,是**擴充 + 砍功能**。M1-M7 的打包功夫 **100% 保留價值**,唯一被動搖的是:
|
|
||||||
1. Wails 視窗長什麼樣(splash → 控制台)
|
|
||||||
2. server 生死邏輯(connected to app lifetime → independent lifecycle)
|
|
||||||
3. 關視窗行為(quit → hide)
|
|
||||||
|
|
||||||
### 工時預估
|
|
||||||
|
|
||||||
| 任務 | 工時 | 前置條件 |
|
|
||||||
|------|------|---------|
|
|
||||||
| A. 砍 yt-dlp 全套(vendor + server + frontend + i18n + Makefile + payload)| 0.5-1 天 | 使用者確認砍 |
|
|
||||||
| B. log buffer + log pump + GetRecentLogs binding | 0.5 天 | — |
|
|
||||||
| C. StartServer/StopServer/RestartServer bindings + state machine | 1 天 | B 完成 |
|
|
||||||
| D. Wails 控制台 UI 改寫(vanilla)| 1.5-2 天 | B, C 完成 |
|
|
||||||
| E. watchServer 改為 Error state(不 os.Exit)| 0.5 天 | C 完成 |
|
|
||||||
| F. Tray(復活 Q-A)| 1.5 天 | 使用者確認復活 tray |
|
|
||||||
| G. Q7 復議為 hide-to-tray | 0.5 天 | 使用者確認 + F 完成 |
|
|
||||||
| H. CORS 限制為 localhost | 0.5 天 | — |
|
|
||||||
| I. 瀏覽器 tab restart 重連邏輯(前端 retry + boot-id 比對)| 1 天 | C 完成 |
|
|
||||||
| J. UploadVideo 擴充副檔名 | 0.5h | — |
|
|
||||||
| K. ffmpeg LGPL 切換評估與可能的重 build pipeline | ? | 需另估 |
|
|
||||||
| L. 端到端重 build + smoke test(macOS + Windows) | 1 天 | 全部完成 |
|
|
||||||
| M. PRD / Design spec / TDD 補文件(M 級 refactor)| 1 天 | 使用者確認範圍 |
|
|
||||||
|
|
||||||
**總計**:**~10 人天**(K 不估)。比「從零做 M1」便宜得多。
|
|
||||||
|
|
||||||
### 風險的遷移成本
|
|
||||||
|
|
||||||
| 風險 | 影響 |
|
|
||||||
|------|------|
|
|
||||||
| **Wails v2 tray 在 Linux 某些 DE 壞掉** | T2 可能在 Ubuntu+GNOME 沒 icon;需要 fallback 邏輯(指向「請使用主視窗」)|
|
|
||||||
| **Wails events 在 Windows 下壓力測試未知** | 若 server 產生大量 log,Wails IPC 可能掉 event。需要 log pump 加 rate limiting |
|
|
||||||
| **瀏覽器 tab 的 security context 問題** | 現有前端程式碼假設 origin 固定,移到瀏覽器後要確認 localStorage / sessionStorage 行為 |
|
|
||||||
| **ffmpeg LGPL 重新評估需時間** | 若使用者要趁這次切,build pipeline 要重構,會延長 2-3 天 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. 待使用者決策的問題(技術選型類)
|
|
||||||
|
|
||||||
| # | 問題 | 選項 | Architect 推薦 |
|
|
||||||
|---|------|------|---------------|
|
|
||||||
| **E-1** | Wails 控制台技術選型 | A1 vanilla / A2 同 server 路由 / A3 Vite mini / A4 native | **A1 vanilla** |
|
|
||||||
| **E-2** | 是否復活 tray(Q-A 復議)| 維持砍(T1)/ 復活(T2)/ 只做 hide(T3)| **T2 復活** |
|
|
||||||
| **E-3** | 視窗關閉行為(Q7 復議)| 維持關=quit(B)/ 改為 hide-to-tray(B1)/ 確認對話框(B3)| **B1 hide-to-tray(綁 E-2)** |
|
|
||||||
| **E-4** | yt-dlp 處理 | 砍 / 保留 | **砍**(無爭議)|
|
|
||||||
| **E-5** | ffmpeg 授權處理 | 維持 GPL 繼續開發、發佈前決定 / 現在就切 LGPL / 改純 Go 解碼 | **維持 GPL,但把 LGPL 評估列為 M8 必做**(因為使用情境變窄,切起來成本變低)|
|
|
||||||
| **E-6** | 支援的上傳影片副檔名 | 現有 mp4/avi/mov / + mpeg/mpg / 所有 ffmpeg 能吃的 | **mp4/avi/mov/mpeg/mpg**(使用者明示「瀏覽器能吃的」)|
|
|
||||||
| **E-7** | 綁定 interface | 只 127.0.0.1 / 加區網 toggle | **只 127.0.0.1**(M1-M7 延續)|
|
|
||||||
| **E-8** | CORS 政策 | 現況(可能寬鬆)/ 明確限制 127.0.0.1 + localhost | **限制**(瀏覽器模式下必做)|
|
|
||||||
| **E-9** | Restart 時瀏覽器 tab 重連 | 不處理 / 前端 retry + boot-id 比對 / Wails reverse proxy | **前端 retry + boot-id** |
|
|
||||||
| **E-10** | watchServer 失敗後行為 | 維持 os.Exit / 改為 Error state | **改為 Error state**(無爭議)|
|
|
||||||
| **E-11** | 自動開瀏覽器 | server 就緒後自動開一次 / 永不自動 / 使用者設定 | **預設自動開,Settings 可關**(對齊 Ollama/Docker Desktop)|
|
|
||||||
| **E-12** | Log panel 大小 | 500/1000/2000/5000 行 | **2000 行**(~200KB,平衡記憶體與可用性)|
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. 風險觀察
|
|
||||||
|
|
||||||
| # | 風險 | 等級 | 緩解 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| F-1 | Wails tray 在 Linux GNOME 下可能完全看不到 icon(StatusNotifierItem 協議在某些版本沒支援)| 🔴 高 | 加 fallback:視窗仍可 minimize 到 dock;提供「一律顯示視窗」的 config |
|
|
||||||
| F-2 | 瀏覽器 tab 與 Wails 控制台同時存在,兩邊 state 會分歧(例如 Wails 控制台顯示「Running」,瀏覽器 fetch 失敗)| 🟠 中 | 控制台的 status 來自 Wails 本地狀態(準確),瀏覽器的失敗要優雅處理(全域 error boundary)|
|
|
||||||
| F-3 | 使用者習慣以「× 關閉」結束程式,改成 hide 後回頭找 app 困難 | 🟠 中 | 第一次 hide 時彈 toast 告訴使用者「visionA-local 正在背景執行,可從 tray/menu bar 找到」|
|
|
||||||
| F-4 | Wails EventsEmit 在極高頻率下(例如每秒 >100 events)可能丟事件或延遲 | 🟡 低 | log pump 加 rate limiting + batch 合併;推論 frame 不走 log |
|
|
||||||
| F-5 | ffmpeg GPL 發佈前 review 仍未解決 | 🔴 高 | 新方向下使用情境變窄,反而是切 LGPL 的好時機;建議 M8 專門處理 |
|
|
||||||
| F-6 | 瀏覽器的 CSRF / CORS 攻擊面(任何網站都能嘗試 fetch 127.0.0.1:3721)| 🟠 中 | CORS 限制 + 必要時加 state-changing 操作的 CSRF token(POST /api/models/upload 等)|
|
|
||||||
| F-7 | 使用者同時開多個 visionA-local 視窗(single-instance 被喚起)時,目前「raise existing window」會失效(因為 Wails 視窗可能被 hide 到 tray)| 🟡 低 | `/ipc/raise` 除了 WindowShow 還要呼叫 WindowUnminimise + 從 tray 彈出 |
|
|
||||||
| F-8 | Restart 期間若使用者 close 視窗,state machine 亂掉 | 🟡 低 | state 轉換要用 mutex 保護;關視窗也透過 command queue |
|
|
||||||
| F-9 | Wails app 關閉時若 server 還活著,下次啟動 port 可能佔用 | 🟡 低 | 已有 isOurStaleServer 偵測;新方向若 server 要獨立生命週期,要重新評估「Wails 關閉是否真的能殺 server」或「要不要保留 daemon」 |
|
|
||||||
| F-10 | 瀏覽器快取問題:使用者更新 visionA-local 後,瀏覽器 tab 仍用舊版前端 | 🟠 中 | server 啟動時在每個 response 加 `ETag` 或 `Cache-Control: no-store`;或升級時改用新 boot-id,前端偵測到強制 reload |
|
|
||||||
| F-11 | macOS Gatekeeper:原本 Wails app 開瀏覽器 = shell out `open`,正常。但若加 tray,icon 檔可能觸發 code signing 問題 | 🟡 低 | icon 打在 binary 內,不走 shell out |
|
|
||||||
| F-12 | 使用者可能期待 Wails app 關掉 = server 也停(避免 USB 裝置被占用)| 🟠 中 | Quit 動作(tray > Quit 或 ⌘Q)要明確停 server;hide 不停;需要清楚的 UX 差異 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. 給 PM 和 Design 的議題
|
|
||||||
|
|
||||||
(Architect 不作決定,只標記需要 PM / Design 處理的事)
|
|
||||||
|
|
||||||
### 給 PM
|
|
||||||
|
|
||||||
- **G-P1:任務等級** — 這次 refactor 涉及 UX 模式變更(Wails 窗 = 控制台、server 獨立生命週期)+ 新 user story(「從瀏覽器使用 visionA-local」)+ 決策復議(Q-A、Q7)。**我的判斷是 L 級**(新 user story + 原決策復議),需要 PM 更新 PRD 的「功能願景」「使用情境」「非功能需求」段落,並重新跑 RICE
|
|
||||||
- **G-P2:定位重寫** — PRD 開頭的定位(「單機桌面應用,像 Docker Desktop」)需要改為「本機 AI inference 服務,提供 server + 網頁介面,像 Ollama(ollama serve + 網頁 / CLI client)」。這不只是包裝,是產品定位的實質變更
|
|
||||||
- **G-P3:使用者旅程重寫** — 從「打開 app → 在視窗內操作」變成「打開 app → 控制台確認 server 在跑 → 在瀏覽器操作」
|
|
||||||
- **G-P4:成功指標** — 現有 AC(「首次啟動 < 5 秒」「關視窗 = 結束」)有一半要改
|
|
||||||
- **G-P5:yt-dlp 砍功能的 user-facing 影響** — 有沒有 PM 認為「URL 推論」是需要保留的功能?若無,YouTube / Vimeo / RTSP 這些 URL 都不支援了,需不需要在 release note 明說
|
|
||||||
- **G-P6:ffmpeg LGPL 評估**要不要排 M8 milestone
|
|
||||||
- **G-P7:「預設模型只能用預設的幾種,其他只能上傳」** — 現況已如此(預置 + 使用者上傳),是確認還是有新要求?要不要 PM 確認
|
|
||||||
- **G-P8:法律** — 新方向下使用情境更窄,GPL 評估可能更好談;TOS/Privacy 要看是否有新的資料流動(實際上沒有)
|
|
||||||
|
|
||||||
### 給 Design
|
|
||||||
|
|
||||||
- **G-D1:Wails 控制台 UI 設計** — 雖然我推薦 vanilla JS 實作,但 layout、視覺語言、用字、深色淺色規範還是需要 Design 出稿
|
|
||||||
- **G-D2:tray 菜單 i18n + icon 設計** — 若 E-2 確認復活,tray icon 需要新做(至少 light/dark × 3 平台 × 2 狀態 = 12 張 icon)
|
|
||||||
- **G-D3:Wails 視窗關閉時的第一次提示 toast** — 「visionA-local 正在背景執行,可從 menu bar 找到」要怎麼寫、什麼時機
|
|
||||||
- **G-D4:Settings 新增「自動開瀏覽器」選項**的文案與位置
|
|
||||||
- **G-D5:Restart 期間瀏覽器 tab 的 skeleton state / loading overlay 設計**
|
|
||||||
- **G-D6:Workspace 的 URL tab 砍掉後的 UI 調整** — source-selector.tsx 目前「上傳檔案 / 貼 URL」雙 mode,砍掉 URL 後要不要簡化成單 tab?還是加入 camera 的 device 選擇?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. 下一步建議
|
|
||||||
|
|
||||||
1. **給使用者看 E 段的 12 個決策點** → 等使用者裁決
|
|
||||||
2. **決策後更新 progress.md 的「未解決問題」** → 清掉已經解決的(ffmpeg GPL 在新方向下的 posture、Wails 主視窗用途),新增新出現的(tray 復活、Q7 復議)
|
|
||||||
3. **若使用者確認走這條路**:
|
|
||||||
- PM:更新 PRD(L 級文件更新)
|
|
||||||
- Design:出控制台 + tray 設計稿
|
|
||||||
- Architect:把這份筆記升級為正式的 Design Doc 更新 + TDD 補丁
|
|
||||||
- 一路走完三方交叉審閱後再進 M8 開發
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**簽名**:Architect Agent,2026-04-14
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
# LGPL ffmpeg 三平台可取得性調查(2026-04-14)
|
|
||||||
|
|
||||||
> 作者:Architect Agent
|
|
||||||
> 任務:調查 macOS x86_64 / Windows x86_64 / Ubuntu x86_64 三平台是否都能取得可直接使用的 LGPL ffmpeg 靜態 binary,作為 visionA-local M6 release blocker 的解套方案評估
|
|
||||||
> 範圍:純 research,不動 Makefile / payload / vendor 腳本
|
|
||||||
> 前置閱讀:`/Users/jimchen/visionA/local-tool/Makefile`(vendor-ffmpeg*)、`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/architect-analysis-round2-refactor.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 摘要(3 行)
|
|
||||||
|
|
||||||
1. **Windows x86_64 與 Ubuntu x86_64 可以直接用現成 LGPL binary**——`BtbN/FFmpeg-Builds` 每天自動產出 `ffmpeg-master-latest-win64-lgpl.zip` 與 `ffmpeg-master-latest-linux64-lgpl.tar.xz`,經原始碼驗證 `--enable-gpl` 未設、`COPYING.LGPLv3`、明確排除 libx264/libx265,且內建 ffmpeg + ffprobe 雙 binary,完全符合需求。
|
|
||||||
2. **macOS x86_64 沒有現成的 LGPL 靜態 binary**——evermeet.cx(目前 Makefile 的來源)、gyan.dev(只做 Windows)、Martin-Riedl、osxexperts、ColorsWind 都不是 LGPL+static+維護中+x86_64 的組合。BtbN 官方 README 明確說「Static Windows (x86_64) and Linux (x86_64) Builds」,macOS 不在支援清單。
|
|
||||||
3. **建議走「混合方案 B」:Windows/Linux 換 BtbN LGPL,macOS 改自 build(GitHub Actions matrix 跑 ffmpeg configure --disable-everything-gpl + 必要 decoder)**。預估工時 1-1.5 人天(只需 macOS 這一支 pipeline),遠低於三平台全自 build 的 2-3 人天,且兩週內 LGPL 政策就能通過、解除 M6 release blocker。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 現況 vendor-ffmpeg 狀態
|
|
||||||
|
|
||||||
讀 `Makefile` 對應 target,目前三平台抓法如下:
|
|
||||||
|
|
||||||
| 平台 | 來源 URL | License 狀態 | 腳本檢查 |
|
|
||||||
|------|---------|-------------|---------|
|
|
||||||
| **macOS x86_64** | `https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip` | **GPL(含 `--enable-gpl --enable-libx264 --enable-libx265`)** | Makefile 會 `grep --enable-gpl`,預設擋下,只在 `VISIONA_ALLOW_GPL_FFMPEG=1` 時放行,檔案大小約 77MB |
|
|
||||||
| **Windows x86_64** | `https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip` | **GPL**(檔名含 `-gpl-`) | Makefile 有 `WARNING: BtbN 為 GPL build;license 由 PM 最終確認` 註解 |
|
|
||||||
| **Linux x86_64** | `https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz` | **GPL**(johnvansickle 只出 GPL static release) | Makefile 有 `WARNING: johnvansickle build 為 GPL build;正式發佈前需改用 LGPL 來源` 註解 |
|
|
||||||
|
|
||||||
三個 target 都把 `ffmpeg`(單一 binary)複製到 `payload/{darwin,windows,linux}/bin/`。目前流程只拉 ffmpeg,不拉 ffprobe(M6 使用者目前的 video 解碼路徑只用 `ffmpeg`,但若未來要用 ffprobe 驗證 metadata,LGPL 候選源也都同時提供)。
|
|
||||||
|
|
||||||
關鍵觀察:**Windows 已經在用 BtbN,只要檔名從 `-win64-gpl.zip` 換成 `-win64-lgpl.zip` 就能解決 Windows**。成本最小的一個換法。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 三平台候選來源調查結果
|
|
||||||
|
|
||||||
### 2.1 Windows x86_64
|
|
||||||
|
|
||||||
| # | 候選來源 | URL | License | 靜態 | ffprobe | 檔案大小 | 更新頻率 | 推薦度 |
|
|
||||||
|---|---------|-----|---------|-----|---------|---------|---------|--------|
|
|
||||||
| 1 | **BtbN/FFmpeg-Builds(lgpl)** | `https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-lgpl.zip` | **純 LGPLv3**(`variants/defaults-lgpl.sh` 驗證:`FF_CONFIGURE="--enable-version3 --disable-debug"`,**沒有 `--enable-gpl`**,`LICENSE_FILE="COPYING.LGPLv3"`) | ✅ static | ✅ 有 | 196 MB(壓縮後) | **每日 12:00 UTC 自動 build**,保留 14 天 + 每月月尾 build 保留 2 年 | ⭐⭐⭐⭐⭐ **首選** |
|
|
||||||
| 2 | BtbN/FFmpeg-Builds(lgpl-shared) | `…-win64-lgpl-shared.zip` | LGPLv3 | ⚠️ shared(dll 分開) | ✅ | 87 MB | 同上 | ⭐⭐ 靜態才省事,不推薦 |
|
|
||||||
| 3 | gyan.dev | `https://www.gyan.dev/ffmpeg/builds/` | **全部 GPLv3**(官方 README 原文:All builds are 64-bit, static and licensed as GPLv3) | ✅ | ✅ | — | 每月 | ❌ **不符合** |
|
|
||||||
| 4 | ffbinaries | `https://github.com/ffbinaries/ffbinaries-prebuilt` | 未標明 | ✅ | ✅ | 54 MB | **停更中**(最後 release v6.1, 2023-12-28) | ❌ 2+ 年未更新,license 不清 |
|
|
||||||
|
|
||||||
**驗證過程:**
|
|
||||||
1. 用 `gh release view latest --repo BtbN/FFmpeg-Builds` 確認 `ffmpeg-master-latest-win64-lgpl.zip` 存在、大小 196877693 bytes ≈ 196 MB。
|
|
||||||
2. 用 `gh api repos/BtbN/FFmpeg-Builds/contents/variants/defaults-lgpl.sh` 驗證 LGPL variant 的 configure flags 就是 `--enable-version3 --disable-debug`,**沒有 `--enable-gpl`**,**License file 指向 `COPYING.LGPLv3`**。
|
|
||||||
3. 用 `gh api repos/BtbN/FFmpeg-Builds/contents/scripts.d/50-x264.sh` 驗證 x264 library script 有 `[[ $VARIANT == lgpl* ]] && return -1` 的 gating,**LGPL variant 不會 link libx264**;x265 同樣 gated。
|
|
||||||
4. 用 `gh api .../util/repack_latest.sh` 確認 BtbN 打包時是整包 `bin/ffmpeg.exe` + `bin/ffprobe.exe` + `doc/` + `LICENSE.txt`。
|
|
||||||
|
|
||||||
**推薦:方案 1(BtbN LGPL static)**。Windows 幾乎零遷移成本——現有 Makefile 已經在用 BtbN,只要改檔名 `-gpl` → `-lgpl` 即可,連 curl / unzip 邏輯都不用改。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.2 Ubuntu x86_64(Linux x86_64 static)
|
|
||||||
|
|
||||||
| # | 候選來源 | URL | License | 靜態 | ffprobe | 檔案大小 | 更新頻率 | 推薦度 |
|
|
||||||
|---|---------|-----|---------|-----|---------|---------|---------|--------|
|
|
||||||
| 1 | **BtbN/FFmpeg-Builds(lgpl)** | `https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-lgpl.tar.xz` | **純 LGPLv3**(同 Windows 驗證) | ✅ static | ✅ 有 | **128 MB**(壓縮 tar.xz) | 每日 12:00 UTC | ⭐⭐⭐⭐⭐ **首選** |
|
|
||||||
| 2 | BtbN(n7.1-lgpl 穩定分支) | `…-linux64-lgpl-7.1.tar.xz` | LGPLv3 | ✅ | ✅ | 106 MB | 每日 build 但對應 ffmpeg 7.1 release 分支 | ⭐⭐⭐⭐ **次選**(更穩定,不追 master) |
|
|
||||||
| 3 | BtbN(n8.1-lgpl 穩定分支) | `…-linux64-lgpl-8.1.tar.xz` | LGPLv3 | ✅ | ✅ | 127 MB | 每日 | ⭐⭐⭐⭐ 對應 ffmpeg 8.1 穩定分支 |
|
|
||||||
| 4 | johnvansickle.com(現況) | `https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz` | **GPLv3**(官方頁面明示含 libx264/libx265) | ✅ | ✅ | ~40 MB | 不定期 | ❌ **GPL 不符合** |
|
|
||||||
|
|
||||||
**驗證過程:**
|
|
||||||
1. BtbN 同時產出 master-latest、n7.1、n8.1 三條線,都有 LGPL 變體。release assets 透過 `gh release view` 完整列出。
|
|
||||||
2. ffmpeg-master-latest-linux64-lgpl 未壓縮後會大幅膨脹(~400 MB 含所有 lib 與 doc),但我們只需要 `bin/ffmpeg` + `bin/ffprobe` 兩支,實際複製到 payload 的大小仍在 80-100 MB 級別(和現況 GPL 版差不多)。
|
|
||||||
3. johnvansickle 無 LGPL variant(已驗證其 download page),不是可換來源。
|
|
||||||
|
|
||||||
**推薦:方案 1 或 2**。若要更穩定,選 `n7.1-latest-linux64-lgpl-7.1.tar.xz`(綁定 7.1 release 分支、仍每日更新),相較 master 減少隨機 regression 風險。遷移成本同樣很低,把 Makefile 的 `FFMPEG_URL_LINUX` 從 johnvansickle 換成 BtbN 這個 URL,並把 tar 解壓路徑的 strip-components 調整即可。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.3 macOS x86_64
|
|
||||||
|
|
||||||
| # | 候選來源 | URL | License | 靜態 | ffprobe | 更新頻率 | 推薦度 | 備註 |
|
|
||||||
|---|---------|-----|---------|-----|---------|---------|--------|------|
|
|
||||||
| 1 | **evermeet.cx(現況)** | `https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip` | **GPL**(configure 含 `--enable-gpl --enable-libx264 --enable-libx265`) | ✅ static | ✅(另一個下載點) | 每日 snapshot + 每月 release | ❌ **不符合** | 目前 Makefile 來源 |
|
|
||||||
| 2 | gyan.dev | — | — | — | — | — | ❌ | **只做 Windows** |
|
|
||||||
| 3 | BtbN/FFmpeg-Builds | — | — | — | — | — | ❌ | **官方 README 明示「Static Windows (x86_64) and Linux (x86_64) Builds」,macOS 不在清單內** |
|
|
||||||
| 4 | **Martin-Riedl(ffmpeg.martin-riedl.de)** | `https://ffmpeg.martin-riedl.de/` | **GPL**(官方描述:uses commonly used codecs like **x264 and x265**) | ✅ static | ✅ | 每日 snapshot | ❌ **GPL** 且 Apple 已宣布 macOS Intel 停更(2026-01 停止 snapshot、2027-01 停止 release) | 即使能用也要半年內找 sunset 替代 |
|
|
||||||
| 5 | ColorsWind/FFmpeg-macOS | `https://github.com/ColorsWind/FFmpeg-macOS` | LGPLv2 | ❌ **shared**(`--enable-shared --disable-static`) | 未明 | **停更**(最後 release 2022-05 n5.0.1-patch3) | ❌ 4 年未更新、非 static | — |
|
|
||||||
| 6 | osxexperts.net | `http://www.osxexperts.net/` | GPL(含 libx264/libx265),且頁面聲明「for educational purposes only」 | ✅ | ✅ | 不定期 | ❌ **GPL + 聲明禁商用** | 分發會有問題 |
|
|
||||||
| 7 | ffbinaries/ffbinaries-prebuilt | — | 不明(未標示) | ✅ | ✅ | **停更**(v6.1, 2023-12-28) | ❌ 2+ 年沒更新 | — |
|
|
||||||
| 8 | eugeneware/ffmpeg-static | — | **抓取 evermeet.cx + osxexperts.net** | — | — | — | ❌ **實際來源仍是 GPL** | 只是 wrapper |
|
|
||||||
| 9 | **MacPorts ffmpeg -gpl2 -gpl3 -nonfree** | `sudo port install ffmpeg -gpl2 -gpl3 -nonfree` | **LGPLv2.1+**(port variant 可關掉 GPL) | ❌ MacPorts 預設 shared | 要自己 build | — | ❌ **不是 prebuilt binary 來源**,須本機編譯 | 可能作為自 build 參考 |
|
|
||||||
| 10 | **Homebrew ffmpeg** | `brew install ffmpeg` | 預設含 libx264(GPL);若要 LGPL 需自己 build formula | ❌ shared,且有 dylib 相依 `/opt/homebrew` | 可 | — | ❌ **不是 static,不是 LGPL** | — |
|
|
||||||
|
|
||||||
**結論:三個平台只有 macOS x86_64 找不到符合全部條件(純 LGPL + static + x86_64 + ffmpeg & ffprobe 同時有 + 仍在維護)的現成下載源。**
|
|
||||||
|
|
||||||
即使用最寬鬆的標準去找,所有 macOS 來源都至少踩到一個雷:
|
|
||||||
- evermeet.cx / gyan.dev / Martin-Riedl / osxexperts → **GPL**
|
|
||||||
- ColorsWind / Homebrew → **shared 不是 static**
|
|
||||||
- ColorsWind / ffbinaries → **停更 2+ 年**
|
|
||||||
- MacPorts → **不是 prebuilt,要本機編譯**
|
|
||||||
- osxexperts → **聲明 educational only**
|
|
||||||
- BtbN / gyan.dev → **根本不出 macOS**
|
|
||||||
|
|
||||||
更要命的是,Martin-Riedl 已經宣布 2026-01 停止 macOS Intel snapshot build、2027-01 停止 release build(Apple 自己停產 Intel Mac 的連鎖效應)。即使我們退而求其次找到勉強能用的 macOS GPL 源,未來 6-18 個月內 macOS x86_64 的 prebuilt ffmpeg 都會嚴重萎縮。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 解碼能力驗證(對 BtbN LGPL build)
|
|
||||||
|
|
||||||
這裡要回答一個關鍵疑問:**LGPL build 拿掉 libx264/libx265 之後,還能不能解碼 .mp4 / .avi / .mov / .mpeg / .mpg?**
|
|
||||||
|
|
||||||
**答案:可以,而且完全沒問題。**
|
|
||||||
|
|
||||||
### 3.1 ffmpeg 的 decoder vs encoder 授權分離
|
|
||||||
|
|
||||||
- **libx264 / libx265 是獨立的外部 library**,是 **GPL** 授權的 **H.264 / H.265 encoder**。LGPL build 只是不 link 這兩個外部 library,**不影響 ffmpeg 內部的 decoder**。
|
|
||||||
- **libavcodec(ffmpeg 的 codec layer)內建的 decoder 都是 LGPL 授權的**,包括:
|
|
||||||
- `h264`(native H.264 decoder,自 ffmpeg 0.5 起內建,**與 libx264 無關**)
|
|
||||||
- `hevc`(native H.265 decoder)
|
|
||||||
- `aac`(native AAC decoder)
|
|
||||||
- `mpeg2video`(MPEG-2 decoder)
|
|
||||||
- `mpeg4`(MPEG-4 Part 2 decoder,涵蓋 DivX/Xvid 檔案)
|
|
||||||
- `mpeg1video`(MPEG-1 decoder)
|
|
||||||
- `mjpeg`(Motion JPEG)
|
|
||||||
- `prores`(ProRes decoder,for .mov)
|
|
||||||
- `flv1`(Sorenson Spark)
|
|
||||||
- 以及所有 AAC / MP3 / Vorbis / Opus / FLAC 等 audio decoder
|
|
||||||
|
|
||||||
這些都不受 `--enable-gpl` 開關影響,在 **configure 時 `--disable-everything` 之後還要主動 `--disable-decoder=h264` 才會拿掉**,預設一定會編進去。
|
|
||||||
|
|
||||||
### 3.2 容器格式(demuxer)也都在 LGPL 範圍
|
|
||||||
|
|
||||||
- `mov` / `mp4` / `m4a`(同一個 demuxer)→ LGPL,預設啟用
|
|
||||||
- `avi` → LGPL,預設啟用
|
|
||||||
- `mpegts` / `mpeg` → LGPL,預設啟用
|
|
||||||
- `matroska`(.mkv)→ LGPL,預設啟用
|
|
||||||
|
|
||||||
### 3.3 使用者需求格式 × BtbN LGPL build 對照表
|
|
||||||
|
|
||||||
| 輸入檔 | 容器 (demuxer) | video codec | audio codec | BtbN LGPL 支援 |
|
|
||||||
|-------|---------------|-------------|-------------|----------------|
|
|
||||||
| `.mp4` | mp4/mov | H.264 (native `h264`) | AAC (native `aac`) | ✅ |
|
|
||||||
| `.mp4` (H.265) | mp4/mov | HEVC (native `hevc`) | AAC | ✅ |
|
|
||||||
| `.avi` | avi | MPEG-4 Part 2 (`mpeg4`) / DivX / Xvid / H.264 | MP3 (`mp3` native) / AC-3 | ✅ |
|
|
||||||
| `.mov` | mov | H.264 / ProRes (`prores`) / H.265 | AAC / PCM | ✅ |
|
|
||||||
| `.mpeg` / `.mpg` | mpeg(ps) | MPEG-1 (`mpeg1video`) / MPEG-2 (`mpeg2video`) | MP2 (`mp2` native) / AC-3 | ✅ |
|
|
||||||
| `.mkv` | matroska | H.264 / H.265 / AV1 (`dav1d` or native `av1`) / VP9 (`libvpx-vp9`) | Opus / Vorbis / AAC | ✅ |
|
|
||||||
|
|
||||||
**5/5 格式全數 LGPL decoder 可解。**
|
|
||||||
|
|
||||||
### 3.4 為什麼 BtbN LGPL build 的檔案還是很大(196 MB Windows / 128 MB Linux)
|
|
||||||
|
|
||||||
因為它仍然 link 了很多 **LGPL-safe 的 extra library**(從 `scripts.d/` 可見):
|
|
||||||
- `libopenh264`(Cisco 的 LGPL H.264 **encoder**——如果產品未來要編碼 H.264,這個是唯一合規選項)
|
|
||||||
- `libvpx`(VP8/VP9 encoder + decoder)
|
|
||||||
- `libaom`(AV1 encoder)
|
|
||||||
- `libdav1d`(AV1 decoder,比 aom 解碼快)
|
|
||||||
- `libopus` / `libvorbis` / `libmp3lame`(audio encoder/decoder)
|
|
||||||
- `libass` / `libfreetype` / `fribidi`(字幕渲染)
|
|
||||||
- `libbluray` / `libtheora` / `libwebp` / `libvpl` / `vulkan` / `openssl`...
|
|
||||||
|
|
||||||
這些對 visionA-local 其實都用不到(我們只做解碼),但 BtbN 統一打包也不是壞事——payload 的 77 MB → 128 MB(Linux)或 77 MB → 196 MB(Windows)多出 50-120 MB 是 trade-off,若要壓縮可以用 `ffmpeg configure --disable-everything --enable-decoder=h264,hevc,aac,mp3,mpeg2video,mpeg4,...` 自 build 降到 20-30 MB,但這是**優化題不是 release blocker**,可留到 M8 處理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 結論
|
|
||||||
|
|
||||||
### 4.1 三方案比較表
|
|
||||||
|
|
||||||
| 方案 | 三平台涵蓋 | 工時 | 風險 | M6 release blocker 何時解除 | 推薦 |
|
|
||||||
|------|----------|------|------|--------------------------|------|
|
|
||||||
| **A. 全現成 LGPL binary** | ❌ **做不到**(macOS 沒來源) | 0.5 人天 | macOS 無法通過 LGPL 政策檢查 | ⚠️ 永遠無法解除 | ❌ 不可行 |
|
|
||||||
| **B. 混合:Win/Linux 用 BtbN LGPL,macOS 自 build** | ✅ 三平台 | **1-1.5 人天**(主要是 macOS 自 build CI pipeline;Win/Linux 改 URL 約 1 小時) | macOS 自 build 需在 GitHub Actions 跑、偶爾要維護 | 1.5 人天後 | ⭐⭐⭐⭐⭐ **推薦** |
|
|
||||||
| **C. 全部自 build LGPL**(三平台 CI matrix) | ✅ 三平台 | **2-3 人天** | 三平台 CI 都要寫 + 除錯 + 維護 | 2.5-3 人天後 | ⭐⭐ 工時最高,換到最乾淨但投資報酬率差 |
|
|
||||||
| **D. 放棄 LGPL,維持 GPL + 法律聲明** | ✅ 三平台(現況) | 0 | 商業模式被 GPL copyleft 鎖住:如果產品任何非 ffmpeg 部分要走閉源或商用訂閱,法務可能擋下;需在 About 頁面露出完整 GPL 聲明 + 提供對應 ffmpeg 原始碼下載連結 | 取決於法務 review,時程不可控 | ⚠️ Fallback,不建議 |
|
|
||||||
|
|
||||||
### 4.2 推薦方案:B(混合)
|
|
||||||
|
|
||||||
**具體做法(給未來 refactor 的人看):**
|
|
||||||
|
|
||||||
1. **Windows**(約 5 分鐘)
|
|
||||||
- 改 `Makefile` 的 `FFMPEG_URL_WINDOWS` 變數:
|
|
||||||
```
|
|
||||||
FFMPEG_URL_WINDOWS := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-lgpl.zip
|
|
||||||
```
|
|
||||||
- zip 內路徑仍然是 `ffmpeg-master-latest-win64-lgpl/bin/ffmpeg.exe`,現有 Python 解壓邏輯只需把 `ffmpeg.exe` 的 zip member 前綴對應過去,邏輯一致。
|
|
||||||
- 同步拉 `ffprobe.exe`(未來 M8 可能用到;現在可選)。
|
|
||||||
|
|
||||||
2. **Linux**(約 10 分鐘)
|
|
||||||
- 改 `FFMPEG_URL_LINUX`:
|
|
||||||
```
|
|
||||||
FFMPEG_URL_LINUX := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz
|
|
||||||
```
|
|
||||||
(選 n7.1 分支而非 master,追求穩定)
|
|
||||||
- tar.xz 內路徑是 `ffmpeg-n7.1-latest-linux64-lgpl-7.1/bin/ffmpeg`,現有 `--strip-components=1` 需改成 `--strip-components=2` 或抓特定 path,小幅調整。
|
|
||||||
|
|
||||||
3. **macOS**(約 1-1.5 人天,主要工作)
|
|
||||||
- 新建 `.github/workflows/build-ffmpeg-macos-lgpl.yml`,在 `macos-13` runner 上跑:
|
|
||||||
```
|
|
||||||
- 抓 ffmpeg release tarball(7.1)
|
|
||||||
- ./configure --prefix=/tmp/ff --enable-version3 --disable-debug
|
|
||||||
--disable-doc --disable-ffplay --pkg-config-flags=--static
|
|
||||||
--enable-openssl --extra-ldflags="-Wl,-search_paths_first"
|
|
||||||
(不加 --enable-gpl / --enable-libx264 / --enable-libx265)
|
|
||||||
(選擇性:--enable-libopenh264 --enable-libvpx --enable-libopus
|
|
||||||
--enable-libmp3lame 以維持 feature parity,但 decoder 層用不到,可全省)
|
|
||||||
- make -j && make install
|
|
||||||
- codesign + notarize(可 ad-hoc sign 先跑過;正式發佈時接 Apple Developer cert)
|
|
||||||
- 打包 ffmpeg + ffprobe → tar.xz upload 到 visionA-local 的自有 GitHub Release(例如 `ffmpeg-lgpl-macos/v7.1/`)
|
|
||||||
- nightly 或 weekly schedule 自動跑
|
|
||||||
```
|
|
||||||
- Makefile 的 `FFMPEG_URL_DARWIN` 指向我們自己的 release URL(`github.com/<org>/visiona-local/releases/download/ffmpeg-lgpl-macos-v7.1/ffmpeg-macos-x86_64.tar.xz`),與 Windows/Linux 流程保持一致。
|
|
||||||
- 驗收重點:用 `ffmpeg -version` 檢查 `configuration:` 那行不含 `--enable-gpl` 也不含 `libx264 / libx265`;用 `ffmpeg -decoders` 檢查 `h264`, `hevc`, `aac`, `mpeg2video`, `mpeg4` 五個 decoder 存在。
|
|
||||||
|
|
||||||
### 4.3 給使用者的直接答案
|
|
||||||
|
|
||||||
> **Windows 和 Ubuntu 都找到能直接用的 LGPL 現成 binary**(BtbN 每天自動 build,已經在用 BtbN 了,把 Windows 的檔名從 `-gpl` 換成 `-lgpl` 就行;Linux 從 johnvansickle 換到 BtbN LGPL variant 也只要改一行 URL)。**macOS x86_64 則沒有任何現成 LGPL 來源可用**——evermeet / gyan / Martin-Riedl / osxexperts / ColorsWind 等全部都有雷(GPL、shared、停更、或商用限制),BtbN 官方根本不出 macOS build。
|
|
||||||
>
|
|
||||||
> 所以**不可能用「全現成 binary」把三平台都搞定**。**務實解法是「Windows + Linux 用 BtbN LGPL 現成 binary(兩個改 URL 工作),macOS 自 build 一支 LGPL 靜態 binary 放到我們自己的 GitHub Release」**。總工時約 1-1.5 人天(主要是 macOS 的 GitHub Actions pipeline),只要這個一次做好,未來 macOS LGPL binary 完全自主可控,不會被上游來源消失影響。
|
|
||||||
>
|
|
||||||
> 這個方案也正好回應 Martin-Riedl 2026-01 停止 macOS Intel snapshot 的公告——即使我們今天妥協用 GPL,6-18 個月內 macOS x86_64 的 prebuilt ffmpeg 來源都會萎縮,**自 build 是遲早要做的事,提前做反而省未來一次遷移**。
|
|
||||||
>
|
|
||||||
> **技術上需要確認的:H.264 / H.265 / AAC / MPEG-2 / MPEG-4 的解碼不受 `--enable-gpl` 影響**(libavcodec 內建 native decoder 都是 LGPL,`libx264` 只是編碼器),所以 `.mp4 / .avi / .mov / .mpeg / .mpg` 五種格式的解碼在 LGPL build 上 100% 可用,已經用 BtbN 的 build script 原始碼逐一驗證過。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 待使用者決策的問題
|
|
||||||
|
|
||||||
1. **要走方案 B 還是方案 C?**
|
|
||||||
- 方案 B:工時少 50%,但維護上要同時盯兩條路(macOS 自 build pipeline + Windows/Linux 的 BtbN 外部來源)。
|
|
||||||
- 方案 C:工時多 50%,三平台一致都是自 build,未來完全自主,但前期投資大,且未來 ffmpeg 有新 release 時要自己追。
|
|
||||||
|
|
||||||
2. **macOS 自 build 的 feature set 要到哪裡?**
|
|
||||||
- **最小版**:只開 decoder(`--disable-everything --enable-decoder=h264,hevc,aac,mp2,mp3,mpeg1video,mpeg2video,mpeg4,mjpeg,prores --enable-demuxer=mov,avi,mpegts,mpeg --enable-protocol=file --enable-filter=scale,format`),binary 大小 ~15-25 MB。
|
|
||||||
- **中等版**:跟 BtbN LGPL 對齊,包含 libopenh264/libvpx/libaom/libopus/libmp3lame 等 LGPL-safe extra library,binary ~80-120 MB。
|
|
||||||
- 我建議先做**最小版**(因為 visionA-local 只解碼 + 只處理已知幾個格式),但未來若有新需求(例如內嵌推流或錄影),再切到中等版。
|
|
||||||
|
|
||||||
3. **BtbN 的「master 分支每日 build」vs「n7.1/n8.1 穩定分支每日 build」要選哪個?**
|
|
||||||
- 建議選 **n7.1-latest**(固定在 ffmpeg 7.1 release 分支、但每日把 bugfix backport 進來)。master 版每天可能踩到還沒 release 的 API 改動或 regression,對 production 太冒險。
|
|
||||||
|
|
||||||
4. **macOS 自 build 的 GitHub Release 要放在哪裡?**
|
|
||||||
- 選項 1:visiona-local repo 自己的 release(tag 類似 `vendor-ffmpeg-lgpl-macos-v7.1-20260414`)。
|
|
||||||
- 選項 2:專門開一個 `visionA-vendor-artifacts` repo。
|
|
||||||
- 建議選項 1,簡單。
|
|
||||||
|
|
||||||
5. **要不要同步拉 ffprobe?**
|
|
||||||
- 目前 Makefile 只拉 `ffmpeg`,但 BtbN 的 tar 內含 `ffmpeg` + `ffprobe` 兩支,多拉一支是 0 成本(幾 MB)。未來若要在 server/scripts 用 ffprobe 檢查影片 metadata(例如驗證長度、bitrate、解析度),已經在 payload 裡。建議**都拉**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 附錄:原始碼驗證紀錄(供未來審閱)
|
|
||||||
|
|
||||||
### 6.1 BtbN `variants/defaults-lgpl.sh` 完整內容
|
|
||||||
```
|
|
||||||
FF_CONFIGURE="--enable-version3 --disable-debug"
|
|
||||||
FF_CFLAGS=""
|
|
||||||
FF_CXXFLAGS=""
|
|
||||||
FF_LDFLAGS=""
|
|
||||||
GIT_BRANCH="master"
|
|
||||||
LICENSE_FILE="COPYING.LGPLv3"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 BtbN `variants/defaults-gpl.sh` 完整內容
|
|
||||||
```
|
|
||||||
FF_CONFIGURE="--enable-gpl --enable-version3 --disable-debug"
|
|
||||||
FF_CFLAGS=""
|
|
||||||
FF_CXXFLAGS=""
|
|
||||||
FF_LDFLAGS=""
|
|
||||||
GIT_BRANCH="master"
|
|
||||||
LICENSE_FILE="COPYING.GPLv3"
|
|
||||||
```
|
|
||||||
|
|
||||||
**差別只有兩處**:LGPL 少一個 `--enable-gpl` flag、LICENSE_FILE 從 GPLv3 換成 LGPLv3。其餘一致。
|
|
||||||
|
|
||||||
### 6.3 BtbN `scripts.d/50-x264.sh` 的 LGPL gating
|
|
||||||
```bash
|
|
||||||
ffbuild_enabled() {
|
|
||||||
[[ $VARIANT == lgpl* ]] && return -1
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
對應的 `50-x265.sh` 有相同 pattern。這證明 LGPL variant build 時這兩個 library 會被 skip,最終的 ffmpeg binary **不會** link libx264 / libx265。
|
|
||||||
|
|
||||||
### 6.4 BtbN 官方 README 摘錄(platform 支援)
|
|
||||||
> Static Windows (x86_64) and Linux (x86_64) Builds
|
|
||||||
> Supported targets: win64, win32, winarm64, linux64, linuxarm64
|
|
||||||
|
|
||||||
**macOS 不在清單內**,證實 BtbN 不出 macOS。
|
|
||||||
|
|
||||||
### 6.5 evermeet.cx 的 GPL configure 證據
|
|
||||||
```
|
|
||||||
--enable-gpl --enable-libx264 --enable-libx265 --enable-libxavs
|
|
||||||
--enable-libxvid --enable-libvidstab --enable-libopenh264 ...
|
|
||||||
```
|
|
||||||
`--enable-gpl` 直接寫在 configure line 裡,明確 GPL build。
|
|
||||||
|
|
||||||
### 6.6 gyan.dev 官方 README 原文
|
|
||||||
> All builds are 64-bit, static and licensed as GPLv3
|
|
||||||
|
|
||||||
明確全線 GPL,沒有 LGPL 變體。
|
|
||||||
|
|
||||||
### 6.7 Martin-Riedl 官方描述
|
|
||||||
> uses commonly used codecs (like x264 and x265)
|
|
||||||
|
|
||||||
`x264` 是 GPL encoder library,只要 build 有 link 進來整個 ffmpeg 就會變 GPL。
|
|
||||||
|
|
||||||
### 6.8 BtbN release 驗證指令(可重現)
|
|
||||||
```bash
|
|
||||||
gh release view latest --repo BtbN/FFmpeg-Builds --json assets \
|
|
||||||
--jq '.assets[] | select(.name | test("lgpl")) | {name, size, url}'
|
|
||||||
```
|
|
||||||
|
|
||||||
輸出確認了 `ffmpeg-master-latest-win64-lgpl.zip`(196 MB)、`ffmpeg-master-latest-linux64-lgpl.tar.xz`(128 MB)、以及 n7.1 / n8.1 穩定分支版本都存在、每日更新。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**以上。待使用者決策後,Architect 可進一步寫出 M6-refactor 的 TDD(正式決策紀錄 + Makefile 變更 patch + macOS CI workflow)。**
|
|
||||||
@ -1,515 +0,0 @@
|
|||||||
# PM 交叉審閱 TDD v2(2026-04-14)
|
|
||||||
|
|
||||||
> 審閱者:PM Agent
|
|
||||||
> 被審物:`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/TDD-v2.md` + `v2/` 全 8 份子檔
|
|
||||||
> 對照基準:`PRD-v2.md`(484 行)+ R5 五輪決策 + R5-D 補充決策
|
|
||||||
> 審閱範圍:需求面完整度、R5 / R5-D 落地、PM 5 懸念的技術回答、體驗紅線
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
- **總結論:需小改後通過**(不是大改、不阻擋整體方向)
|
|
||||||
- **問題數**:Major 4 個 / Minor 5 個
|
|
||||||
- **是否阻擋進開發**:**部分阻擋**——Major #1 / #2(R5-D1 / R5-D2 / R5-D3 未落地)必須在進 M8-4/M8-5 開發前補上;其餘 Major 可在 M8-10 前修正
|
|
||||||
- **整體品質**:TDD v2 對 R5 主決策的落地完整度 **90%**;對 R5-D 補充決策的落地完整度 **0%**(這是 PM 的主要不滿);對 R5 主決策以外的技術設計(state machine、LogBuffer、CORS、ffmpeg LGPL)品質很高
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 需求完整性檢查(對 PRD v2)
|
|
||||||
|
|
||||||
| PRD v2 章節 | 要求 | TDD v2 落地位置 | 檢查結果 |
|
|
||||||
|------------|------|---------------|---------|
|
|
||||||
| §4.1 保留功能 (8 項) | device scan / 模型管理 / 批次影像上傳 / camera / 上傳影片 / MJPEG / WebSocket / 系統 API / i18n / Dark Mode / Log 持久化 | `code-reuse-v2.md`(沿用 85-95%)+ `deletions.md` §1-6 明確區分保留 vs 刪除 | ✅ 無誤刪。`deletions.md` §2.6/2.7 小心處理 manager_test / api_e2e_test |
|
|
||||||
| §4.2 砍除 9 項 | URL endpoint / url_handler / yt-dlp / 前端 URL tab / Mock 全部 / webview 載 Next.js / tray / first-run 模式選擇 / US-2 Mock | `deletions.md` §1-6 + `milestone-plan.md` M8-1/M8-2 逐檔逐行 | ✅ 全部對應;`deletions.md` §8 的驗收 grep 是漂亮的防呆 |
|
|
||||||
| §4.3 新增 6 項 (N1-N6) | Wails 控制台 / 自動開瀏覽器 / Offline Overlay / boot-id / CORS / watchServer Error state | `control-panel.md`(N1 / N2)+ `web-ui-offline-overlay.md`(N3)+ `server-lifecycle.md` §9(N4)+ `cors-security.md`(N5)+ `control-panel.md` §4.7(N6) | ✅ 六項皆有專屬子檔 |
|
|
||||||
| §5 US-1 ~ US-7 AC | 每個 AC 應有技術對應 | 見 §C「US AC 追蹤表」 | ⚠️ 有 2 個 AC 沒對應(見 C)|
|
|
||||||
| §6 非功能 | 安裝檔 ≤ 185/165/165 MB、idle RAM ≤ 450 MB、首次推論、瀏覽器相容 | `milestone-plan.md` M8-10 有驗收 installer size;idle RAM 和首次推論時間**完全沒被估算** | ❌ **見 Major #3** |
|
|
||||||
| §7 第三方授權 | ffmpeg LGPL 方案 B(三平台)+ ffprobe + macOS commit + 授權檔 | `ffmpeg-lgpl.md` 完整落地 | ✅ 很完整,甚至連 `spctl` / `codesign` / BUILD.md reproducibility 都想到 |
|
|
||||||
| §8 新風險 N-R1/2/3/4 | 瀏覽器相容性 / macOS ffmpeg 維護 / 關窗誤解 / CI 測試分層 | TDD v2 §3 新增 R-v2-1 ~ R-v2-7 對應(N-R1 吸收進 R-v2-6,N-R2 吸收進 R-v2-2,N-R3 部分吸收進 AC-7.6 要求);N-R4 無明確對應 | ⚠️ **見 Minor #1**(N-R4 要求在 TDD v2 討論階段提出分層方案,TDD 只在 PM 懸念欄說「交 Testing」而沒給方案)|
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. R5 / R5-D 決策落地檢查
|
|
||||||
|
|
||||||
### B.1 R5 主決策(12 條)
|
|
||||||
|
|
||||||
| 決策 | TDD v2 落地位置 | 落地狀況 |
|
|
||||||
|------|---------------|---------|
|
|
||||||
| R5-1(動機 A+B+G,純 127.0.0.1)| `control-panel.md` §1-2、`server-lifecycle.md` §2.1、`cors-security.md` §3 | ✅ 完整 |
|
|
||||||
| R5-2(視窗關閉 = 結束 server)| `server-lifecycle.md` §2.3、§7 `OnBeforeClose` return false、§8 shutdown 順序 | ✅ 完整,且 main.go 明確寫出 OnBeforeClose hook,預留未來改動空間 |
|
|
||||||
| R5-3(維持砍 tray)| `server-lifecycle.md` §2.3 註解「不 hide-to-tray」、`TDD-v2.md` §0.1 | ✅ 不存在任何 tray code |
|
|
||||||
| R5-4(啟動自動開瀏覽器 + Settings 可關)| `control-panel.md` §4.6 ServerController.Start 末段、`server-lifecycle.md` §2.1 t=2.500 | ⚠️ **語意違反 R5-D3**(見 Major #1)|
|
|
||||||
| R5-5(控制台 scope)| `control-panel.md` §1 明列「不做業務 UI」 | ✅ 完整 |
|
|
||||||
| R5-5a(Mock 完全砍)| `deletions.md` §2 + §3 + §6、`milestone-plan.md` M8-2 | ✅ 涵蓋 Go / Wails app / 前端 / i18n / CLI flag 所有層 |
|
|
||||||
| R5-6(LGPL 方案 B)| `ffmpeg-lgpl.md` §1 表格 | ✅ |
|
|
||||||
| R5-6a(macOS decoder-only ~20 MB)| `ffmpeg-lgpl.md` §2.3 configure flags + §2.2 feature set | ✅ 驗收條件 `< 25 MB` 合理 |
|
|
||||||
| R5-6b(macOS binary commit)| `ffmpeg-lgpl.md` §5 layout + §6 `.gitignore` | ✅ 連 `!/vendor/ffmpeg/macos/**` 的 gitignore 順序陷阱都提到 |
|
|
||||||
| R5-6c(一起包 ffprobe)| `ffmpeg-lgpl.md` §2.2 末段 + §2.3 cp 指令 + §7 payload + §8 installer | ✅ 三平台全覆蓋 |
|
|
||||||
| R5-7(M7 Windows 先不管)| `milestone-plan.md` M8-10 註解「R5-7 同意先不管,這次順帶驗證」 | ✅ 沒偷跑 M7 Windows,但又不漏驗 |
|
|
||||||
| 共識 #14(boot-id)| `server-lifecycle.md` §9 + `web-ui-offline-overlay.md` | ✅ 端到端 |
|
|
||||||
|
|
||||||
### B.2 R5-D 補充決策(3 條)— **這是最大問題**
|
|
||||||
|
|
||||||
| 決策 | 應落地位置 | 落地狀況 |
|
|
||||||
|------|----------|---------|
|
|
||||||
| R5-D1:Server 崩潰時保留 OS 原生通知 | `control-panel.md` §4.7 watchServer Error state / `server-lifecycle.md` §6 watchServer 或 `control-panel.md` §4 ServerController.setState | ❌ **完全缺失**。`grep -ri 'osascript|notify-send|powershell.*notification' v2/` 零匹配。TDD 只 emit `server:error` Wails event 給控制台,沒有發系統原生通知 |
|
|
||||||
| R5-D2:Linux 預設 `openBrowserOnStart=OFF`,mac/Win = ON | `preferences.go`(預設值載入邏輯)或 `control-panel.md` §4.3 Preferences struct 預設 | ❌ **完全缺失**。TDD 只定義 `Preferences.OpenBrowserOnStart bool`,沒有 `NewDefaultPreferences()` 依 `runtime.GOOS` 分平台初值的邏輯。若 `preferences.json` 不存在,使用者第一次啟動在 Linux 上會用 Go zero value(false),剛好誤中,但這是 **碰巧對**,不是設計對 |
|
|
||||||
| R5-D3:每次 Start 成功都自動開瀏覽器(不只首次)| `control-panel.md` §4.6 ServerController.Start 末段 | ❌ **實作與決策相反**。現況是 `if c.app.prefs.OpenBrowserOnStart && !c.app.autoOpenedThisSession` — `autoOpenedThisSession` per-session-once 只會開一次,Restart 後不再開。**這和 R5-D3 「每次啟動都自動開」語意相反**。milestone-plan.md M8-9 step 3 甚至把「防呆:autoOpenedThisSession 確保 Restart 不會二次開瀏覽器」列為驗收條件,直接落地了**錯的語意** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. US AC 追蹤表(PRD §5 × TDD v2)
|
|
||||||
|
|
||||||
| AC | 需求 | TDD 對應 | 狀況 |
|
|
||||||
|----|------|---------|------|
|
|
||||||
| AC-1.1/1.2 | 安裝 ≤ 3 分 / 上限 5 分 | v1 沿用,TDD 未碰 | ✅ |
|
|
||||||
| **AC-1.3** | 首次啟動 Wails + server + 瀏覽器 ≤ 10 秒 | **未估算** | ❌ Major #3 |
|
|
||||||
| AC-1.4 | 無硬體時顯示 empty state | `deletions.md` §6.5 noDevices 文案、R-v2-7 | ✅ |
|
|
||||||
| AC-1.5 | Gatekeeper 引導 | 同 v1 沿用 | ✅ |
|
|
||||||
| AC-1.6 | 可關閉自動開瀏覽器 | Preferences.OpenBrowserOnStart | ✅(但受 R5-D2 / D3 牽連)|
|
|
||||||
| AC-2.1 | 日常啟動 ≤ 5 秒全就緒 | 未估算 | ⚠️ Minor #2 |
|
|
||||||
| AC-2.2 | 保留 i18n / dark mode 偏好 | 前端 localStorage 沿用 | ✅(但沒寫進 TDD) |
|
|
||||||
| AC-2.3 | 關過自動開瀏覽器後只開控制台 | Preferences + autoOpenedThisSession | ✅ |
|
|
||||||
| AC-3.7 | 使用者關瀏覽器後不發 toast / 不發 OS 通知 | TDD 未明確處理,`control-panel.md` 沒有 toast 入口,默認符合 | ⚠️ 和 R5-D1 衝突。AC-3.7 說「不發 OS 通知」,R5-D1 說「server 崩潰時保留 OS 通知」。**兩者 scope 不同**(AC-3.7 是裝置事件,R5-D1 是 server 崩潰),TDD 都沒處理,但兩者需要分開實作 |
|
|
||||||
| AC-4.5 | 拖放 .nef 只在瀏覽器有效 | `control-panel.md` §1 明列「不做業務」隱含 | ✅ |
|
|
||||||
| AC-4.6 | 8 個預設 .nef + 無 Model Zoo | v1 沿用 | ✅ |
|
|
||||||
| AC-5.1-5.6 | 瀏覽器 Workspace / 後端 camera pipeline / 不走 getUserMedia | v1 沿用 | ✅ |
|
|
||||||
| AC-6.1-6.4 | 5 種副檔名上傳影片 / 不支援 URL | `milestone-plan.md` M8-6 + `deletions.md` §5.1 前端 accept + backend handler patch | ✅ diff 清楚 |
|
|
||||||
| **AC-7.1** | 偵測斷線顯示 Offline Overlay | `web-ui-offline-overlay.md` § 全部 | ✅ |
|
|
||||||
| **AC-7.2** | Overlay 文案 + Retry | `web-ui-offline-overlay.md` §4.6 i18n | ✅ |
|
|
||||||
| **AC-7.3** | 控制台狀態變 Error + log panel 顯示錯誤 | `control-panel.md` §4.7 + §3 狀態 badge 紅 | ✅ |
|
|
||||||
| **AC-7.4** | Restart 後 boot-id 變 → reload | `server-lifecycle.md` §2.2 + `web-ui-offline-overlay.md` §4.3 | ✅ |
|
|
||||||
| AC-7.5 | 關 Wails → server 結束 → 相同 Overlay | `server-lifecycle.md` §2.3 | ✅ |
|
|
||||||
| AC-7.6 | Close 按鈕 tooltip / confirm 警語 | **未落地**(Design Agent 任務,TDD 未強制 Wails main.go 加 confirm) | ⚠️ Minor #3 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. PM 5 懸念的技術回答檢查
|
|
||||||
|
|
||||||
| # | PRD §11 問題 | 歸屬 | TDD 有無回答 |
|
|
||||||
|---|-------------|------|------------|
|
|
||||||
| 1 | 自動開瀏覽器 Settings 與 Web UI Settings 資料是否同一份 | Architect | ✅ `control-panel.md` §4.1 明確說「`visiona-local/preferences.go` 存 `<dataDir>/preferences.json`」— 這是**控制台側**的偏好;Web UI 側的 i18n / dark mode 仍用 browser localStorage(v1 沿用)。兩邊**分離**,PM 接受這個答案 |
|
|
||||||
| 2 | AC-1.3 10 秒預算是否可達 | Architect 估算 | ❌ **完全沒估算**。TDD 沒有任何段落討論「Wails 冷啟動 + Go server waitHealthy + 瀏覽器冷啟動」的時間預算分解。PRD 明確要求 Architect 驗證此預算 |
|
|
||||||
| 3 | idle RAM ≤ 450 MB 可達性 | Architect 實測 | ❌ **完全沒提**。TDD 沒有任何段落討論 idle RAM 估算(v2 砍了 Mock state 和 yt-dlp 子程序後的 baseline) |
|
|
||||||
| 4 | N-R4 CI / E2E 測試分層方案 | Testing Agent(現階段不審) | — 略過 |
|
|
||||||
| 5 | 「由 visionA-local 跑」常駐徽章 | Design Agent(現階段不審) | — 略過 |
|
|
||||||
|
|
||||||
→ **PM 5 懸念中的 2/3(AC-1.3、idle RAM)完全沒被回答。這兩題是 PRD §11 明列「交 Architect」的題目,Architect 沒回等於把球踢回給 PM。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. 臆測 / 誤刪 / 誤解清單
|
|
||||||
|
|
||||||
### E.1 臆測超出範圍
|
|
||||||
|
|
||||||
- **無**。TDD v2 沒有偷偷新增 LAN mode、tray fallback、daemon、auto-update、telemetry 等 R5 明確否決的功能。這點乾淨
|
|
||||||
|
|
||||||
### E.2 誤刪
|
|
||||||
|
|
||||||
- **無誤刪**。批次影像上傳(R5 共識 10 / PRD §4.1)在 `deletions.md` 中未被列入刪除清單,`code-reuse-v2.md` 也把它放在沿用區。`camera_handler.go` 只砍 `StartFromURL`,沒碰 `UploadBatch` 相關 handler
|
|
||||||
- **但有一個 risky 邊界**:`deletions.md` §1.1 說「若 `NewVideoSourceFromURL` **唯一呼叫者**是 StartFromURL → 一起砍」。實際 grep 顯示 `camera_handler.go:731` 還有 `NewVideoSourceFromURLWithSeek` 被本地上傳影片 seek 呼叫。**M8-1 執行者必須先把這個 grep 結果列出**,否則可能會誤砍本地影片 seek 功能。TDD `ffmpeg-lgpl.md` §11 已警示這點,但 `deletions.md` §1.1 的敘述「可從 RTSP stream 呼叫」不精準,實際是「從本地影片 seek 呼叫」
|
|
||||||
|
|
||||||
### E.3 誤解 R5 / R5-D
|
|
||||||
|
|
||||||
- **R5-4 理解錯誤**(**Major #1**):TDD 把「首次啟動自動開瀏覽器」實作成 per-session-once(`autoOpenedThisSession` flag),但 R5-D3 明確修正語意為「每次 Start Server 成功都開」。這個誤解在 `milestone-plan.md` M8-9 和 `control-panel.md` §4.6 各出現一次,是**系統性誤解**
|
|
||||||
- **R5-D1 / R5-D2 整組遺漏**(**Major #1** 包含)
|
|
||||||
- **R5-2 理解正確**:TDD 明確寫 `OnBeforeClose` return false、不 hide-to-tray、有 `shutdownGracePeriod` 5s → 10s 對齊 server 的討論。這點很到位
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. 體驗紅線檢查
|
|
||||||
|
|
||||||
### F.1 北極星指標「5 分鐘內瀏覽器跑第一幀推論」可達嗎
|
|
||||||
|
|
||||||
PRD §1.4 把「瀏覽器啟動時間」也納入 5 分鐘預算。TDD 沒有做端到端預算分解,但從元件看:
|
|
||||||
|
|
||||||
- Wails 冷啟動:~0.5-1 s(v1 經驗)
|
|
||||||
- Go server spawn + waitHealthy:1-3 s(依 Python sidecar 啟動)
|
|
||||||
- `OpenInBrowser("")`:系統 call ~0.1 s
|
|
||||||
- 瀏覽器冷啟動(第一次點開 Chrome/Safari/Edge):**3-8 s**(最大不確定性)
|
|
||||||
- Next.js static export 載入:~0.5-1 s
|
|
||||||
|
|
||||||
→ 在已開瀏覽器的情境下 5-6 s 可達 AC-2.1 的「日常啟動 ≤ 5 秒」;在冷啟動情境下 6-13 s **很可能超過 AC-1.3 的 10 秒上限**。
|
|
||||||
|
|
||||||
**PM 結論**:AC-1.3 的 10 秒上限在「使用者尚未開過瀏覽器」的情境下可能不可達。建議 Architect 在 TDD v2 增補一段預算分解,若確實超過,PRD 要放寬到 15 秒。這是進開發前必須釐清的事項(見 Major #3)。
|
|
||||||
|
|
||||||
### F.2 race condition:Wails 關閉視窗 → StopServer 過程中瀏覽器 tab
|
|
||||||
|
|
||||||
TDD v2 `R-v2-4` 已明確提到這個 race condition(0.5-5s 內瀏覽器可能看到 ECONNREFUSED 但 Overlay 還沒觸發),並分析為「實務上使用者關 Wails 通常也會關瀏覽器 tab,race 不構成實際問題;若真的沒關,15 s 後 Overlay 會出現」。**PM 認可這個取捨**,但有補充意見:
|
|
||||||
|
|
||||||
- **建議**:在 Wails OnBeforeClose 觸發前,先透過 WebSocket broadcaster 發一個 `server:shutdown-imminent` 訊息給所有連著的瀏覽器 tab(利用現有 `/ws/server-logs` 或 `/ws/devices/*` 通道),前端收到後立即顯示 Overlay,不需要等 15 s polling。這個成本低(server 端只 3-5 行、前端 store 新增一個 listener 即 10 行)
|
|
||||||
- 若 Architect 評估成本過高 → 接受現狀。這是 **Minor #4**,不阻擋進開發
|
|
||||||
|
|
||||||
### F.3 「關閉視窗 ≠ 結束 server」的心智負擔(N-R3)
|
|
||||||
|
|
||||||
PRD §8.2 N-R3 要求三處文案到位:
|
|
||||||
1. Wails Close 按鈕 tooltip / confirm dialog(AC-7.6)— **TDD 未落地**(main.go 只寫了 `OnBeforeClose: return false`,沒加 confirm 對話框邏輯;是否加是 Design Agent 的 scope,但 TDD 應該**預留 hook**)
|
|
||||||
2. Offline Overlay 文案引導 — `web-ui-offline-overlay.md` §4.6 ✅
|
|
||||||
3. First-Run 歡迎頁一句說明 — TDD 未提(但 v1 有,v2 沿用,推測 OK)
|
|
||||||
|
|
||||||
→ **Minor #3**:TDD 應在 `server-lifecycle.md` §7 的 main.go 範例裡保留 `OnBeforeClose` 可讀 `Preferences.ConfirmOnClose` 的 hook,而不是寫死 `return false`。這樣 Design Agent 後續加 confirm dialog 時不用再重寫 TDD
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. PM 對 Architect Q4 的回答
|
|
||||||
|
|
||||||
Architect 在 `server-lifecycle.md` §10 問:「`shutdownGracePeriod` 5s → 10s 的副作用?使用者頻繁開關 app 可能感覺變慢。」
|
|
||||||
|
|
||||||
**PM 回答**(基於使用者體驗基準):
|
|
||||||
|
|
||||||
| 問題 | 答案 |
|
|
||||||
|------|-----|
|
|
||||||
| 總 grace period 建議值 | **7 秒**(而非 10 秒),理由:Nielsen Norman 的研究指出 1 秒內 = 即時反饋、10 秒 = 使用者注意力臨界點;7 秒保留足夠 server 優雅結束餘量,又不會逼近「我是不是當機」的感受閾值 |
|
|
||||||
| 超過多久要顯示「停止中…」進度提示 | **1 秒**。Wails 收到關閉訊號後應立即(< 100 ms)顯示一個不可關閉的 modal「正在停止本機伺服器…」+ spinner,避免使用者看到「空白卡住」。這個 modal 在 7 秒後若 server 仍未退出,換文字為「伺服器未正常結束,正在強制關閉…」,之後 SIGKILL |
|
|
||||||
| 如果 7 秒真的不夠(server 端某些清理需要 > 7 秒)| 兩個選擇:(a) 降低 server 端清理時間(理想)(b) 把 server 端的 `shutdownFn` 10 s timeout 改為 6 s,與 Wails 7 s 留 1 s 緩衝 |
|
|
||||||
|
|
||||||
**建議**:採用 PM 的 7 秒 + modal 方案,這是比 Architect 提的「5-10 秒區間 emit event」更簡單(不需要 event 機制)且更符合 UX 基準的做法。Architect 若有技術面反對意見,請在使用者確認前提出。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. 問題清單
|
|
||||||
|
|
||||||
### Major(阻擋進開發,必須在 M8-4 / M8-5 開發前修)
|
|
||||||
|
|
||||||
#### Major #1:R5-D1 / R5-D2 / R5-D3 三條補充決策完全沒落地
|
|
||||||
|
|
||||||
**問題**:R5-D 三條是使用者在 2026-04-14 Design v2 產出後的補充決策,時間上**晚於 TDD v2 draft**,但 TDD v2 必須被修正以落地這三條。現況:
|
|
||||||
|
|
||||||
- **R5-D1(崩潰時保留 OS 原生通知)**:TDD 只在 Wails 控制台發 `server:error` event,沒叫 `osascript display notification` / `powershell New-BurntToastNotification` / `notify-send`
|
|
||||||
- **R5-D2(Linux 預設自動開瀏覽器 OFF)**:`preferences.go` 沒有依 `runtime.GOOS` 分平台 default 的邏輯
|
|
||||||
- **R5-D3(每次 Start 都開,不只首次)**:`autoOpenedThisSession` flag 和 milestone-plan M8-9 驗收條件明確是 per-session-once,**和決策相反**
|
|
||||||
|
|
||||||
**應加在**:
|
|
||||||
|
|
||||||
| 決策 | 子檔 | 修正位置 |
|
|
||||||
|------|------|---------|
|
|
||||||
| R5-D1 | `control-panel.md` §4.7(watchServer Error state)+ 新增 §4.8「OS 原生通知 helper」 | 新增 `visiona-local/notify_darwin.go` / `notify_windows.go` / `notify_linux.go` 三檔,在 `ctrl.setState(ServerStateError, ...)` 時呼叫 |
|
|
||||||
| R5-D2 | `control-panel.md` §4.3 Preferences 型別 + `v2/server-lifecycle.md` §2.1 載入流程 | 新增 `NewDefaultPreferences() Preferences`:`runtime.GOOS == "linux"` → `OpenBrowserOnStart: false`,否則 `true`。`preferences.go` 的 load 若檔不存在,用這個預設 |
|
|
||||||
| R5-D3 | `control-panel.md` §4.6 `ServerController.Start` 末段 + `milestone-plan.md` M8-9 step 3/5 驗收條件 | **砍 `autoOpenedThisSession` flag**,改為:只要 `prefs.OpenBrowserOnStart == true` 就每次 Start 成功後 `OpenInBrowser("")`。M8-9 驗收改為「多次按 Restart:每次都開新 tab(或 focus 既有 tab,瀏覽器行為決定)」 |
|
|
||||||
|
|
||||||
**阻擋等級**:Major,必須在 M8-4/M8-5 開發前修 TDD 子檔。因為這三條會影響 `preferences.go` / `server_control.go` / 新增的 notify_*.go 的介面簽名,若先開發再改會浪費時間
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Major #2:R5-D3 誤解已被寫進 milestone-plan M8-9 驗收條件
|
|
||||||
|
|
||||||
**問題**:`milestone-plan.md` 第 335 / 347 / 350 行把「Restart 不會二次開瀏覽器」列為**驗收成功**條件。這會在 Reviewer 審查 M8-9 時被當成「通過」,但其實這**違反 R5-D3**。
|
|
||||||
|
|
||||||
**修正**:M8-9 step 3 改寫,驗收條件改為:
|
|
||||||
- 首次啟動 Server:瀏覽器開 1 個 tab
|
|
||||||
- 手動按 Restart:瀏覽器開第 2 個 tab(或在瀏覽器自動重用既有 tab,視瀏覽器行為)
|
|
||||||
- 使用者在 Preferences 關掉 `openBrowserOnStart`:之後 Start/Restart 都不開瀏覽器
|
|
||||||
|
|
||||||
**阻擋等級**:與 Major #1 綁在一起修
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Major #3:PM 5 懸念中 AC-1.3(10 秒)與 idle RAM(≤ 450 MB)沒被 Architect 回答
|
|
||||||
|
|
||||||
**問題**:PRD §11 明確把這兩題交給 Architect「TDD v2 驗證」,但 TDD 所有 8 個子檔都找不到任何段落在討論這兩個預算。
|
|
||||||
|
|
||||||
**應加在**:
|
|
||||||
|
|
||||||
- **AC-1.3 10 秒可達性**:建議在 `TDD-v2.md` §1 或 `server-lifecycle.md` §2.1 後新增一節「冷啟動預算分解」:
|
|
||||||
```
|
|
||||||
Wails 冷啟動 ~0.5-1 s
|
|
||||||
migrateOldDataDirs ~0.01 s
|
|
||||||
startIPCServer ~0.05 s
|
|
||||||
seedUserDataDir ~0.02-2 s(首次安裝時 copy 8 個 .nef,約 73 MB)
|
|
||||||
ensurePythonRuntime ~0.5-3 s(首次啟動 bundled Python extract)
|
|
||||||
exec.Command spawn ~0.01 s
|
|
||||||
waitHealthy (Python sidecar 起) ~1-3 s
|
|
||||||
OpenInBrowser system call ~0.1 s
|
|
||||||
瀏覽器冷啟動 ~3-8 s(使用者尚未開瀏覽器)
|
|
||||||
Next.js SSG 首次載入 ~0.5-1 s
|
|
||||||
────────────────────────
|
|
||||||
首次安裝冷啟動總計 ~5.5-18 s
|
|
||||||
回訪情境(瀏覽器已開)~2.5-10 s
|
|
||||||
```
|
|
||||||
結論:**AC-1.3 的 10 秒上限在首次安裝 + 冷啟動瀏覽器情境下不可達**,建議 PRD 放寬到 15 秒,或在 PRD AC-1.3 明確註記「若瀏覽器尚未啟動,可能延長到 15 s」
|
|
||||||
|
|
||||||
- **idle RAM ≤ 450 MB**:建議在 `TDD-v2.md` §3 風險清單新增一條,或在 `control-panel.md` 新增一節「資源估算」:
|
|
||||||
```
|
|
||||||
Wails 殼 ~80-120 MB(WebView2 / WKWebView 基礎)
|
|
||||||
Go server 子程序 ~40-60 MB(Gin + 8 個預載入 .nef metadata)
|
|
||||||
Python sidecar ~150-220 MB(Python runtime + numpy + opencv + pyusb)
|
|
||||||
logs/ LogBuffer 等 ~5 MB
|
|
||||||
────────────────────
|
|
||||||
總計 ~275-405 MB
|
|
||||||
```
|
|
||||||
結論:**idle RAM ≤ 450 MB 應可達**,上限 550 MB 安全。PM 可以接受此估算作為 TDD 回答
|
|
||||||
|
|
||||||
**阻擋等級**:Major,必須在使用者確認 TDD v2 前補上。若 10 秒不可達,要決定是放寬 AC-1.3 還是改架構(例如 Wails 內建 WebView 搭配預先隱藏視窗的 fallback)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Major #4:shutdownGracePeriod 5s → 10s 的使用者體感取捨未解決
|
|
||||||
|
|
||||||
**問題**:TDD `server-lifecycle.md` §10-2 把這題列為「待確認」,但這是**體驗面**的問題,不是技術面,Architect 不該把球踢回使用者。
|
|
||||||
|
|
||||||
**PM 回答**:見 §G。採用 **7 秒 + 1 秒內顯示「停止中…」modal** 方案。
|
|
||||||
|
|
||||||
**應加在**:`server-lifecycle.md` §8 把 `shutdownGracePeriod` 改為 7 s,§5 ServerController.Stop 新增 `emit "server:stopping"` event,前端新增「stopping modal」元件(`control-panel.md` §5 action-bar.js 補),M8-5 驗收條件加「關閉視窗後 1 秒內看到停止中 modal」
|
|
||||||
|
|
||||||
**阻擋等級**:Major,但範圍小。可併入 M8-4 / M8-5 開發
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Minor(可先進開發,後續修)
|
|
||||||
|
|
||||||
#### Minor #1:N-R4(CI / E2E 測試分層)沒有方案
|
|
||||||
|
|
||||||
PRD §8.2 明確要求 Architect 在 TDD v2 討論階段提出分層方案。TDD 沒提。但這題現階段交 Testing Agent,暫不阻擋。
|
|
||||||
|
|
||||||
**建議**:在 `milestone-plan.md` M8-10 新增一段「測試分層建議」,或在 TDD v2 §3 風險區新增 R-v2-8 占位,標註「待 Testing Agent 審 TDD 時補方案」
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Minor #2:AC-2.1(日常啟動 ≤ 5 秒)沒估算
|
|
||||||
|
|
||||||
和 Major #3 同源。若 Major #3 補上冷啟動預算分解,順便也覆蓋 AC-2.1
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Minor #3:AC-7.6(Wails Close 按鈕 tooltip / confirm 警語)沒落地
|
|
||||||
|
|
||||||
TDD `server-lifecycle.md` §7 的 main.go 範例寫死 `OnBeforeClose: return false`。這等同「直接關,無警告」,和 PRD AC-7.6 要求的「明確寫『關閉此視窗會結束 Local Server』」衝突。
|
|
||||||
|
|
||||||
**建議**:TDD 應預留 hook,例如:
|
|
||||||
```go
|
|
||||||
OnBeforeClose: func(ctx context.Context) (prevent bool) {
|
|
||||||
if app.prefs.ConfirmOnClose {
|
|
||||||
// TODO: Design Agent 決定 confirm dialog 文案
|
|
||||||
// 用 wailsRuntime.MessageDialog 跳對話框
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
```
|
|
||||||
這樣 Design Agent 後續加 dialog 時不用再修 main.go。是否顯示 confirm 由 Design Agent 決定,但 hook 要先留
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Minor #4:Wails 關閉時可主動通知瀏覽器 tab 避免 15 s race condition
|
|
||||||
|
|
||||||
見 §F.2。建議在 Wails OnBeforeClose 之前透過 WebSocket 廣播 `server:shutdown-imminent` 訊息,前端立即顯示 Overlay。成本低(server 端 ~3 行、前端 ~10 行),體驗提升明顯。
|
|
||||||
|
|
||||||
**阻擋等級**:Minor,可在 M8-10 smoke test 發現體驗不佳時補
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Minor #5:`deletions.md` §1.1 「RTSP stream 可能呼叫 NewVideoSourceFromURL」敘述不精準
|
|
||||||
|
|
||||||
實際 grep 顯示 `camera_handler.go:731` 用 `NewVideoSourceFromURLWithSeek` 處理**本地上傳影片的 seek**,不是 RTSP。若 M8-1 執行者照字面理解,會把「不是 URL 但共用函式」的本地影片 seek 功能誤砍。
|
|
||||||
|
|
||||||
**修正**:`deletions.md` §1.1 文字改為「若唯一呼叫者是 `StartFromURL` → 整個砍;若仍有本地影片 seek 呼叫者(camera_handler.go:731 `NewVideoSourceFromURLWithSeek`)→ **函式保留**,但 rename 去掉 `FromURL` 字眼避免誤解,例如改為 `NewVideoSourceWithSeek`,並把 `--disable-network` 這項 configure flag 保留(因為本地檔案讀取走 `protocol=file` 不需 network)」
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. 通過 / 不通過 結論
|
|
||||||
|
|
||||||
### 結論:**條件通過**
|
|
||||||
|
|
||||||
| 條件 | 必須完成 |
|
|
||||||
|------|---------|
|
|
||||||
| Major #1 + Major #2 修完(R5-D1/D2/D3 落地 + milestone-plan 驗收條件修正)| **進 M8-4 / M8-5 前必須** |
|
|
||||||
| Major #3 補上冷啟動預算分解 + idle RAM 估算 | **使用者確認 TDD v2 前必須** |
|
|
||||||
| Major #4 採用 7 秒 + modal 方案 | **進 M8-4 / M8-5 前必須**(介面簽名受影響)|
|
|
||||||
| Minor #1-#5 | 可先進開發,M8-10 smoke test 前補齊即可 |
|
|
||||||
|
|
||||||
### 整體評價
|
|
||||||
|
|
||||||
**TDD v2 品質:8.5/10**(扣分在 R5-D 整組遺漏 + PM 5 懸念 2 題沒回答)。
|
|
||||||
|
|
||||||
- ✅ **值得稱讚**:
|
|
||||||
- `ffmpeg-lgpl.md` 的完整度(configure flags、BUILD.md reproducibility、spctl 驗證、gitignore 陷阱)
|
|
||||||
- `deletions.md` 的「按表操作 + 驗收 grep」設計,防呆到位
|
|
||||||
- R5-2 / R5-3 的處理乾淨俐落(不存在任何 tray 殘跡)
|
|
||||||
- state machine 五態的思考完整(`txMu` vs `mu` 雙 mutex 對抗 race)
|
|
||||||
- 新風險 R-v2-1 ~ R-v2-7 對應具體且有緩解
|
|
||||||
- milestone-plan.md 的依賴圖清晰,支援平行開發
|
|
||||||
|
|
||||||
- ❌ **需要補強**:
|
|
||||||
- R5-D 三條補充決策**整組遺漏**,這是 PM 的主要不滿。Architect 需要在使用者回答 R5-D 後,立即在 TDD v2 出一個 patch
|
|
||||||
- PM 5 懸念的 AC-1.3 與 idle RAM **完全沒回答**,PRD 明確要求 Architect 在 TDD v2 驗證
|
|
||||||
- UX 取捨(shutdownGracePeriod)被當成技術問題丟回使用者
|
|
||||||
|
|
||||||
### 下一步建議
|
|
||||||
|
|
||||||
1. Orchestrator 啟動 Architect Agent,做 **TDD v2 patch**,聚焦修 Major #1 ~ #4
|
|
||||||
2. 修完後 Orchestrator 再啟動 PM 做第二輪審閱(只審 patch 部分,~30 分鐘)
|
|
||||||
3. 同時 Orchestrator 啟動 Design Agent 做 Design 交叉審閱(本輪 PM 審閱發現 AC-7.6 是 Design 的未決項)
|
|
||||||
4. Design + PM 兩輪都通過後交使用者確認 → 進 M8-1 ~ M8-3(互不依賴的三個砍除/ffmpeg milestone 可立即啟動,不受 Major #1/#2 影響,加速開發)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 附錄:對照資料
|
|
||||||
|
|
||||||
- **PRD v2**:`/Users/jimchen/visionA/local-tool/.autoflow/02-prd/PRD-v2.md`(484 行)
|
|
||||||
- **TDD v2 索引**:`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/TDD-v2.md`(136 行)
|
|
||||||
- **TDD v2 子檔 8 份**:`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/v2/`(3738 行)
|
|
||||||
- **R5 / R5-D 決策**:`/Users/jimchen/visionA/local-tool/.autoflow/progress.md` §「R5 第五輪」+ §「R5-Design 補充」
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# PM 第二輪審閱 TDD v2.1(2026-04-14)
|
|
||||||
|
|
||||||
> 審閱者:PM Agent
|
|
||||||
> 被審物:`TDD-v2.md` v2.1 + 9 份 `v2/*.md` 子檔(新增 `startup-pipeline.md`)
|
|
||||||
> 審閱範圍:只審 v2.0 → v2.1 補丁(Major × 4 + Minor × 5 + R5-E 落地 + Architect 自補),不重審 v2.0 既有內容
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
- **總結論:通過(附 1 個 Minor 餘項)**
|
|
||||||
- 第一輪 **Major × 4 全部修好**;Minor × 5 修好 4 個 + 1 個以另一等效方案處理
|
|
||||||
- **R5-E1~E6 六條全部落地**(`v2/startup-pipeline.md` 新檔 518 行,程式碼級完整)
|
|
||||||
- **不阻擋進 M8 開發**。M8-1/M8-2/M8-3/M8-8 可立即啟動;M8-4/M8-4b/M8-5/M8-9 在 Design v2.1 文案完成後即可啟動
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 第一輪 Major 修復檢查
|
|
||||||
|
|
||||||
| # | 問題 | 檢查位置 | 結果 |
|
|
||||||
|---|-----|---------|------|
|
|
||||||
| **Major 1 R5-D1 OS 通知** | `server-lifecycle.md` 新增 §10(L690-791)含 `notify.go` + macOS `osascript` / Linux `notify-send -u critical` / Windows PowerShell BurntToast + `msg *` fallback;`control-panel.md` L482-486 在 Start 失敗時 `go sendCrashNotification(...)`;§6 diff(L405-408)在 watchServer 3 次失敗時 fire-and-forget;`startup-pipeline.md` L295-299 pipeline 失敗時一併發通知 | ✅ 完整。觸發點三處(Start 失敗 / watchServer 3 次失敗 / startup pipeline 失敗)都有 non-blocking `go` 呼叫 |
|
|
||||||
| **Major 1 R5-D2 Linux 預設 OFF** | `server-lifecycle.md` §11.3(L824-850)`DefaultPreferences()` 依 `runtime.GOOS != "linux"` 分平台;`control-panel.md` L169 + L498-500 註解對應;`milestone-plan.md` M8-9 驗收 L404-405 分 macOS/Win vs Linux 兩條 | ✅ 完整 |
|
|
||||||
| **Major 1 R5-D3 每次都開** | `control-panel.md` `ServerController.Start` L491-503 直接檢查 `if c.app.prefs.AutoOpenBrowser` + 註解明確「取消 v2.0 的 autoOpenedThisSession flag」;`server-lifecycle.md` L923 明示「已移除」;`milestone-plan.md` L393 / L411 / L439 反覆強調砍除 | ✅ 完整。但 `code-reuse-v2.md:92` 有殘留(見 §G Minor)|
|
|
||||||
| **Major 2 M8-9 驗收條件** | `milestone-plan.md` M8-9 L407「多次按 Restart:若 `AutoOpenBrowser=true`,每次 Restart 都會呼叫 `OpenInBrowser`」L411 Reviewer 檢查重點「不存在 `autoOpenedThisSession` 欄位」 | ✅ 完整。原「Restart 不會二次開」條件已全數砍除 |
|
|
||||||
| **Major 3 §11-1 Preferences 持久化** | `server-lifecycle.md` §11 全段(L795-924):§11.1 `<dataDir>/preferences.json` + §11.2 struct 定義 + §11.3 DefaultPreferences + §11.4 讀寫時機 + L879 atomic write-rename | ✅ 完整 spec |
|
|
||||||
| **Major 3 §11-2 冷啟動預算** | 被 R5-E 取代(索引 L35 明示「原 10 秒可達性已被 R5-E 60 s 上限 + 階段化進度取代」);`server-lifecycle.md` §2.1a(L83-90)補上日常啟動 ~3.8 s 估算 | ✅ 以 R5-E 取代是合理方案 |
|
|
||||||
| **Major 3 §11-3 450 MB 範圍** | `server-lifecycle.md` §11.6(L925-943)明確「450 MB 目標不含瀏覽器 tab 的記憶體消耗」+ 量測條件定義 + 悲觀情境 50 MB 超標時的 fallback | ✅ 完整 |
|
|
||||||
| **Major 4 shutdown 7+1** | `server-lifecycle.md` L125 PM Q4 定案「7 秒 + 1 秒 modal」;L527 `shutdownGracePeriod = 7 秒`;L550 `graceTimer := time.NewTimer(7 * time.Second)`;§8(L499-521)含廣播 + 1 秒 modal + SIGKILL 完整順序;§2.3 時序 t=1.000 `shutdown:modal-show` | ✅ 完整,採 PM 方案 |
|
|
||||||
|
|
||||||
**結論**:4 個 Major 全部修好。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 第一輪 Minor 修復檢查
|
|
||||||
|
|
||||||
| # | 問題 | 檢查 | 結果 |
|
|
||||||
|---|-----|------|------|
|
|
||||||
| Minor 1 N-R4 測試分層 | `control-panel.md` L826 標 `blocked-on-testing-agent` | ✅ 按約定處理 |
|
|
||||||
| Minor 2 AC-2.1 ≤5 s | `server-lifecycle.md` §2.1a(L83-90)日常啟動 ~3.8 s 估算表 + 結論「遠低於 60 s 上限」| ✅ |
|
|
||||||
| Minor 3 OnBeforeClose confirm hook | `server-lifecycle.md` §7(L475-479)寫死 `return false` + 註解「不跳確認對話框」,**未預留 `ConfirmOnClose` hook**;但 Architect 在 L490-493 解釋「Wails v2 default 就是直接關,與 R5-2 語意一致,不加 hook」 | ⚠️ 以等效方案處理。PM 第一輪建議是「預留 hook」方便未來改動,Architect 選擇「保持乾淨不加 dead code」。兩者語意均不彈對話框,符合 R5-2;PM 接受此取捨,**非阻擋** |
|
|
||||||
| Minor 4 WebSocket shutdown-imminent | `server-lifecycle.md` §2.3 t=0.005 / §8 L511 / `web-ui-offline-overlay.md` §3.2a(L78-95)+ L158 新增 `use-shutdown-watcher.ts` + L361 訂閱處理;server 新增 `/ws/system` endpoint;Wails `ctrl.Stop()` SIGTERM 前先廣播 | ✅ 完整(server + 前端 + Wails 三側齊全)|
|
|
||||||
| Minor 5 deletions.md §1.1 精準化 | `deletions.md` L38-94 新增 v2.1 Minor 5 段落:明列 `grep -rn 'NewVideoSourceFromURL'` 命令 + `camera_handler.go:731` 是 `handleVideoSeek` 的 `videoIsURL` dead code + 整個 function 砍 + `videoIsURL` field 砍 + 驗收 grep | ✅ 完整,甚至列出驗收 grep 指令 |
|
|
||||||
|
|
||||||
**結論**:5 個 Minor 全部處理(1 個以等效方案)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. R5-E 落地檢查(重點)
|
|
||||||
|
|
||||||
| # | 預期 | 位置 | 結果 |
|
|
||||||
|---|-----|------|------|
|
|
||||||
| **R5-E1** 60 s hard timeout | `startup-pipeline.md` L156 `startupHardTimeout = 60 * time.Second` + L325-330 watcher 檢查 `sinceTotal > startupHardTimeout` → `Fail()` | ✅ |
|
|
||||||
| **R5-E2** 6 階段化 | L154 `startupTotalStages = 6` + L167-177 `StartupPipeline` struct + §3 6 階段表(L118)+ L422 前端 render 6 rows | ✅ |
|
|
||||||
| **R5-E3** 20 s soft timeout 顯示重試提示 | L155 `startupSoftTimeout = 20 * time.Second` + L332-346 watcher 檢查 `sinceStage > startupSoftTimeout` → emit `startup:stage-timeout` + `softTimeoutEmitted` flag 防重複 | ✅ |
|
|
||||||
| **R5-E4** 60 s Error state + 三按鈕 | L325-330 hard timeout 呼叫 `Fail()` + `emitError()` + L292-294 同步叫 `ctrl.setState(ServerStateError, ...)`;三按鈕(重試/檢視 log/回報)在 `startup-panel.js` `showStartupError()`(L469+)和 Design Spec 對齊 | ✅ |
|
|
||||||
| **R5-E5** 階段文字交 Design | `startup-pipeline.md` §2(L87-105)只定義 i18n key(`startup.stage.{1-6}.label`),實際文字留給 `i18n/zh-TW.json` / `en-US.json`(Design 填);L517 明示「R5-E5 Design Spec v2.1 尚未敲定,i18n key 已預留」 | ✅ 零硬編碼文字,完全交 Design |
|
|
||||||
| **R5-E6** WebSocket 連上 = 就緒 | `startup-pipeline.md` L22 R5-E6 明文;§3 L118 階段 6 定義「`OnClientConnected` 第一次觸發」;L120-130 兩種實作方式(long-poll endpoint / sentinel file)留給 M8-4b 執行者選擇;L515 `startup-pipeline.md` 末段留 A-Q1 待 M8-4b 決定 | ✅ 設計完整,實作方式待 M8-4b 決定(PM 接受)|
|
|
||||||
|
|
||||||
**結論**:R5-E 六條全部落地,且程式碼級細節(event schema、1-indexed 陣列、非阻塞 EventsEmit、softTimeoutEmitted 防重複、watcher 生命週期管理)皆嚴謹。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. TDD ↔ PRD v2.1 對齊
|
|
||||||
|
|
||||||
| PRD v2.1 AC | TDD v2.1 對應 | 結果 |
|
|
||||||
|------------|--------------|------|
|
|
||||||
| AC-1.3 60 s 硬上限 | `startup-pipeline.md` L156 + `TDD-v2.md` 索引 L18 | ✅ |
|
|
||||||
| AC-1.3a 6 階段進度 | `startup-pipeline.md` §3 + §6 前端 render | ✅ |
|
|
||||||
| AC-1.3b 20 s 重試提示 | `startup-pipeline.md` L332-346 | ✅ |
|
|
||||||
| AC-1.3c 60 s Error state + 三按鈕 | `startup-pipeline.md` L325-330 + `startup-panel.js` showStartupError | ✅ |
|
|
||||||
| AC-1.3d 階段文字 Design | `startup-pipeline.md` L517 | ✅ |
|
|
||||||
| AC-7.7 OS 通知並存 | `server-lifecycle.md` §10 三平台 + L791「兩者互補,不取代」 | ✅ |
|
|
||||||
| AC-2.1 日常啟動 ≤5 s | `server-lifecycle.md` §2.1a 日常啟動 ~3.8 s | ✅ |
|
|
||||||
| AC-2.1a 每次 Start 都自動開 | `control-panel.md` L491-503 | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Architect 自補項檢查
|
|
||||||
|
|
||||||
- **F-2 Restart port 強制保留**:`server-lifecycle.md` §3.2(L179-224)完整,含 `pickPort(preferredPort, forceMatch=true)` + 使用者情境解釋 + port 被佔則進 Error state + 廣播 shutdown-imminent ✅
|
|
||||||
- **階段 6 WebSocket 實作細節**:`startup-pipeline.md` L120-130 列出 long-poll endpoint / sentinel file 兩方案,L515 明示「M8-4b 執行者決定並回報」。PM 接受此處延後決策,因為兩方案介面簽名相容,不影響上游 M8-4 開發 ✅
|
|
||||||
- **boot-id crypto/rand 16 bytes**(Q3):索引 L29 v2.1 變更條目記錄,不引額外依賴 ✅
|
|
||||||
- **navigator.language fallback 強化**(Q5):索引 L30 記錄 ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. 工時合理性
|
|
||||||
|
|
||||||
v2.0 10 人天 → v2.1 12 人天(+2 天),增量分解:
|
|
||||||
- R5-E 階段化啟動 +1 天(M8-4b 新 milestone)
|
|
||||||
- R5-D1 OS 通知 +0.3 天
|
|
||||||
- PM Q4 7+1 shutdown modal +0.2 天
|
|
||||||
- Minor 4 WebSocket 廣播 +0.3 天
|
|
||||||
- 其餘(M8-10 驗收項目擴充)+0.2 天
|
|
||||||
|
|
||||||
合計 2.0 天,與 milestone-plan.md L491 總計相符。
|
|
||||||
|
|
||||||
**PM 接受 12 人天**;另 L493 建議「加 M8-3 / M8-4 buffer 1 天 → 對使用者回報 ~13 人天」也合理。
|
|
||||||
|
|
||||||
**輕微瑕疵**:`milestone-plan.md` L6 文字摘要寫「~11.5 人天」,L491 合計表寫「12.0」。摘要行未同步更新,屬文字不一致。非阻擋。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. 第二輪新發現問題
|
|
||||||
|
|
||||||
### Major
|
|
||||||
|
|
||||||
無。
|
|
||||||
|
|
||||||
### Minor
|
|
||||||
|
|
||||||
#### Minor 6(新):`code-reuse-v2.md:92` 殘留 v2.0 字串
|
|
||||||
|
|
||||||
**問題**:`v2/code-reuse-v2.md` L92 仍然寫
|
|
||||||
|
|
||||||
```
|
|
||||||
第 83 行:砍 `mockMode` 欄位;新增 `ctrl` / `logBuf` / `prefs` / `autoOpenedThisSession` 欄位
|
|
||||||
```
|
|
||||||
|
|
||||||
**但** `milestone-plan.md:147` / `server-lifecycle.md:923` / `control-panel.md:811` 全部明示「**不存在** `autoOpenedThisSession`」。這是 v2.0 → v2.1 轉版時 `code-reuse-v2.md` 沒同步更新。
|
|
||||||
|
|
||||||
**影響**:M8-4 / M8-5 的工程師 Agent 若以此為唯一指引,可能加回 `autoOpenedThisSession` 欄位 → 違反 R5-D3。
|
|
||||||
|
|
||||||
**修正**:`code-reuse-v2.md:92` 移除 `autoOpenedThisSession` 字樣。一行 diff。
|
|
||||||
|
|
||||||
**阻擋等級**:Minor。不阻擋進開發(因其他三處指引都正確),但進 M8-5 前最好修掉避免混淆。
|
|
||||||
|
|
||||||
#### Minor 7(新):`milestone-plan.md:6` 摘要 11.5 vs 合計 12.0
|
|
||||||
|
|
||||||
見 §F。非阻擋,下次順手改。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. 第二輪通過 / 不通過 結論
|
|
||||||
|
|
||||||
### 結論:**通過**(附 Minor 6 / Minor 7 可隨開發過程修)
|
|
||||||
|
|
||||||
| 項目 | 第一輪 | 第二輪 |
|
|
||||||
|------|-------|-------|
|
|
||||||
| Major | 4 | **0** |
|
|
||||||
| Minor | 5 | **2**(新 Minor 6 / 7,非阻擋)|
|
|
||||||
| R5-E 落地 | — | **100%** |
|
|
||||||
| 是否阻擋進 M8 開發 | 部分阻擋 | **不阻擋** |
|
|
||||||
| 整體品質 | 8.5/10 | **9.3/10** |
|
|
||||||
|
|
||||||
### 稱讚
|
|
||||||
|
|
||||||
- R5-D1 OS 通知的三個觸發點(Start 失敗 / watchServer 3 次失敗 / startup pipeline 失敗)全覆蓋 + 非阻塞 goroutine 設計完美
|
|
||||||
- `startup-pipeline.md` 是一份**程式碼級**的子檔(518 行 Go + JS 全部寫出),Reviewer 可以直接比對
|
|
||||||
- R5-E5 階段文字零硬編碼,完全以 i18n key 交給 Design,等 Design Spec v2.1 敲定就能直接填 JSON
|
|
||||||
- §11.6 的 450 MB 範圍澄清 + 悲觀情境 fallback 策略很到位
|
|
||||||
- `deletions.md` Minor 5 修正後的驗收 grep 命令可直接複製執行,防呆
|
|
||||||
|
|
||||||
### 下一步
|
|
||||||
|
|
||||||
1. 使用者確認 TDD v2.1 + Design Spec v2.1 → 即可進 M8 開發
|
|
||||||
2. **M8-1 / M8-2 / M8-3 / M8-8 可立即並行啟動**(互不依賴;Major 修復未觸及此四者)
|
|
||||||
3. **M8-4 / M8-4b / M8-5 / M8-9** 依賴 `DefaultPreferences()` / `sendCrashNotification()` / `StartupPipeline` / Preferences 持久化,這四者 TDD v2.1 都已 spec 完整可開工
|
|
||||||
4. Minor 6 / Minor 7 建議 M8-5 啟動前或開發過程中順手修
|
|
||||||
5. 本輪 PM 審閱完成,不需第三輪
|
|
||||||
|
|
||||||
- **審閱耗時**:~1 小時(含所有子檔閱讀 + 交叉對照)
|
|
||||||
@ -1,219 +0,0 @@
|
|||||||
# v2/code-reuse-v2.md — 沿用 vs 改寫 vs 新寫
|
|
||||||
|
|
||||||
> 所屬:TDD v2 §2.8
|
|
||||||
> 目的:以 v1 (M1-M7 已完成)為基準,量化 v2 refactor 的沿用率
|
|
||||||
> 承接:`architect-analysis-round2-refactor.md` §D
|
|
||||||
> 結論:**整體沿用率 85-95%**(三方共識 #1),**不是丟掉重做**,是**擴充 + 砍功能**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 模組沿用率總表
|
|
||||||
|
|
||||||
| 模組 | 現況大小 | 改動量 | 沿用率 | 關鍵影響 |
|
|
||||||
|------|---------|-------|-------|---------|
|
|
||||||
| `server/` Go 後端 | ~15k LoC | ~400 行修改 + 新增 boot-id endpoint | **~96%** | 砍 yt-dlp + Mock + 新增 CORS whitelist + boot-id;預置模型 / inference / camera pipeline / Python bridge / WebSocket / static serving 全部不動 |
|
|
||||||
| `server/web/out/` go:embed 的 Next.js static | ~20k LoC | ~100 行修改 + ~250 行新增(offline overlay + boot-id watcher + i18n) | **~88%** | 砍 URL tab + Mock UI;新增 Offline Overlay + boot-id watcher;業務頁面全部不動 |
|
|
||||||
| `visiona-local/app.go` Wails 殼 | 1584 行 | ~350 行新增 / ~30 行刪改 | **~85%** | 新增 ServerController + LogBuffer + Preferences;watchServer 改為 Error state;`mockMode` 欄位與 VISIONA_MOCK 砍除;核心 startup / shutdown / single-instance / driver installer 全部不動 |
|
|
||||||
| `visiona-local/frontend/` Wails 內嵌 UI | 211 行(html + js + css) | 三檔全部改寫 + 新增 12 檔 | **程式面砍掉重寫,概念承襲** | 沿用 ES module + wailsjs 串接模式,但內容是全新控制台 UI |
|
|
||||||
| `installer/` 打包腳本 | macOS dmgbuild / Windows iss / Linux AppImage | 各 ~3 行修改 | **~98%** | 只改 ffmpeg/ffprobe/LGPL licnese 的 copy,其餘不變 |
|
|
||||||
| `Makefile` | 570 行 | ~60 行修改 + ~80 行新增(macOS build) | **~85%** | 砍 yt-dlp targets;改 Windows/Linux ffmpeg URL;新增 macOS 自 build |
|
|
||||||
| `scripts/bootstrap-*.sh/.ps1` | 中等 | 各 ~5 行修改 | **~99%** | 只砍 yt-dlp 參照 |
|
|
||||||
| `vendor/` | 現有依賴 | 砍 `yt-dlp/`;改 `ffmpeg/`;新增 `ffmpeg/macos/` 進 git | N/A | 內容差異顯著 |
|
|
||||||
|
|
||||||
**總計**:整個 repo 程式碼層 ~85-95% 沿用(依粗估方式不同,但結論是「絕大多數 M1-M7 投入仍有效」)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 逐目錄細節
|
|
||||||
|
|
||||||
### 2.1 `server/` — 96% 沿用
|
|
||||||
|
|
||||||
**完全不動**(> 99%):
|
|
||||||
- `server/internal/api/ws/` — WebSocket hub / handlers(除 CheckOrigin 那個 helper 是 M8-8 新增)
|
|
||||||
- `server/internal/camera/` — 除 `video_source.go` 的 yt-dlp 函式(§2.2)+ `mock_camera.go`(整檔刪)
|
|
||||||
- `server/internal/device/` — 除 `manager.go` 的 mockMode 條件 + `driver/mock/` 目錄
|
|
||||||
- `server/internal/inference/` — 推論 service 邏輯
|
|
||||||
- `server/internal/model/` — 模型 repository / store / upload
|
|
||||||
- `server/internal/flash/` — flash service(Kneron 韌體燒錄,v1 已保留)
|
|
||||||
- `server/pkg/logger/` — logger + broadcaster
|
|
||||||
- `server/scripts/kneron_bridge.py` — Python KneronPLUS bridge
|
|
||||||
- `server/data/` — 預置 .nef 模型
|
|
||||||
|
|
||||||
**修改**(< 5% 程式碼):
|
|
||||||
- `server/main.go` 第 86-87 行(log message)、第 89-95 行(註解)、第 142 行(NewManager 簽名)、第 147 行(NewManager 簽名)
|
|
||||||
- `server/internal/api/router.go` 第 83 行(刪 `/media/url`)+ 新增 `/api/system/boot-id`
|
|
||||||
- `server/internal/api/handlers/camera_handler.go` 第 251 行(擴充副檔名)+ 第 341-497 行(刪 yt-dlp 相關)
|
|
||||||
- `server/internal/api/handlers/system_handler.go` 新增 `BootID` method + constructor 新增 bootID 參數
|
|
||||||
- `server/internal/api/middleware.go` 整檔覆寫(CORS 白名單)
|
|
||||||
- `server/internal/config/config.go` 刪 MockMode / MockCamera / MockDeviceCount
|
|
||||||
- `server/internal/deps/checker.go` 刪 yt-dlp entry + 註解更新
|
|
||||||
- `server/internal/camera/video_source.go` 砍 `ResolveWithYTDLP` / `friendlyYTDLPError`;可能砍 `NewVideoSourceFromURL`(視 grep 結果)
|
|
||||||
- `server/internal/camera/manager.go` 砍 mockMode / mockCamera
|
|
||||||
- `server/internal/device/manager.go` 砍 mockMode / mockCount
|
|
||||||
|
|
||||||
**新增**:
|
|
||||||
- `server/internal/api/ws/origin.go` — CheckOrigin helper(~30 行)
|
|
||||||
- `server/internal/api/middleware_test.go` — TestIsAllowedOrigin(~50 行)
|
|
||||||
|
|
||||||
### 2.2 `server/web/out/`(go:embed 的 Next.js SPA,實際源碼在 `frontend/`)— 88% 沿用
|
|
||||||
|
|
||||||
**完全不動**:
|
|
||||||
- `frontend/src/app/dashboard/` / `devices/` / `models/` / `workspace/` — 業務頁面
|
|
||||||
- `frontend/src/components/device/` / `model/` / `inference/` — 業務元件
|
|
||||||
- `frontend/src/lib/api/` — API client
|
|
||||||
- `frontend/src/stores/` 除 `camera-store.ts` 的 `startFromUrl`
|
|
||||||
|
|
||||||
**修改**:
|
|
||||||
- `frontend/src/components/camera/source-selector.tsx` — 砍 URL tab,擴充副檔名
|
|
||||||
- `frontend/src/stores/camera-store.ts` — 砍 `startFromUrl`
|
|
||||||
- `frontend/src/app/settings/page.tsx` — 砍 Mock 模式切換
|
|
||||||
- `frontend/src/app/layout.tsx` — 掛 `<ServerOfflineOverlay />` + `<BootIdWatcherMount />`
|
|
||||||
- `frontend/src/lib/i18n/{types,zh-TW,en}.ts` — 砍 4 個 yt-dlp keys + 4 個 Mock keys + `noDevices` 文案 + 新增 `serverOffline` 區塊
|
|
||||||
|
|
||||||
**新增**:
|
|
||||||
- `frontend/src/stores/system-store.ts`(~40 行)
|
|
||||||
- `frontend/src/hooks/use-boot-id-watcher.ts`(~70 行)
|
|
||||||
- `frontend/src/components/server-offline-overlay.tsx`(~90 行)
|
|
||||||
- `frontend/src/components/boot-id-watcher-mount.tsx`(~10 行)
|
|
||||||
|
|
||||||
### 2.3 `visiona-local/app.go` — 85% 沿用
|
|
||||||
|
|
||||||
**完全不動**的 v1 邏輯:
|
|
||||||
- `startup()` 的 data dir migration / single-instance lock / IPC server / seed user data dir
|
|
||||||
- `shutdown()` 的 watchCancel / ipcListener.Close / releaseLock
|
|
||||||
- `reportFatal()` + `showNativeError()` 三平台原生對話框(保留給 startup 致命錯誤)
|
|
||||||
- `ensurePythonRuntime()` / `findSystemPython()` / `ensureBundledPython()` 完整 Python 雙策略
|
|
||||||
- `ensureDriverInstalled()` / `markDriverInstalled()` / `InstallKneronDriver()` — Kneron WinUSB driver 安裝
|
|
||||||
- `locateServerBinary()` / `pickPort()` / `waitHealthy()` / `writeIPCPort()` — 這些 helper 不動
|
|
||||||
- `seedUserDataDir()` — 首次啟動的資料 seed
|
|
||||||
- `configureSysProcAttr()` / `openBrowser()` 跨平台封裝
|
|
||||||
|
|
||||||
**修改**:
|
|
||||||
- 第 83 行:砍 `mockMode` 欄位;新增 `ctrl` / `logBuf` / `prefs` / `startupPipeline` / `pipelineCancelFn` 欄位(R5-D3 規則:每次 Start 都開瀏覽器,不需 per-session 的 flag;詳見 `server-lifecycle.md` §11.5)
|
|
||||||
- 第 119-120 行:砍 VISIONA_MOCK env 讀取
|
|
||||||
- 第 313-326 行:砍 `GetBootstrapStatus` / `setBootstrapStatus` / `bootstrapStatus`
|
|
||||||
- 第 425-564 行:`startServer()` 改名為 `startServerV2()`,內容改為
|
|
||||||
- 用 `StdoutPipe / StderrPipe` 而非 file
|
|
||||||
- 啟動兩個 `logPump` goroutine
|
|
||||||
- mockMode 相關條件全砍
|
|
||||||
- `--mock` arg 拿掉
|
|
||||||
- 第 571-610 行:`watchServer()` 的 `reportFatal + os.Exit` 改為 `ctrl.setState(Error)` + event emit
|
|
||||||
|
|
||||||
**新增 bindings**(13 個 method,見 `v2/control-panel.md` §4.2):
|
|
||||||
- `StartServer` / `StopServer` / `RestartServer`
|
|
||||||
- `GetServerStatus`(改版)/ `GetRecentLogs` / `ClearLogs`
|
|
||||||
- `GetSystemInfo`
|
|
||||||
- `OpenInBrowser` / `RevealLogsFolder` / `ExportLog`
|
|
||||||
- `GetPreferences` / `SetPreferences`
|
|
||||||
- `RestartStartupSequence`(v2.1:Startup Error state 的 Retry 按鈕;見 `startup-pipeline.md` §9)
|
|
||||||
|
|
||||||
### 2.4 `visiona-local/` 新增檔案
|
|
||||||
|
|
||||||
- `visiona-local/server_control.go`(~250 行)— ServerController + startServerV2 + logPump
|
|
||||||
- `visiona-local/log_buffer.go`(~120 行)— LogBuffer ring buffer
|
|
||||||
- `visiona-local/preferences.go`(~60 行)— JSON load/save
|
|
||||||
|
|
||||||
### 2.5 `visiona-local/main.go` — 修改 < 5 行
|
|
||||||
|
|
||||||
新增 `OnBeforeClose` handler;其餘不動。
|
|
||||||
|
|
||||||
### 2.6 `visiona-local/frontend/` — 改寫
|
|
||||||
|
|
||||||
原本(M7-B 後):`index.html` 25 行 + `app.js` 74 行 + `style.css` 112 行 = 211 行 splash。
|
|
||||||
|
|
||||||
v2:
|
|
||||||
- `index.html` ~80 行(控制台 layout)
|
|
||||||
- `app.js` ~130 行(init + binding 呼叫 + event 訂閱)
|
|
||||||
- `style.css` ~300 行(控制台樣式 + dark/light tokens)
|
|
||||||
- 新增 components/×4 + i18n/×3 + icons/×6 + wailsjs/(自動生成)
|
|
||||||
|
|
||||||
**概念沿用**:ES module + `import` wailsjs + `EventsOn` 訂閱模式——v1 M7-B splash 就是這樣寫的,v2 延續這個模式,只是元件更多。
|
|
||||||
|
|
||||||
**程式碼沿用**:0。三個檔案的內容全部重寫。但這三個檔案加起來才 211 行,重寫 2 天是合理的。
|
|
||||||
|
|
||||||
### 2.7 `installer/` — 98% 沿用
|
|
||||||
|
|
||||||
- `installer/windows/visiona-local.iss` — 改 1 行(砍 yt-dlp)+ 加 2 行(ffprobe + license)
|
|
||||||
- `installer/linux/build-appimage.sh` — 改 1 行(砍 yt-dlp 從 for loop)
|
|
||||||
- macOS `dmgbuild` 路徑完全不動(dmg 是從 `visiona-local/build/bin/visiona-local.app` 直接建,ffmpeg/ffprobe 已透過 payload-macos 置入)
|
|
||||||
|
|
||||||
### 2.8 `Makefile` — 85% 沿用
|
|
||||||
|
|
||||||
**新增 target**:`vendor-ffmpeg-macos-build`(~50 行)
|
|
||||||
|
|
||||||
**修改**:
|
|
||||||
- `vendor-ffmpeg` 改為驗證(~10 行)
|
|
||||||
- `vendor-ffmpeg-windows` URL + Python zipfile 提取清單擴充為 ffmpeg + ffprobe + LICENSE
|
|
||||||
- `vendor-ffmpeg-linux` URL + 解壓邏輯 + 加抓 ffprobe
|
|
||||||
- `payload-macos / payload-windows / payload-linux` 全部改為 `cp ffmpeg + ffprobe`,砍 `cp yt-dlp`
|
|
||||||
|
|
||||||
**刪除**:
|
|
||||||
- `vendor-ytdlp` / `vendor-ytdlp-windows` / `vendor-ytdlp-linux` 三個 target
|
|
||||||
- 三個 `YTDLP_URL_*` 變數
|
|
||||||
- `vendor-sync` 中的 `vendor-ytdlp` 依賴
|
|
||||||
|
|
||||||
### 2.9 `scripts/bootstrap-*.sh/.ps1` — 99% 沿用
|
|
||||||
|
|
||||||
只改刪 yt-dlp 參照,其他完全不動。
|
|
||||||
|
|
||||||
### 2.10 `vendor/` 目錄
|
|
||||||
|
|
||||||
| 子目錄 | v1 | v2 | 備註 |
|
|
||||||
|-------|----|----|------|
|
|
||||||
| `vendor/python/` | 進 vendor(不進 git)| 不變 | - |
|
|
||||||
| `vendor/wheels/` | 進 vendor | 不變 | - |
|
|
||||||
| `vendor/ffmpeg/darwin/` | 進 vendor(不進 git,GPL build)| **目錄改名** `vendor/ffmpeg/macos/` | 進 git,LGPL build |
|
|
||||||
| `vendor/ffmpeg/windows/` | 進 vendor | 同路徑,LGPL build | - |
|
|
||||||
| `vendor/ffmpeg/linux/` | 進 vendor | 同路徑,LGPL build | - |
|
|
||||||
| `vendor/yt-dlp/` | 進 vendor | **整個刪除** | R5-7 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 文字統計估算
|
|
||||||
|
|
||||||
| 類別 | 行數 |
|
|
||||||
|------|------|
|
|
||||||
| v2 新增 Go 程式碼 | ~600 行(server_control.go + log_buffer.go + preferences.go + boot-id handler + CORS middleware + origin.go + middleware_test.go) |
|
|
||||||
| v2 新增 JS/TS 程式碼 | ~700 行(Wails 控制台 5 JS 檔 + 6 SVG + 2 JSON + frontend Offline Overlay 4 檔) |
|
|
||||||
| v2 修改 Go 程式碼 | ~400 行 diff |
|
|
||||||
| v2 修改 TS 程式碼 | ~200 行 diff |
|
|
||||||
| v2 修改 Makefile / scripts / installer | ~100 行 diff |
|
|
||||||
| v2 刪除 Go 程式碼 | ~500 行(mock_driver.go 183 + mock_camera.go 95 + camera_handler.go yt-dlp 段 160 + video_source.go yt-dlp 段 50) |
|
|
||||||
| v2 刪除 TS 程式碼 | ~80 行(source-selector URL tab 70 + camera-store startFromUrl 30 - 扣除同 section 重複計算) |
|
|
||||||
| v2 刪除 Makefile / scripts | ~60 行 |
|
|
||||||
|
|
||||||
**總 diff 量**:~2600 行(新增 + 修改 + 刪除)
|
|
||||||
**總 repo 規模**:~40k LoC
|
|
||||||
**diff 比例**:~6.5%
|
|
||||||
|
|
||||||
→ 換算成**淨沿用率**:~93%,落在三方共識的「85-95%」區間上半部。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 為什麼沿用率這麼高
|
|
||||||
|
|
||||||
M1-M7 的投資集中在「打包」這件事上(vendor 機制、dmg/iss/AppImage、Python runtime 雙策略、single-instance、data dir migration、Kneron driver installer),這些**完全不動**。
|
|
||||||
|
|
||||||
v2 的 refactor 集中在:
|
|
||||||
1. 把業務 UI 從 Wails WebView 搬到瀏覽器(純移動,Next.js 源碼不動)
|
|
||||||
2. Wails 視窗內容從 splash 換成控制台(全新 UI,但只有 ~700 行程式碼)
|
|
||||||
3. 砍兩個功能(yt-dlp, Mock)— 都是局部刪除,不觸及核心架構
|
|
||||||
4. 切 ffmpeg 授權(純 vendor 層,不動執行邏輯)
|
|
||||||
|
|
||||||
**等於說 v2 是在 v1 基礎上做「加工」,不是推倒重來**。原本這個 refactor 有可能變成「丟掉重做」的惡夢,但 M7-B 已經把 Next.js UI 從 Wails binding 強耦合解開變成純 HTTP,少了這個前置條件 v2 真的會很痛。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 被保留但可能失去商業意義的部分
|
|
||||||
|
|
||||||
這些 v2 不砍,但隨 R5 決策的不同方向它們的重要性變低了:
|
|
||||||
|
|
||||||
- **Python runtime 雙策略 + PBS 內嵌(M3 work)** — 仍然需要,Kneron inference 必須有 Python。保留。
|
|
||||||
- **驗證 driver installer(M4 Windows WinUSB)** — 仍然需要。保留。
|
|
||||||
- **預置 .nef 模型**(8 個,73 MB) — 仍然需要。保留。
|
|
||||||
- **Kneron KL520/KL720 韌體檔案** — 仍然需要(最近才補上)。保留。
|
|
||||||
- **splash 進度訊息 `setBootstrapStatus`** — 砍掉(R5 下 Wails 視窗是控制台,沒有「loading」階段,Start 是 async 不阻塞 UI)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**結論**:v2 refactor 的沿用率接近 93%,剩下的 7% diff 主要是新增控制台 UI 與整合 boot-id 機制,總工時 ~10 人天是合理估算。
|
|
||||||
@ -1,849 +0,0 @@
|
|||||||
# v2/control-panel.md — Wails 控制台實作規格
|
|
||||||
|
|
||||||
> 所屬:TDD v2 §2.1
|
|
||||||
> 版本:v2.1(2026-04-14 吸收 PM 審閱 + R5-D + R5-E)
|
|
||||||
> 決策依據:R5-1(Wails 視窗 = 控制台)、R5-5(Mock 切換不放控制台)、R5-D1(OS 崩潰通知並存)、R5-D2(Linux 預設 auto-open OFF)、R5-D3(每次 Start 成功都開瀏覽器)、R5-E(階段化啟動進度)、三方共識 #7(vanilla HTML/JS/CSS)
|
|
||||||
> 對應 milestone:M8-4(lifecycle + bindings + LogBuffer)、M8-4b(階段化啟動管線)、M8-5(vanilla UI 改寫)
|
|
||||||
> 關聯子檔:`v2/startup-pipeline.md`(R5-E 6 階段啟動管線細節)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 目的與範圍
|
|
||||||
|
|
||||||
把現有的 `visiona-local/frontend/` splash(M7-B 寫的 78 行 splash + redirect)**整組改寫**成一個靜態的控制台 UI,長駐在 Wails 視窗內,不再跳轉。控制台提供:
|
|
||||||
|
|
||||||
1. **Server 狀態卡片**:即時 state、port、PID、Python runtime 資訊
|
|
||||||
2. **Log panel**:ring buffer 2000 行、auto-scroll、pause、clear
|
|
||||||
3. **動作列**:Start / Stop / Restart / Open in Browser / Reveal Logs Folder / Clear Logs
|
|
||||||
4. **Preferences 區塊**:`AutoOpenBrowser` toggle(R5-4 / R5-D2 / R5-D3)
|
|
||||||
5. **系統資訊**:data dir / app version / build time(對齊 `GET /api/system/info`,但取自 Wails local state 更穩)
|
|
||||||
|
|
||||||
控制台**不做**:業務資料(裝置清單、模型清單、推論畫面、Settings > 語言、Mock 切換)— 這些全在瀏覽器 tab 的 Next.js Web UI。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 技術選型(R5-1 + 三方共識 #7)
|
|
||||||
|
|
||||||
| 面向 | 決定 | 原因 |
|
|
||||||
|------|------|------|
|
|
||||||
| UI stack | **vanilla HTML + CSS + ES module JS** | 控制台元件 < 10 個,引入 React/Vue/Svelte 反而重;現有 `visiona-local/frontend/` 已經是 ES module,不需重建 build chain |
|
|
||||||
| Build | **無** — `go:embed all:frontend` 直接塞入 Wails binary | 維持現有 `visiona-local/main.go:11-12` 的 embed 機制,完全不動 |
|
|
||||||
| CSS 方法 | BEM 命名 + CSS variables(light/dark mode token) | 不需 Tailwind;dark mode 靠 `@media (prefers-color-scheme: dark)` 換 CSS var |
|
|
||||||
| 圖示 | Inline SVG(action bar 的播放 / 停止 / 刷新 / 瀏覽器 / 資料夾 / 垃圾桶) | 不引外部 icon library,避免多一份字型檔 |
|
|
||||||
| i18n | 沿用 `frontend/src/lib/i18n/{zh-TW,en}.ts` 的字串鍵,但以**獨立的 JSON 子集**提供給控制台(避免控制台與 Next.js 前端耦合 build pipeline) | 詳見 §6 |
|
|
||||||
| 自動偵測語系 | Q5 強化:`navigator.languages[0] \|\| navigator.language`,以 `zh` 開頭 → `zh-TW`;`C` / `POSIX` / 空字串 → `en-US`;其他 → `en-US`。fetch 失敗 → hardcoded 英文 fallback | Wails v2 在 Windows/Linux 下偶爾回 `en` 或 `C`,需 fallback;細節見 §6.2 |
|
|
||||||
|
|
||||||
### 2.1 檔案結構
|
|
||||||
|
|
||||||
```
|
|
||||||
visiona-local/frontend/
|
|
||||||
├── index.html ← 改寫:控制台 layout
|
|
||||||
├── app.js ← 改寫:初始化 + binding 呼叫 + event 訂閱
|
|
||||||
├── style.css ← 改寫:控制台樣式(status / log / action bar / prefs)
|
|
||||||
├── components/
|
|
||||||
│ ├── status-card.js ← 新增:狀態卡片 render + update
|
|
||||||
│ ├── log-panel.js ← 新增:ring buffer 顯示 + auto-scroll + pause
|
|
||||||
│ ├── action-bar.js ← 新增:6 顆按鈕 + disable 邏輯(依 state machine)
|
|
||||||
│ ├── preferences.js ← 新增:AutoOpenBrowser toggle(R5-D2/D3)
|
|
||||||
│ └── startup-panel.js ← 新增:R5-E 6 階段啟動進度面板 render/update
|
|
||||||
├── i18n/
|
|
||||||
│ ├── en-US.json ← 新增:控制台 i18n 字串子集
|
|
||||||
│ └── zh-TW.json ← 新增
|
|
||||||
├── icons/
|
|
||||||
│ ├── play.svg ← 新增
|
|
||||||
│ ├── stop.svg
|
|
||||||
│ ├── restart.svg
|
|
||||||
│ ├── browser.svg
|
|
||||||
│ ├── folder.svg
|
|
||||||
│ └── trash.svg
|
|
||||||
└── wailsjs/ ← 自動生成(Wails build),不動
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. UI 元件清單 + 視覺結構
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────────────────────────────────┐
|
|
||||||
│ visionA Local [■ Dark/Light]│ ← titlebar(Wails frameless 或 system,v2 保持 system)
|
|
||||||
├────────────────────────────────────────────────────────────────┤
|
|
||||||
│ ┌─────────────── Server Status ─────────────────────────────┐ │
|
|
||||||
│ │ ● Running │ │ ← state badge(顏色:Running 綠 / Starting 黃 / Error 紅 / Stopped 灰)
|
|
||||||
│ │ http://127.0.0.1:3721 • PID 42131 │ │
|
|
||||||
│ │ Python: system (/usr/bin/python3.12) │ │
|
|
||||||
│ │ Data dir: ~/Library/Application Support/visiona-local │ │
|
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────── Server Log ────────────────────────────────┐ │
|
|
||||||
│ │ 14:23:01 [INFO] visiona-local-server starting on :3721 │ │
|
|
||||||
│ │ 14:23:01 [INFO] Python bridge: /usr/bin/python3.12 │ │
|
|
||||||
│ │ 14:23:02 [INFO] Loaded 8 built-in models │ │
|
|
||||||
│ │ 14:23:02 [INFO] HTTP server listening on 127.0.0.1:3721 │ │
|
|
||||||
│ │ 14:23:15 [GIN] 200 | 2.1ms | GET /api/system/info │ │ ← scrollable <pre class="log__body">
|
|
||||||
│ │ ... │ │
|
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ [Pause auto-scroll] [Clear] │ ← log panel 底下的子動作
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────── Actions ───────────────────────────────────┐ │
|
|
||||||
│ │ [▶ Start] [■ Stop] [⟲ Restart] [🌐 Open in Browser] │ │
|
|
||||||
│ │ [📁 Reveal Logs] │ │
|
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────── Preferences ───────────────────────────────┐ │
|
|
||||||
│ │ ☑ Open browser automatically when server starts │ │
|
|
||||||
│ │ (Linux 預設為 OFF — R5-D2;macOS/Windows 預設為 ON) │ │
|
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
|
||||||
└────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Button disable 矩陣(依 ServerState):
|
|
||||||
|
|
||||||
| 按鈕 | Stopped | Starting | Running | Stopping | Error |
|
|
||||||
|------|---------|----------|---------|----------|-------|
|
|
||||||
| Start | ✅ | ❌ | ❌ | ❌ | ✅ |
|
|
||||||
| Stop | ❌ | ❌ | ✅ | ❌ | ❌ |
|
|
||||||
| Restart | ❌ | ❌ | ✅ | ❌ | ❌ |
|
|
||||||
| Open in Browser | ❌ | ❌ | ✅ | ❌ | ❌ |
|
|
||||||
| Reveal Logs | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Clear Logs | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
|
|
||||||
`Starting` 與 `Stopping` 期間顯示進度 spinner 取代 state icon。
|
|
||||||
|
|
||||||
### 3.1 Starting state 下的「啟動進度面板」(R5-E)
|
|
||||||
|
|
||||||
當 ServerState 處於 `Starting` 時,主控台覆蓋一層啟動進度面板,**取代**(而非疊加於)Status/Log/Actions 區塊:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────────────────────────────────┐
|
|
||||||
│ visionA Local │
|
|
||||||
├────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ 正在啟動 visionA Local │
|
|
||||||
│ │
|
|
||||||
│ [■■■■□□] 4 / 6 │
|
|
||||||
│ │
|
|
||||||
│ ✅ 1. 初始化控制台 (0.1 s) │
|
|
||||||
│ ✅ 2. 檢查 Python runtime (1.8 s) │
|
|
||||||
│ ✅ 3. 啟動本機伺服器 (2.4 s) │
|
|
||||||
│ 🔄 4. 偵測 Kneron 裝置 (進行中…) │
|
|
||||||
│ ⏳ 5. 開啟瀏覽器 │
|
|
||||||
│ ⏳ 6. 等待 Web UI 連線 │
|
|
||||||
│ │
|
|
||||||
│ 已耗時 4.3 s · 上限 60 s │
|
|
||||||
│ │
|
|
||||||
│ (第 N 階段正在重試 …) │
|
|
||||||
│ │
|
|
||||||
└────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- 6 階段逐步 render,每個階段 status:`pending` / `running` / `completed` / `failed`,對應 ⏳ / 🔄 / ✅ / ❌
|
|
||||||
- 底部總耗時 counter(每秒更新一次)
|
|
||||||
- 當某階段收到 `startup:stage-timeout` event(soft timeout 20 s),在該階段行下方顯示 subtle 灰字「第 N 階段正在重試 …」
|
|
||||||
- 當收到 `startup:error` event(任一階段失敗 或 總時 60 s)→ 切 Error state,淡出進度面板顯示主控台 Error banner
|
|
||||||
- 當收到 `startup:ready` event → 淡出 300 ms → 主控台 UI 顯現
|
|
||||||
- i18n key:`startup.stage.1.label` ~ `startup.stage.6.label` / `startup.retrying` / `startup.elapsed` / `startup.error`。文案由 Design Spec v2.1 敲定(R5-E5)
|
|
||||||
|
|
||||||
完整 event schema、超時機制、watcher goroutine 見 `v2/startup-pipeline.md`。
|
|
||||||
|
|
||||||
### 3.2 Error state 的 UI 呈現 + R5-D1 OS 通知並存
|
|
||||||
|
|
||||||
當 ServerState 切到 `Error`(watchServer 連續 3 次失敗、StartServer 失敗、或階段化啟動總超時 60 s):
|
|
||||||
|
|
||||||
1. **控制台內**:Status card 的 badge 變紅、顯示 `lastError` 訊息、log panel 頂端插入一條 error banner(可關閉),action bar 的 [Start] 重新 enable 讓使用者手動 Restart
|
|
||||||
2. **同時**:透過 `visiona-local/notify.go` 發一則 OS 原生通知(macOS `osascript display notification` / Linux `notify-send` / Windows `powershell BurntToast` 或 `msg *`),標題「visionA Local — Server 崩潰」,副文「點回應用程式查看錯誤詳情」
|
|
||||||
3. **非此即彼**:OS 通知與控制台 banner **並存**,並非二選一。OS 通知的用途是「使用者把 Wails 視窗縮小/最小化時也能被知會」;控制台 banner 是「使用者打開 Wails 時一眼可見」。
|
|
||||||
|
|
||||||
詳細 `notify.go` 三平台實作見 `server-lifecycle.md` §10。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Go 側:Wails bindings + state machine + LogBuffer
|
|
||||||
|
|
||||||
### 4.1 新增/修改的 Go 檔案
|
|
||||||
|
|
||||||
| 檔案 | 狀態 | 內容 |
|
|
||||||
|------|------|------|
|
|
||||||
| `visiona-local/app.go` | 修改(~1584 行 → +250 / -30) | 新增 bindings;`watchServer` 改為 Error state;`shutdown` 不動;`reportFatal` 保留給「完全無法啟動」的致命錯誤 |
|
|
||||||
| `visiona-local/server_control.go` | **新增** | `ServerController` struct + 狀態機 + Start/Stop/Restart 方法 |
|
|
||||||
| `visiona-local/log_buffer.go` | **新增** | `LogBuffer` struct + ring buffer + subscribe / unsubscribe / append + logPump |
|
|
||||||
| `visiona-local/preferences.go` | **新增** | `Preferences` struct + `DefaultPreferences()`(依 `runtime.GOOS` 分平台 default,R5-D2)+ load/save JSON(atomic write-rename)+ 路徑 `<dataDir>/preferences.json` |
|
|
||||||
| `visiona-local/notify.go` | **新增** | `sendCrashNotification(title, body string)` 三平台實作(macOS `osascript` / Linux `notify-send` / Windows `powershell BurntToast` 或 `msg *`)。非阻塞、最佳努力送達。詳見 `server-lifecycle.md` §10 |
|
|
||||||
| `visiona-local/startup_pipeline.go` | **新增** | R5-E 6 階段啟動管線:`StartupPipeline` struct + watcher goroutine(20 s soft / 60 s hard timeout)+ event emit。詳見 `v2/startup-pipeline.md` |
|
|
||||||
|
|
||||||
### 4.2 新增 Bindings(Wails 自動暴露為前端 JS 函式)
|
|
||||||
|
|
||||||
```go
|
|
||||||
// server_control.go 中新增的 App method(Wails binding 會暴露成前端可呼叫)
|
|
||||||
|
|
||||||
// StartServer 啟動 server 子程序。若目前已是 Running / Starting / Stopping → 回錯誤(前端 UI 用 button disable 防呆)。
|
|
||||||
func (a *App) StartServer() error
|
|
||||||
|
|
||||||
// StopServer 優雅停止 server 子程序:SIGTERM → 等 5 s → SIGKILL。若目前 Stopped / Error → no-op。
|
|
||||||
func (a *App) StopServer() error
|
|
||||||
|
|
||||||
// RestartServer = Stop 同步完成後 Start。中間經過 Stopped 狀態。
|
|
||||||
func (a *App) RestartServer() error
|
|
||||||
|
|
||||||
// GetServerStatus 取代 v1 版本,回傳更完整的結構(見 §4.3)。
|
|
||||||
func (a *App) GetServerStatus() ServerStatusV2
|
|
||||||
|
|
||||||
// GetRecentLogs 回傳 LogBuffer 最後 n 行;n <= 0 或 > 2000 則回全部。
|
|
||||||
// 前端初次載入時呼叫一次,之後靠 log:append event 增量更新。
|
|
||||||
func (a *App) GetRecentLogs(n int) []LogLine
|
|
||||||
|
|
||||||
// ClearLogs 只清畫面(不動 server.stdout.log / server.stderr.log 磁碟檔)。
|
|
||||||
// 實作:LogBuffer.Reset() + emit event 'log:clear'。
|
|
||||||
func (a *App) ClearLogs()
|
|
||||||
|
|
||||||
// GetSystemInfo 回傳靜態系統資訊(給 Status card 顯示)。
|
|
||||||
// 不是 HTTP API 調用,直接從 Wails local state / config 讀,避免 server 未 Running 時撈不到。
|
|
||||||
func (a *App) GetSystemInfo() SystemInfo
|
|
||||||
|
|
||||||
// OpenInBrowser 用系統預設瀏覽器開啟 url;空字串則用當前 server URL。
|
|
||||||
// 實作沿用現有 openBrowser()(platform_darwin.go / _linux.go / _windows.go),完全不動。
|
|
||||||
func (a *App) OpenInBrowser(url string) error
|
|
||||||
|
|
||||||
// RevealLogsFolder 在檔案管理器中開啟 <dataDir>/logs/ 目錄。
|
|
||||||
// macOS: `open <path>` / Windows: `explorer <path>` / Linux: `xdg-open <path>`
|
|
||||||
func (a *App) RevealLogsFolder() error
|
|
||||||
|
|
||||||
// ExportLog 將 LogBuffer ring buffer 當前內容(最多 2000 行)寫入單一檔案並回傳路徑。
|
|
||||||
// 路徑:<dataDir>/exports/log-<timestamp>.txt (timestamp = time.Now().Format("20060102-150405"))
|
|
||||||
// 內容格式:逐行 dump,每行格式 "[level] timestamp message"(level 為空時省略 bracket)
|
|
||||||
// 實作要點:
|
|
||||||
// 1. 確保 <dataDir>/exports/ 目錄存在(os.MkdirAll 0755)
|
|
||||||
// 2. LogBuffer.Snapshot() 取得當前 ring buffer 副本(不干擾後續 append)
|
|
||||||
// 3. os.WriteFile(atomic-ish;檔案小、不需 atomic rename)
|
|
||||||
// 4. 回傳絕對路徑(前端可顯示 toast「已匯出至 <path>」並提供「在檔案管理器中顯示」按鈕)
|
|
||||||
// 用途:Wails 控制台「Export log」按鈕(Design Spec v2.1 §3.4);使用者回報問題時一鍵拿到 snapshot
|
|
||||||
func (a *App) ExportLog() (string, error)
|
|
||||||
|
|
||||||
// GetPreferences / SetPreferences 控制台 Preferences 區塊。
|
|
||||||
// 讀取失敗或檔案不存在時回傳 DefaultPreferences()(依平台預設)。
|
|
||||||
// SetPreferences 使用 atomic write-rename 避免 crash 時檔案損毀。
|
|
||||||
func (a *App) GetPreferences() Preferences
|
|
||||||
func (a *App) SetPreferences(p Preferences) error
|
|
||||||
```
|
|
||||||
|
|
||||||
**保留不動(v1 已有)**:`GetServerURL()`(前端控制台不會用,但為了相容性不刪)
|
|
||||||
|
|
||||||
**刪掉(v1 splash 殘留)**:`GetBootstrapStatus()`(splash 專用,控制台不需要)
|
|
||||||
|
|
||||||
### 4.3 型別定義
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ServerState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ServerStateStopped ServerState = "stopped"
|
|
||||||
ServerStateStarting ServerState = "starting"
|
|
||||||
ServerStateRunning ServerState = "running"
|
|
||||||
ServerStateStopping ServerState = "stopping"
|
|
||||||
ServerStateError ServerState = "error"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ServerStatusV2 struct {
|
|
||||||
State ServerState `json:"state"`
|
|
||||||
Port int `json:"port,omitempty"`
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
PID int `json:"pid,omitempty"`
|
|
||||||
PythonBin string `json:"pythonBin,omitempty"`
|
|
||||||
PythonMode string `json:"pythonMode,omitempty"`
|
|
||||||
StartedAt int64 `json:"startedAt,omitempty"` // Unix ms
|
|
||||||
LastError string `json:"lastError,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SystemInfo struct {
|
|
||||||
AppVersion string `json:"appVersion"` // Wails build 時塞的 version
|
|
||||||
BuildTime string `json:"buildTime"`
|
|
||||||
DataDir string `json:"dataDir"`
|
|
||||||
LogsDir string `json:"logsDir"`
|
|
||||||
Platform string `json:"platform"` // runtime.GOOS + "/" + runtime.GOARCH
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preferences 控制台偏好設定。對應檔案:<dataDir>/preferences.json。
|
|
||||||
// 詳細持久化策略見 server-lifecycle.md §11「Preferences 持久化」。
|
|
||||||
type Preferences struct {
|
|
||||||
// AutoOpenBrowser — StartServer 成功後是否自動開瀏覽器。
|
|
||||||
// 預設值由 DefaultPreferences() 依 runtime.GOOS 決定:
|
|
||||||
// macOS / Windows → true
|
|
||||||
// Linux → false (R5-D2:Linux 桌面環境差異大,預設關)
|
|
||||||
// 使用者可在 Preferences 區塊自行切換。
|
|
||||||
AutoOpenBrowser bool `json:"autoOpenBrowser"`
|
|
||||||
|
|
||||||
// Locale — 控制台 UI 的語系覆寫;空字串 → 自動偵測(navigator.language)。
|
|
||||||
Locale string `json:"locale,omitempty"`
|
|
||||||
|
|
||||||
// LogRingSize — log panel ring buffer 行數上限。0 → 使用預設 2000。
|
|
||||||
LogRingSize int `json:"logRingSize,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LogLine struct {
|
|
||||||
Ts int64 `json:"ts"` // Unix ms
|
|
||||||
Stream string `json:"stream"` // "stdout" / "stderr" / "wails"(控制台自己的 log)
|
|
||||||
Line string `json:"line"`
|
|
||||||
Level string `json:"level,omitempty"` // 解析出 INFO/WARN/ERROR 時才填
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 LogBuffer 實作(`visiona-local/log_buffer.go`)
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"io"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
logBufferCap = 2000
|
|
||||||
logScannerMaxBytes = 1 * 1024 * 1024 // 1 MB 單行上限
|
|
||||||
logBatchWindowMs = 10 // micro-batch window(見 R-v2-3)
|
|
||||||
)
|
|
||||||
|
|
||||||
type LogBuffer struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
lines [logBufferCap]LogLine
|
|
||||||
head int
|
|
||||||
size int
|
|
||||||
// stats:給未來觀察用
|
|
||||||
dropped uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLogBuffer() *LogBuffer { return &LogBuffer{} }
|
|
||||||
|
|
||||||
func (b *LogBuffer) Append(l LogLine) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
b.lines[b.head] = l
|
|
||||||
b.head = (b.head + 1) % logBufferCap
|
|
||||||
if b.size < logBufferCap {
|
|
||||||
b.size++
|
|
||||||
} else {
|
|
||||||
b.dropped++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot 依插入順序回傳 (最舊 → 最新) 的行,最多 n 行。
|
|
||||||
func (b *LogBuffer) Snapshot(n int) []LogLine {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
if n <= 0 || n > b.size {
|
|
||||||
n = b.size
|
|
||||||
}
|
|
||||||
out := make([]LogLine, 0, n)
|
|
||||||
start := (b.head - b.size + logBufferCap) % logBufferCap
|
|
||||||
// 跳過 b.size - n 行
|
|
||||||
skip := b.size - n
|
|
||||||
for i := 0; i < b.size; i++ {
|
|
||||||
idx := (start + i) % logBufferCap
|
|
||||||
if i < skip {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, b.lines[idx])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *LogBuffer) Reset() {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
b.head = 0
|
|
||||||
b.size = 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.5 logPump goroutine(`visiona-local/server_control.go`)
|
|
||||||
|
|
||||||
```go
|
|
||||||
// logPump 讀取 server 子程序的 stdout 或 stderr pipe,同時寫檔 + append 到 LogBuffer +
|
|
||||||
// 以 micro-batch 方式 emit log:append event 給前端。
|
|
||||||
//
|
|
||||||
// 參數:
|
|
||||||
// pipe — server cmd.StdoutPipe() 或 cmd.StderrPipe() 回傳的 ReadCloser
|
|
||||||
// stream — "stdout" / "stderr"
|
|
||||||
// fileWriter — logs/server.<stream>.log 的 os.File(append 模式)
|
|
||||||
// done — 當 pump 結束時關閉(例如 process exit / pipe EOF)
|
|
||||||
func (a *App) logPump(pipe io.ReadCloser, stream string, fileWriter io.Writer, done chan<- struct{}) {
|
|
||||||
defer close(done)
|
|
||||||
defer pipe.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(pipe)
|
|
||||||
scanner.Buffer(make([]byte, 64*1024), logScannerMaxBytes)
|
|
||||||
|
|
||||||
// micro-batch:累積 logBatchWindowMs 內的行,一次 emit 一個 log:append event
|
|
||||||
batch := make([]LogLine, 0, 16)
|
|
||||||
ticker := time.NewTicker(time.Duration(logBatchWindowMs) * time.Millisecond)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
flush := func() {
|
|
||||||
if len(batch) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if a.ctx != nil {
|
|
||||||
wailsRuntime.EventsEmit(a.ctx, "log:append", batch)
|
|
||||||
}
|
|
||||||
batch = batch[:0]
|
|
||||||
}
|
|
||||||
|
|
||||||
scanDone := make(chan struct{})
|
|
||||||
lineCh := make(chan string, 128)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(scanDone)
|
|
||||||
for scanner.Scan() {
|
|
||||||
select {
|
|
||||||
case lineCh <- scanner.Text():
|
|
||||||
default:
|
|
||||||
// 丟行(極高頻下的安全閥),同時寫檔仍保留
|
|
||||||
// 避免阻塞 scanner goroutine 讓 server stdout 背壓
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case line, ok := <-lineCh:
|
|
||||||
if !ok {
|
|
||||||
flush()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 1. 寫檔(持久化)
|
|
||||||
_, _ = fileWriter.Write([]byte(line + "\n"))
|
|
||||||
// 2. ring buffer
|
|
||||||
l := LogLine{
|
|
||||||
Ts: time.Now().UnixMilli(),
|
|
||||||
Stream: stream,
|
|
||||||
Line: line,
|
|
||||||
Level: parseLogLevel(line),
|
|
||||||
}
|
|
||||||
a.logBuf.Append(l)
|
|
||||||
// 3. 加進 batch
|
|
||||||
batch = append(batch, l)
|
|
||||||
case <-ticker.C:
|
|
||||||
flush()
|
|
||||||
case <-scanDone:
|
|
||||||
flush()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseLogLevel 嘗試從 Go logger 的典型格式抽 level(best-effort,只為 UI 著色,不影響邏輯)。
|
|
||||||
// 例:`2026/04/14 14:23:01 [INFO] ...` → "info"
|
|
||||||
// `[GIN] 500 | ... | POST /...` → "error"(status >= 500)
|
|
||||||
func parseLogLevel(line string) string {
|
|
||||||
// 實作細節省略;用簡單 substring match。
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6 ServerController state machine(`visiona-local/server_control.go`)
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ServerController struct {
|
|
||||||
app *App
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
state ServerState
|
|
||||||
proc *ServerProcess
|
|
||||||
startedAt time.Time
|
|
||||||
lastError string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ServerController) setState(s ServerState, err string) {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.state = s
|
|
||||||
c.lastError = err
|
|
||||||
c.mu.Unlock()
|
|
||||||
if c.app.ctx != nil {
|
|
||||||
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", c.app.GetServerStatus())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ServerController) Start() error {
|
|
||||||
c.mu.Lock()
|
|
||||||
if c.state == ServerStateRunning || c.state == ServerStateStarting || c.state == ServerStateStopping {
|
|
||||||
c.mu.Unlock()
|
|
||||||
return fmt.Errorf("cannot start: current state=%s", c.state)
|
|
||||||
}
|
|
||||||
c.state = ServerStateStarting
|
|
||||||
c.lastError = ""
|
|
||||||
c.mu.Unlock()
|
|
||||||
if c.app.ctx != nil {
|
|
||||||
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", c.app.GetServerStatus())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 呼叫 a.startServer()(沿用 v1 邏輯,見 app.go:425-564),差別:
|
|
||||||
// 1. 用 StdoutPipe/StderrPipe 取代 cmd.Stdout=file
|
|
||||||
// 2. 啟動兩個 logPump goroutine
|
|
||||||
// 3. 健康檢查 OK 後 state → Running
|
|
||||||
// 4. 失敗後 state → Error(不 os.Exit)
|
|
||||||
if err := c.app.startServerV2(c); err != nil {
|
|
||||||
c.setState(ServerStateError, err.Error())
|
|
||||||
if c.app.ctx != nil {
|
|
||||||
wailsRuntime.EventsEmit(c.app.ctx, "server:error", err.Error())
|
|
||||||
}
|
|
||||||
// R5-D1:server 啟動徹底失敗時,除了 Error state 以外也發 OS 原生通知
|
|
||||||
// 實作細節見 visiona-local/notify.go 與 server-lifecycle.md §10
|
|
||||||
go sendCrashNotification(
|
|
||||||
"visionA Local — Server 啟動失敗",
|
|
||||||
"點回應用程式查看錯誤詳情或按 Restart 重試。",
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.setState(ServerStateRunning, "")
|
|
||||||
|
|
||||||
// R5-D3:只要 Preferences 允許,每次 StartServer 成功都呼叫 OpenInBrowser。
|
|
||||||
// 取消 v2.0 的 autoOpenedThisSession flag(砍掉 per-session-once 概念)。
|
|
||||||
//
|
|
||||||
// 為什麼不怕 Restart 時開多個 tab:OS 瀏覽器的 open/start/xdg-open 對於
|
|
||||||
// 「已經載入同一 URL 的 tab」通常是聚焦既有 tab 而不是開新的;即使在
|
|
||||||
// 少數平台實際開了新 tab,這也是 R5-D3 明示可接受的結果。
|
|
||||||
//
|
|
||||||
// Preferences 預設值由 DefaultPreferences() 依 runtime.GOOS 決定:
|
|
||||||
// macOS / Windows → true
|
|
||||||
// Linux → false (R5-D2)
|
|
||||||
if c.app.prefs.AutoOpenBrowser {
|
|
||||||
_ = c.app.OpenInBrowser("")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ServerController) Stop() error {
|
|
||||||
c.mu.Lock()
|
|
||||||
if c.state == ServerStateStopped || c.state == ServerStateError {
|
|
||||||
c.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if c.state != ServerStateRunning {
|
|
||||||
c.mu.Unlock()
|
|
||||||
return fmt.Errorf("cannot stop: current state=%s", c.state)
|
|
||||||
}
|
|
||||||
c.state = ServerStateStopping
|
|
||||||
proc := c.proc
|
|
||||||
c.mu.Unlock()
|
|
||||||
if c.app.ctx != nil {
|
|
||||||
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", c.app.GetServerStatus())
|
|
||||||
}
|
|
||||||
|
|
||||||
if proc != nil {
|
|
||||||
proc.stop() // 沿用 v1:SIGTERM → 5s grace → SIGKILL
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
c.proc = nil
|
|
||||||
c.mu.Unlock()
|
|
||||||
c.setState(ServerStateStopped, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ServerController) Restart() error {
|
|
||||||
if err := c.Stop(); err != nil && err.Error() != "" {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.Start()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.7 watchServer 改為 Error state(修改 `app.go:571-610`)
|
|
||||||
|
|
||||||
原本:
|
|
||||||
```go
|
|
||||||
if failures >= 3 {
|
|
||||||
a.reportFatal("server died", ...)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
改為:
|
|
||||||
```go
|
|
||||||
if failures >= 3 {
|
|
||||||
a.ctrl.setState(ServerStateError, "health check failed 3 times")
|
|
||||||
if a.ctx != nil {
|
|
||||||
wailsRuntime.EventsEmit(a.ctx, "server:error", map[string]any{
|
|
||||||
"reason": "health check failed 3 times",
|
|
||||||
"port": sp.port,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// 不呼叫 reportFatal、不 os.Exit — 讓使用者在控制台手動 Restart 或查 log
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`reportFatal()` 本身保留(app.go:215-237),只留給 `startup()` 階段致命錯誤:data dir 建不起來、single-instance lock 無法取得(非「別人在跑」的情況)、首次啟動 startServer 失敗(此時無控制台可看)。後續 server 崩潰一律走 Error state。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 前端 JS 實作(`visiona-local/frontend/app.js` 重寫)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// visiona-local/frontend/app.js
|
|
||||||
import {
|
|
||||||
StartServer, StopServer, RestartServer,
|
|
||||||
GetServerStatus, GetRecentLogs, ClearLogs, GetSystemInfo,
|
|
||||||
OpenInBrowser, RevealLogsFolder, ExportLog,
|
|
||||||
GetPreferences, SetPreferences,
|
|
||||||
} from './wailsjs/go/main/App.js';
|
|
||||||
import { EventsOn } from './wailsjs/runtime/runtime.js';
|
|
||||||
|
|
||||||
import { renderStatusCard, updateStatusCard } from './components/status-card.js';
|
|
||||||
import { initLogPanel, appendLogLines, clearLogPanel } from './components/log-panel.js';
|
|
||||||
import { renderActionBar, updateActionBarState } from './components/action-bar.js';
|
|
||||||
import { renderPreferences } from './components/preferences.js';
|
|
||||||
import { loadLocale, t } from './i18n/loader.js';
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// 1. 載入 i18n(依 navigator.language)
|
|
||||||
await loadLocale();
|
|
||||||
|
|
||||||
// 2. 靜態元件一次 render
|
|
||||||
const sys = await GetSystemInfo();
|
|
||||||
renderStatusCard(sys);
|
|
||||||
initLogPanel();
|
|
||||||
renderActionBar({
|
|
||||||
onStart: () => StartServer().catch(showError),
|
|
||||||
onStop: () => StopServer().catch(showError),
|
|
||||||
onRestart: () => RestartServer().catch(showError),
|
|
||||||
onOpenBrowser: () => OpenInBrowser('').catch(showError),
|
|
||||||
onReveal: () => RevealLogsFolder().catch(showError),
|
|
||||||
onClear: () => { ClearLogs(); clearLogPanel(); },
|
|
||||||
onExportLog: async () => {
|
|
||||||
try {
|
|
||||||
const path = await ExportLog();
|
|
||||||
showToast(t('log.exported', { path }));
|
|
||||||
} catch (e) {
|
|
||||||
showError(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const prefs = await GetPreferences();
|
|
||||||
renderPreferences(prefs, async (p) => { await SetPreferences(p); });
|
|
||||||
|
|
||||||
// 3. 初始 server status + log 快照
|
|
||||||
const st = await GetServerStatus();
|
|
||||||
updateStatusCard(st);
|
|
||||||
updateActionBarState(st.state);
|
|
||||||
const initialLogs = await GetRecentLogs(2000);
|
|
||||||
appendLogLines(initialLogs);
|
|
||||||
|
|
||||||
// 4. 訂閱 Wails events
|
|
||||||
EventsOn('log:append', (batch) => appendLogLines(batch));
|
|
||||||
EventsOn('log:clear', () => clearLogPanel());
|
|
||||||
EventsOn('server:state-change', (newStatus) => {
|
|
||||||
updateStatusCard(newStatus);
|
|
||||||
updateActionBarState(newStatus.state);
|
|
||||||
});
|
|
||||||
EventsOn('server:error', (info) => {
|
|
||||||
// Status card 的 last error 段會自動透過 state-change 更新,
|
|
||||||
// 這裡額外做一個 toast(若有需要的話)。
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. R5-E 階段化啟動進度 — 四個 event 訂閱(細節見 v2/startup-pipeline.md)
|
|
||||||
EventsOn('startup:progress', (e) => {
|
|
||||||
// e = { stage, totalStages, labelKey, status, startedAt }
|
|
||||||
updateStartupPanel(e);
|
|
||||||
});
|
|
||||||
EventsOn('startup:stage-timeout', (e) => {
|
|
||||||
// e = { stage, softTimeoutSeconds }
|
|
||||||
// 顯示「階段 N 正在重試 ...」副文字(不中斷流程)
|
|
||||||
updateStartupPanel({ ...e, status: 'retrying' });
|
|
||||||
});
|
|
||||||
EventsOn('startup:error', (e) => {
|
|
||||||
// e = { stage, error }
|
|
||||||
// 切到 Error state + 彈 toast。實際 server state 會由 server:state-change 同步。
|
|
||||||
showStartupError(e);
|
|
||||||
});
|
|
||||||
EventsOn('startup:ready', () => {
|
|
||||||
// 總時 < 60 s、6 階段都 completed → 淡出啟動進度面板,顯示主控台
|
|
||||||
hideStartupPanel();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(e) {
|
|
||||||
// 顯示 inline toast;詳見 style.css .toast
|
|
||||||
const el = document.getElementById('toast');
|
|
||||||
el.textContent = String(e);
|
|
||||||
el.hidden = false;
|
|
||||||
setTimeout(() => (el.hidden = true), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => console.error('[visiona-local console] init failed:', e));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.1 Log panel 細節(`components/log-panel.js`)
|
|
||||||
|
|
||||||
- 容器:`<pre class="log__body">`,最多保留 2000 行(超過則從頂部截)
|
|
||||||
- Auto-scroll:若使用者滾輪已滾到底(`scrollTop + clientHeight >= scrollHeight - 2`),自動 scroll 到新行;若使用者手動往上捲,停止 auto-scroll(即 Pause 狀態)
|
|
||||||
- Pause 按鈕:明確切 auto-scroll on/off,即使在底部也不跟
|
|
||||||
- Level 顏色:`.log-line--info` / `--warn` / `--error`(由 Go `parseLogLevel` 塞到 LogLine.Level)
|
|
||||||
- Batch render:每收到一批 log:append,一次 DOM 更新(createDocumentFragment)避免 layout thrash
|
|
||||||
- 行長度截斷:單行 > 2 KB 的 log 在 UI 只顯示前 2 KB + `…(點擊展開)`
|
|
||||||
|
|
||||||
### 5.2 Action bar disable 邏輯
|
|
||||||
|
|
||||||
見 §3 的 disable 矩陣,`updateActionBarState(state)` 依 state 呼叫 `btn.disabled = true|false`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. i18n 機制
|
|
||||||
|
|
||||||
### 6.1 控制台自己的字串清單(不動 Next.js Web UI i18n)
|
|
||||||
|
|
||||||
`visiona-local/frontend/i18n/zh-TW.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCard": {
|
|
||||||
"running": "執行中",
|
|
||||||
"stopped": "已停止",
|
|
||||||
"starting": "啟動中…",
|
|
||||||
"stopping": "停止中…",
|
|
||||||
"error": "錯誤",
|
|
||||||
"port": "埠",
|
|
||||||
"pid": "PID",
|
|
||||||
"python": "Python",
|
|
||||||
"dataDir": "資料目錄"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"start": "啟動",
|
|
||||||
"stop": "停止",
|
|
||||||
"restart": "重啟",
|
|
||||||
"openBrowser": "在瀏覽器中開啟",
|
|
||||||
"revealLogs": "開啟 log 資料夾",
|
|
||||||
"clearLogs": "清空 log"
|
|
||||||
},
|
|
||||||
"logPanel": {
|
|
||||||
"title": "Server Log",
|
|
||||||
"pauseAutoScroll": "暫停捲動",
|
|
||||||
"resumeAutoScroll": "繼續捲動",
|
|
||||||
"clear": "清空"
|
|
||||||
},
|
|
||||||
"preferences": {
|
|
||||||
"title": "偏好設定",
|
|
||||||
"openBrowserOnStart": "Server 就緒時自動開啟瀏覽器"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"startFailed": "啟動失敗:{msg}",
|
|
||||||
"stopFailed": "停止失敗:{msg}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`en-US.json` 內容對應,英文版。
|
|
||||||
|
|
||||||
### 6.2 Loader
|
|
||||||
|
|
||||||
`visiona-local/frontend/i18n/loader.js`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
let strings = {};
|
|
||||||
|
|
||||||
// Q5:navigator.language fallback 強化。
|
|
||||||
//
|
|
||||||
// Wails v2 在 Windows 下某些情況會回 'en';在 Linux 下有時回 'C';在 macOS 下偶爾
|
|
||||||
// 會回空字串。我們明確處理這些邊緣情況:
|
|
||||||
//
|
|
||||||
// 1. 若 Preferences.Locale 有值(使用者手動覆寫)→ 直接用
|
|
||||||
// 2. 否則讀 navigator.languages[0] || navigator.language
|
|
||||||
// 3. 空字串 / 'C' / 'POSIX' → 視為 'en'
|
|
||||||
// 4. 以 'zh' 開頭(含 zh-TW / zh-CN / zh-HK / zh-SG)→ 一律載 zh-TW
|
|
||||||
// (我們目前只有一份中文,不做繁簡切換)
|
|
||||||
// 5. 其他 → 載 en-US
|
|
||||||
// 6. 若第一次 fetch 失敗 → 最終 fallback 到 hardcoded 英文字串集(~30 key)
|
|
||||||
//
|
|
||||||
// 第 6 點的 hardcoded fallback 直接定義在 loader.js 最底下,避免「i18n 完全壞掉
|
|
||||||
// 時控制台 UI 變成一片 placeholder key」。
|
|
||||||
export async function loadLocale(override) {
|
|
||||||
const raw = override
|
|
||||||
|| (Array.isArray(navigator.languages) && navigator.languages[0])
|
|
||||||
|| navigator.language
|
|
||||||
|| '';
|
|
||||||
const cleaned = String(raw).trim();
|
|
||||||
let lang;
|
|
||||||
if (cleaned === '' || cleaned === 'C' || cleaned === 'POSIX') {
|
|
||||||
lang = 'en-US';
|
|
||||||
} else if (cleaned.toLowerCase().startsWith('zh')) {
|
|
||||||
lang = 'zh-TW';
|
|
||||||
} else {
|
|
||||||
lang = 'en-US';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await fetch(`./i18n/${lang}.json`);
|
|
||||||
if (!res.ok) throw new Error(`i18n load failed: ${res.status}`);
|
|
||||||
strings = await res.json();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[visiona-local console] i18n load failed, using hardcoded fallback:', e);
|
|
||||||
strings = HARDCODED_EN_FALLBACK;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function t(key, params = {}) {
|
|
||||||
const parts = key.split('.');
|
|
||||||
let v = strings;
|
|
||||||
for (const p of parts) {
|
|
||||||
v = v?.[p];
|
|
||||||
if (v === undefined) return key;
|
|
||||||
}
|
|
||||||
if (typeof v !== 'string') return key;
|
|
||||||
return v.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ''));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 為何不共用 Next.js Web UI 的 i18n 檔
|
|
||||||
|
|
||||||
Next.js 的 `frontend/src/lib/i18n/{zh-TW,en}.ts` 是 TypeScript 源碼,需要編譯,控制台 UI 不引 TS build chain;若直接複製一份 JS 也可以,但控制台需要的 string 集合(~30 個 key)和 Web UI(~400+ key)差異太大,獨立 JSON 更清晰。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 檔案系統變化(relative to `visiona-local/`)
|
|
||||||
|
|
||||||
**刪檔**:無(現有 `frontend/` 下的 3 個檔都是要改寫而非刪)
|
|
||||||
|
|
||||||
**改寫**:
|
|
||||||
- `frontend/index.html`(M7-B 的 splash → 控制台 layout,~80 行 HTML)
|
|
||||||
- `frontend/app.js`(78 行 splash → ~130 行 init + 事件處理)
|
|
||||||
- `frontend/style.css`(112 行 splash 樣式 → ~300 行控制台 + dark/light mode tokens)
|
|
||||||
|
|
||||||
**新增**:
|
|
||||||
- `frontend/components/status-card.js`(~80 行)
|
|
||||||
- `frontend/components/log-panel.js`(~180 行,含 virtual-scroll-lite + batch render)
|
|
||||||
- `frontend/components/action-bar.js`(~80 行)
|
|
||||||
- `frontend/components/preferences.js`(~60 行)
|
|
||||||
- `frontend/components/startup-panel.js`(~100 行,R5-E 階段化進度面板)
|
|
||||||
- `frontend/i18n/zh-TW.json`、`frontend/i18n/en-US.json`(~60 行 each;含 startup.stage.1-6 / startup.retrying / startup.error)
|
|
||||||
- `frontend/i18n/loader.js`(~35 行,含 Q5 navigator.language fallback 強化)
|
|
||||||
- `frontend/icons/*.svg` × 6
|
|
||||||
- `server_control.go`(~250 行)
|
|
||||||
- `log_buffer.go`(~120 行)
|
|
||||||
- `preferences.go`(~80 行,含 `DefaultPreferences()` 依 `runtime.GOOS` 分平台 default + atomic write-rename)
|
|
||||||
- `notify.go`(~100 行,R5-D1 三平台 OS 通知)
|
|
||||||
- `startup_pipeline.go`(~180 行,R5-E 6 階段 watcher + event emit)
|
|
||||||
|
|
||||||
**修改**:
|
|
||||||
- `app.go`:
|
|
||||||
- 新增 `ctrl *ServerController` / `logBuf *LogBuffer` / `prefs Preferences` / `pipeline *StartupPipeline` 欄位(**不再**有 `autoOpenedThisSession` — R5-D3 砍掉 per-session-once 概念)
|
|
||||||
- 砍 `GetBootstrapStatus()` / `setBootstrapStatus()` / `bootstrapStatus` 欄位(splash 專用,~15 行)
|
|
||||||
- 砍 `mockMode` 欄位 + `VISIONA_MOCK` 環境變數讀取(R5-5a,見 `v2/deletions.md`)
|
|
||||||
- 改 `watchServer()` 失敗後行為(不 os.Exit,進 Error state + 發 OS 通知,R5-D1)
|
|
||||||
- 新增 bindings(§4.2 的 13 個 method:Start/Stop/Restart Server、GetServerStatus、GetRecentLogs、ClearLogs、GetSystemInfo、OpenInBrowser、RevealLogsFolder、ExportLog、GetPreferences、SetPreferences、RestartStartupSequence)
|
|
||||||
- `startup()` 流程:seed → lock → 階段化 `ctrl.Start`(透過 StartupPipeline,R5-E)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 待確認 / 風險
|
|
||||||
|
|
||||||
1. **Wails v2 EventsEmit 的 payload 大小上限** — micro-batch window 10 ms × 100 行 × 2 KB = 200 KB。Wails v2 文件沒明確標 payload limit,實作完要壓測:用一個 bash 腳本噴 1000 行/秒的 stdout 看 events 有沒有丟 / 延遲。若有問題,改成「每 batch 最多 50 行,超過就拆兩個 event」。
|
|
||||||
2. **Dark mode 與 system 切換時的閃爍** — CSS var 切換應用 `transition: background-color 0.2s`,但 `prefers-color-scheme` media query 切換瞬間 Wails WebView 可能有瞬閃。實測,必要時改用 JS 主動套 `data-theme` 屬性。
|
|
||||||
3. **啟動進度面板 vs 主控台的轉場** — R5-E 定義「6 階段 completed 後淡出」,但若啟動很快(< 2 s)閃太快不好看;若啟動慢(20 s+)使用者看得很久。實務上建議淡出用 300 ms ease,進度面板本身做 min-display-time 1 s 避免閃爍。文案與 min-display-time 等交 Design Spec v2.1 敲定。
|
|
||||||
4. **R5-D1 OS 通知在使用者關閉系統通知時的 fallback** — 若使用者在 macOS 系統偏好「通知」關閉 visionA Local,`osascript display notification` 會靜默失敗。不額外處理(最佳努力送達的設計),控制台 Error banner 仍會顯示。
|
|
||||||
5. **N-R4 CI/E2E 測試分層** — PM 審閱的 Minor 1,交 Testing Agent 階段解決。狀態:blocked-on-testing-agent。
|
|
||||||
|
|
||||||
**已移至 `server-lifecycle.md` §11 的項目**(不再重複):
|
|
||||||
- Preferences JSON 的 atomic write-rename(PM §11-1 已定案)
|
|
||||||
- `navigator.language` 在 Wails v2 的邊緣情況(Q5 已在 §6.2 強化)
|
|
||||||
@ -1,358 +0,0 @@
|
|||||||
# v2/cors-security.md — CORS + 安全邊界
|
|
||||||
|
|
||||||
> 所屬:TDD v2 §2.4
|
|
||||||
> 決策依據:三方共識 #5(CORS 限制 127.0.0.1/localhost)、R5-1(維持 127.0.0.1 綁定,不做 LAN)
|
|
||||||
> 對應 milestone:M8-8
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 目的
|
|
||||||
|
|
||||||
當使用者模式從「Wails WebView 內的 UI(origin `wails://`)」變成「瀏覽器 tab 內的 UI(origin `http://127.0.0.1:<port>`)」之後,server 暴露在任何本機瀏覽器程序的 CORS 攻擊面下。必須明確限定哪些 Origin 可以跨來源存取 API,關掉潛在的 CSRF / 資料竊取入口。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 攻擊場景(為什麼要做)
|
|
||||||
|
|
||||||
使用者在 Chrome 開了:
|
|
||||||
- Tab A: `http://127.0.0.1:3721/` ← visionA-local Web UI
|
|
||||||
- Tab B: `https://evil.example.com/` ← 某個有問題的網站
|
|
||||||
|
|
||||||
Tab B 的 JS 可以:
|
|
||||||
```javascript
|
|
||||||
fetch('http://127.0.0.1:3721/api/models/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: maliciousModelFormData,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**v1 的 CORS middleware**(`server/internal/api/middleware.go:9-29`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
if origin != "" {
|
|
||||||
c.Header("Access-Control-Allow-Origin", origin) // 回聲 Origin!
|
|
||||||
}
|
|
||||||
c.Header("Access-Control-Allow-Credentials", "true")
|
|
||||||
```
|
|
||||||
|
|
||||||
→ Tab B 的惡意 fetch 會收到 `Access-Control-Allow-Origin: https://evil.example.com` 和 `Access-Control-Allow-Credentials: true`,**CORS 放行**。Tab B 可以讀回應、可以帶 cookie、可以送 POST。
|
|
||||||
|
|
||||||
這在 v1 的 Wails WebView 模式下不是問題(Wails 內的 origin 是 `wails://`),但 v2 把業務 UI 搬到瀏覽器後變成真實威脅。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 新 CORS 政策
|
|
||||||
|
|
||||||
### 3.1 白名單
|
|
||||||
|
|
||||||
只允許以下 Origin 的跨來源請求:
|
|
||||||
- `http://127.0.0.1:*`(任何 port)
|
|
||||||
- `http://localhost:*`
|
|
||||||
- `http://[::1]:*`(IPv6 loopback,罕見但完整)
|
|
||||||
|
|
||||||
**不允許**:
|
|
||||||
- `https://...` — 瀏覽器連本機不可能是 https(沒有憑證)
|
|
||||||
- `null` Origin — 本地 HTML file 或某些 sandbox iframe 會送 null,不信任
|
|
||||||
- 其他任何 hostname
|
|
||||||
|
|
||||||
### 3.2 OPTIONS 預檢
|
|
||||||
|
|
||||||
- 來自白名單 Origin → 正常回 200 + 完整 ACA* headers
|
|
||||||
- 非白名單 Origin → 回 403 Forbidden(不回 `Access-Control-Allow-Origin`)
|
|
||||||
|
|
||||||
### 3.3 非預檢請求(simple request,例如 `GET` without custom header)
|
|
||||||
|
|
||||||
瀏覽器不會送 OPTIONS,直接送請求。這種請求 server 會執行 handler(即使 Origin 不在白名單),但回應 header 沒有 `Access-Control-Allow-Origin`,**瀏覽器 JS 讀不到回應**。這是 CORS 的基本行為。
|
|
||||||
|
|
||||||
然而 `GET` 造成的副作用(例如 `GET /api/devices/scan` 若被設計成觸發掃描)仍會執行。解決:**副作用操作一律 POST**(server 現況已做到)。GET 只用於讀資料。
|
|
||||||
|
|
||||||
**CSRF 風險**:POST simple request(content-type `application/x-www-form-urlencoded` / `multipart/form-data` / `text/plain`)不會觸發 OPTIONS 預檢。但 visionA-local 所有 POST handler 都吃 JSON 或 multipart 的**特定 field**,且 v2 會額外要求**所有 POST / PUT / DELETE handler 拒絕非白名單 Origin**(見 §4.3),進一步關掉漏洞。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 實作
|
|
||||||
|
|
||||||
### 4.1 修改 `server/internal/api/middleware.go`
|
|
||||||
|
|
||||||
整檔覆寫:
|
|
||||||
|
|
||||||
```go
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// allowedHosts 定義 CORS 白名單的 hostname。
|
|
||||||
// 任何 port 都允許,scheme 只允許 http(本機不可能是 https)。
|
|
||||||
var allowedHosts = map[string]bool{
|
|
||||||
"127.0.0.1": true,
|
|
||||||
"localhost": true,
|
|
||||||
"[::1]": true,
|
|
||||||
"::1": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// isAllowedOrigin 判斷 origin header 是否屬於白名單。
|
|
||||||
// 合法例:http://127.0.0.1:3721 / http://localhost:3721 / http://[::1]:3721
|
|
||||||
// 不合法例:https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721
|
|
||||||
func isAllowedOrigin(origin string) bool {
|
|
||||||
if origin == "" || origin == "null" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
u, err := url.Parse(origin)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if u.Scheme != "http" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
host := strings.ToLower(u.Hostname())
|
|
||||||
return allowedHosts[host]
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORSMiddleware 僅允許 127.0.0.1/localhost/::1 任意 port 的跨來源請求。
|
|
||||||
// 非白名單 Origin 的 OPTIONS 預檢會被擋下;其他方法會正常執行 handler
|
|
||||||
// 但回應沒有 Access-Control-Allow-Origin header,瀏覽器讀不到 body。
|
|
||||||
// 為了保守,state-changing 方法(POST/PUT/DELETE)會明確回 403 當 Origin 非白名單。
|
|
||||||
func CORSMiddleware() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
origin := c.GetHeader("Origin")
|
|
||||||
method := c.Request.Method
|
|
||||||
|
|
||||||
// Same-origin 請求(Origin header 為空)一律放行 — 瀏覽器不會在 same-origin 送 Origin
|
|
||||||
if origin == "" {
|
|
||||||
if method == http.MethodOptions {
|
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isAllowedOrigin(origin) {
|
|
||||||
// 非白名單 Origin
|
|
||||||
// 1. OPTIONS → 403,不回 ACA*
|
|
||||||
// 2. state-changing 方法 → 403
|
|
||||||
// 3. GET/HEAD → 執行 handler,但不回 ACA*,瀏覽器 JS 讀不到 body
|
|
||||||
if method == http.MethodOptions ||
|
|
||||||
method == http.MethodPost ||
|
|
||||||
method == http.MethodPut ||
|
|
||||||
method == http.MethodDelete ||
|
|
||||||
method == http.MethodPatch {
|
|
||||||
c.AbortWithStatus(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// GET / HEAD:執行但不設 ACA*
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 白名單 Origin:回完整 ACA* headers
|
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
||||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
||||||
c.Header("Access-Control-Allow-Credentials", "true")
|
|
||||||
c.Header("Vary", "Origin")
|
|
||||||
|
|
||||||
if method == http.MethodOptions {
|
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**diff 要點 vs v1**:
|
|
||||||
- 新增 `allowedHosts` + `isAllowedOrigin` 白名單檢查
|
|
||||||
- 原本「回聲 Origin」改為「只回白名單 Origin」
|
|
||||||
- state-changing 方法(POST/PUT/DELETE/PATCH)若 Origin 非白名單,直接 403
|
|
||||||
- 砍掉 `X-Relay-Token` header(relay 功能在 M1 已砍)
|
|
||||||
- 加 `Vary: Origin` 給 CDN / proxy 快取正確性(雖然我們沒走 CDN)
|
|
||||||
|
|
||||||
### 4.2 單元測試
|
|
||||||
|
|
||||||
新增 `server/internal/api/middleware_test.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestIsAllowedOrigin(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
origin string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"http://127.0.0.1:3721", true},
|
|
||||||
{"http://localhost:3721", true},
|
|
||||||
{"http://localhost", true},
|
|
||||||
{"http://[::1]:3721", true},
|
|
||||||
{"http://evil.example.com", false},
|
|
||||||
{"https://127.0.0.1:3721", false},
|
|
||||||
{"http://192.168.1.5:3721", false},
|
|
||||||
{"null", false},
|
|
||||||
{"", false},
|
|
||||||
{"http://127.0.0.1.evil.com", false},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
got := isAllowedOrigin(tc.origin)
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("isAllowedOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
M8-8 驗收時跑 `go test ./server/internal/api/... -run TestIsAllowedOrigin`,全部通過才算完成。
|
|
||||||
|
|
||||||
### 4.3 Pre-handler origin check(二道防線)
|
|
||||||
|
|
||||||
在 `router.go` 的 state-changing 路由註冊處額外掛一層 origin check,確保即使 CORSMiddleware 有 bug(例如某條 handler skip 了 middleware)也不會出事:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// router.go 新增
|
|
||||||
func requireSameOriginOrNoOrigin() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
origin := c.GetHeader("Origin")
|
|
||||||
if origin == "" {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !isAllowedOrigin(origin) {
|
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": gin.H{"code": "FORBIDDEN", "message": "origin not allowed"},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
掛在 `/api/*` group:
|
|
||||||
```go
|
|
||||||
api := r.Group("/api")
|
|
||||||
api.Use(requireSameOriginOrNoOrigin())
|
|
||||||
```
|
|
||||||
|
|
||||||
**實作備註**:`requireSameOriginOrNoOrigin` 和 `CORSMiddleware` 看似重複,但邏輯不同:
|
|
||||||
- CORSMiddleware 管「要不要回 ACA* headers」
|
|
||||||
- requireSameOriginOrNoOrigin 管「要不要執行 handler」
|
|
||||||
|
|
||||||
前者是瀏覽器層的防線,後者是 server 層的。兩層都擋才夠 defensive。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. WebSocket Origin check
|
|
||||||
|
|
||||||
Gin 的 WS upgrader(`gorilla/websocket`)需要自行設定 CheckOrigin。
|
|
||||||
|
|
||||||
### 5.1 現況
|
|
||||||
|
|
||||||
檢查 `server/internal/api/ws/` 下的所有 handler 建立 upgrader 的地方。
|
|
||||||
|
|
||||||
### 5.2 新增 helper(新 file `server/internal/api/ws/origin.go`)
|
|
||||||
|
|
||||||
```go
|
|
||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CheckOrigin 決定 WebSocket upgrade 是否允許。
|
|
||||||
// 與 HTTP CORS 白名單一致:http://127.0.0.1:* / http://localhost:* / http://[::1]:*
|
|
||||||
// 與 same-origin(Origin header 為空或等於 Host)。
|
|
||||||
func CheckOrigin(r *http.Request) bool {
|
|
||||||
origin := r.Header.Get("Origin")
|
|
||||||
if origin == "" {
|
|
||||||
return true // same-origin
|
|
||||||
}
|
|
||||||
u, err := url.Parse(origin)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if u.Scheme != "http" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
host := strings.ToLower(u.Hostname())
|
|
||||||
return host == "127.0.0.1" || host == "localhost" || host == "::1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
把所有 WS handler 的 upgrader `CheckOrigin` field 指向這個 func。
|
|
||||||
|
|
||||||
### 5.3 掃清單(M8-8 執行時 grep)
|
|
||||||
|
|
||||||
```
|
|
||||||
grep -rn 'websocket.Upgrader' /Users/jimchen/visionA/local-tool/server/internal/api/ws/
|
|
||||||
```
|
|
||||||
|
|
||||||
**預期**:每一個 upgrader 都要掛 `CheckOrigin: ws.CheckOrigin`。現況 v1 沒有明確設(gorilla 預設為 same-origin),v2 改為明確白名單 + 同 hostname。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 綁定 interface 維持 `127.0.0.1`(R5-1)
|
|
||||||
|
|
||||||
現況 `visiona-local/app.go:468` 明寫 `--host 127.0.0.1`,server `main.go` 會以此為 `http.Server.Addr` 啟動。v2 **完全不動**這個設定。
|
|
||||||
|
|
||||||
**不做**:
|
|
||||||
- `--host 0.0.0.0` toggle(R5-1 明確否決 LAN mode)
|
|
||||||
- Auth token / bearer / session(127.0.0.1 only 下沒必要,see `v1/risks-and-mitigations.md` 的分析)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 資料驗證邊界
|
|
||||||
|
|
||||||
v1 既有的驗證全部保留,v2 沒有變更:
|
|
||||||
- 檔案上傳大小限制
|
|
||||||
- 檔案類型白名單(v2 擴充為 `.mp4/.avi/.mov/.mpeg/.mpg`)
|
|
||||||
- Gin struct binding + validator tag(各 handler)
|
|
||||||
- `server/internal/model/` 的 .nef 檔案簽名檢查(若有)
|
|
||||||
|
|
||||||
新增:
|
|
||||||
|
|
||||||
- 檢查 `Content-Type` 是否為 `multipart/form-data`(已有,但 v2 要 explicit 防呆)
|
|
||||||
- 檢查檔案大小上限(v1 是 100 MB,v2 維持)
|
|
||||||
- Path traversal:`filepath.Clean` + 確認不含 `..`(`UploadVideo` 用 `os.CreateTemp` 沒有此問題;`UploadModel` 在 `custom-models/` 底下,v1 已做 sanitize,v2 增加測試 case)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 無 Auth token
|
|
||||||
|
|
||||||
本機單人使用(R5-1 + 127.0.0.1 only),不導入任何 auth。v2 保留 v1 的「單人無認證」模型。
|
|
||||||
|
|
||||||
**不做**:
|
|
||||||
- Basic auth / bearer token
|
|
||||||
- Login / logout / session
|
|
||||||
- 單機版的 TLS(沒意義)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 驗收條件
|
|
||||||
|
|
||||||
| 檢查 | 指令 | 預期 |
|
|
||||||
|------|------|------|
|
|
||||||
| 白名單 127.0.0.1 POST 可過 | `curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: http://127.0.0.1:9999'` | 200 + `Access-Control-Allow-Origin: http://127.0.0.1:9999` |
|
|
||||||
| 白名單 localhost GET 可過 | `curl http://127.0.0.1:3721/api/models -H 'Origin: http://localhost:3000'` | 200 + ACA header |
|
|
||||||
| 非白名單 GET 執行但無 ACA | `curl -v http://127.0.0.1:3721/api/models -H 'Origin: http://evil.com'` | 200,**無** ACA header |
|
|
||||||
| 非白名單 POST 403 | `curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: http://evil.com'` | 403 |
|
|
||||||
| 非白名單 OPTIONS 403 | `curl -X OPTIONS http://127.0.0.1:3721/api/models -H 'Origin: http://evil.com'` | 403 |
|
|
||||||
| `null` Origin 擋 | `curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: null'` | 403 |
|
|
||||||
| https Origin 擋 | `curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: https://127.0.0.1:3721'` | 403 |
|
|
||||||
| Same-origin 正常 | `curl http://127.0.0.1:3721/api/models`(不帶 Origin) | 200 + 正常 response |
|
|
||||||
| WS 白名單可連 | `websocat -H='Origin: http://127.0.0.1:9999' ws://127.0.0.1:3721/ws/devices/events` | 連上 |
|
|
||||||
| WS 非白名單擋 | `websocat -H='Origin: http://evil.com' ws://127.0.0.1:3721/ws/devices/events` | 403 / 拒絕 upgrade |
|
|
||||||
| 單元測試 | `cd server && go test ./internal/api/... -run TestIsAllowedOrigin` | PASS |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 待確認
|
|
||||||
|
|
||||||
1. **開發模式下的 Next.js dev server** — `frontend/` 在 `pnpm dev` 跑時是 `http://localhost:3000`,打後端 `http://127.0.0.1:3721` 會送 `Origin: http://localhost:3000`。白名單會放行(localhost 在名單),OK。要確認所有 API 呼叫的 CORS credentials flag 是否要設 `'include'` — 既有程式碼應該已經處理過。
|
|
||||||
2. **SSR / static export 下是否有 Origin header** — Next.js static export 的 page 在瀏覽器直接 fetch,Origin header 會是當前頁面的 origin(即 `http://127.0.0.1:3721`,等於 same-origin)— 但實務上 same-origin 不送 Origin header。驗收時用 Chrome devtools Network tab 確認。
|
|
||||||
3. **`requireSameOriginOrNoOrigin` 是否要 apply 在 WS 上** — WebSocket upgrade 是 HTTP GET,middleware 會看到。但 gorilla upgrader 自己會先 CheckOrigin。兩層都做等於雙保險,但也可能導致 edge case。M8-8 實測,若正常就留著。
|
|
||||||
@ -1,634 +0,0 @@
|
|||||||
# v2/deletions.md — 刪檔 / 刪程式碼清單
|
|
||||||
|
|
||||||
> 所屬:TDD v2 §2.5
|
|
||||||
> 版本:v2.1(2026-04-14 吸收 PM Minor 5 grep 精準化 + Architect Q1 互審結論)
|
|
||||||
> 決策依據:R5-5a(Mock 模式完全砍除)、R5-7 前置(yt-dlp 全套砍除)
|
|
||||||
> 對應 milestone:M8-1(砍 yt-dlp)、M8-2(砍 Mock)
|
|
||||||
> 給工程師:這是按表操作的清單,依序砍完就能送 Reviewer
|
|
||||||
|
|
||||||
**操作規則**:
|
|
||||||
- 每個「刪」動作後必須 `go build ./server/...` 和 `pnpm --dir frontend build` 確認還可 compile
|
|
||||||
- 每個「改」動作後同樣
|
|
||||||
- 全部砍完後 `git grep -i 'yt-dlp\|ytdlp\|YTDLP\|mock\|Mock\|MOCK\|VISIONA_MOCK'` 應該只剩註解(如「原本這裡有 Mock 模式,已在 v2 砍除」)或明確是別的 mock(例如 gomock 測試框架)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 後端 Go — yt-dlp 全套砍除
|
|
||||||
|
|
||||||
### 1.1 `server/internal/camera/video_source.go`
|
|
||||||
|
|
||||||
**刪除區塊**:第 91-140 行
|
|
||||||
|
|
||||||
```go
|
|
||||||
// ResolveWithYTDLP uses yt-dlp to extract the direct video stream URL
|
|
||||||
// from platforms like YouTube, Vimeo, etc.
|
|
||||||
// Returns the resolved direct URL or an error.
|
|
||||||
func ResolveWithYTDLP(rawURL string) (string, error) {
|
|
||||||
... 整個 func ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// friendlyYTDLPError 把 yt-dlp 的技術性錯誤訊息轉成使用者能理解的提示。
|
|
||||||
func friendlyYTDLPError(stderr string) string {
|
|
||||||
... 整個 func ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
同時檢查 import:若原本因為 `ResolveWithYTDLP` 引入了 `strings` / `os/exec` / `fmt`,確認這些 import 在檔案其他地方還有用(視情況保留或移除)。
|
|
||||||
|
|
||||||
**`NewVideoSourceFromURL` / `NewVideoSourceFromURLWithSeek` 的砍除**(v2.1 Minor 5 精準化):
|
|
||||||
|
|
||||||
v2.0 原本寫「可能仍然有其他路徑呼叫,grep 再決定」— 這是模糊的。Architect Q1 互審時已實際 grep 確認結論:
|
|
||||||
|
|
||||||
```
|
|
||||||
grep -rn 'NewVideoSourceFromURL' /Users/jimchen/visionA/local-tool/server/
|
|
||||||
```
|
|
||||||
|
|
||||||
**實測結果**(2026-04-14 Architect 互審):
|
|
||||||
- `server/internal/api/handlers/camera_handler.go:435`(`StartFromURL` 內部呼叫)— 即將砍
|
|
||||||
- `server/internal/api/handlers/camera_handler.go:731`(`handleVideoSeek` 的 `videoIsURL` guard 下的 seek 分支)— dead code,連同 `videoIsURL` field 一起砍
|
|
||||||
|
|
||||||
**沒有其他呼叫者**。所以:
|
|
||||||
|
|
||||||
- 連同 `NewVideoSourceFromURL` 整個 function 砍
|
|
||||||
- 連同 `NewVideoSourceFromURLWithSeek`(若有)整個 function 砍
|
|
||||||
- 連同 `newVideoSource(..., isURL=true, ...)` 的 `isURL` 參數分支砍(簡化內部函式簽名)
|
|
||||||
- `camera_handler.go:731` 的 `handleVideoSeek` 中 `if h.videoIsURL { ... }` 整段 seek URL 分支砍(dead code,因為 `videoIsURL` 將不可能為 true)
|
|
||||||
- `CameraHandler` struct 的 `videoIsURL bool` field 砍
|
|
||||||
|
|
||||||
**驗收**:砍完後以下 grep 應全部無輸出:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn 'NewVideoSourceFromURL\|videoIsURL' /Users/jimchen/visionA/local-tool/server/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 `server/internal/api/handlers/camera_handler.go`
|
|
||||||
|
|
||||||
**刪除區塊**:第 341-497 行
|
|
||||||
|
|
||||||
```go
|
|
||||||
// ytdlpHosts lists hostnames where yt-dlp should be used to resolve the actual
|
|
||||||
var ytdlpHosts = map[string]bool{ ... }
|
|
||||||
|
|
||||||
type urlKind int
|
|
||||||
const (
|
|
||||||
urlDirect urlKind = iota
|
|
||||||
urlYTDLP
|
|
||||||
urlBad
|
|
||||||
)
|
|
||||||
|
|
||||||
// classifyVideoURL determines how to handle the given URL.
|
|
||||||
func classifyVideoURL(rawURL string) (urlKind, string) { ... }
|
|
||||||
|
|
||||||
// StartFromURL handles video/stream inference from a URL (HTTP, HTTPS, RTSP).
|
|
||||||
func (h *CameraHandler) StartFromURL(c *gin.Context) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
→ 整個 `ytdlpHosts` / `urlKind` / `classifyVideoURL` / `StartFromURL` 函式全砍。
|
|
||||||
|
|
||||||
**`videoIsURL` field 的處置**(v2.1 Minor 5 定案):
|
|
||||||
|
|
||||||
Architect Q1 互審時已 grep:`videoIsURL` 只在 `camera_handler.go:731` 的 `handleVideoSeek` URL 分支被讀取,沒有 stopActivePipeline 的特殊 cleanup。因此:
|
|
||||||
|
|
||||||
- 砍 `CameraHandler.videoIsURL bool` field
|
|
||||||
- 砍 `handleVideoSeek` 內 `if h.videoIsURL { ... }` 整個 URL seek 分支(dead code — 砍 `StartFromURL` 後永遠不會是 true)
|
|
||||||
- 砍 `startVideoInference` 設 `h.videoIsURL = true` 的行(若有)
|
|
||||||
- 結論:**不保留為 always-false flag**,直接刪乾淨(v2.0 原本留餘地「可能保留」,v2.1 明確決定刪)
|
|
||||||
|
|
||||||
### 1.3 `server/internal/api/router.go`
|
|
||||||
|
|
||||||
**刪除**:第 83 行
|
|
||||||
|
|
||||||
```go
|
|
||||||
api.POST("/media/url", cameraHandler.StartFromURL)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.4 `server/internal/deps/checker.go`
|
|
||||||
|
|
||||||
**刪除**:第 30-32 行
|
|
||||||
|
|
||||||
```go
|
|
||||||
check("yt-dlp", false,
|
|
||||||
"macOS: brew install yt-dlp | Windows: winget install yt-dlp",
|
|
||||||
"--version"),
|
|
||||||
```
|
|
||||||
|
|
||||||
**同時修改**:第 69-70 行的註解提及 yt-dlp 冷啟動 20 秒的內容,更新為:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 效能:bundle 內的 binary 冷啟動可能較慢(尤其 PyInstaller),
|
|
||||||
// bundle binary 已知良好,跳過 version 查詢以加速啟動。
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.5 `server/main.go`
|
|
||||||
|
|
||||||
**修改**:第 89-95 行的 PATH 注入註解。原文:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH,讓 exec.Command("yt-dlp") / exec.Command("ffmpeg")
|
|
||||||
// 能透過 LookPath 找到 bundle 內的 binary(Go 1.19+ Windows 不再搜 cwd)。
|
|
||||||
```
|
|
||||||
|
|
||||||
改為:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH,讓 exec.Command("ffmpeg") / exec.Command("ffprobe")
|
|
||||||
// 能透過 LookPath 找到 bundle 內的 binary(Go 1.19+ Windows 不再搜 cwd)。
|
|
||||||
```
|
|
||||||
|
|
||||||
**PATH 注入邏輯本身不動**(ffmpeg 仍需要)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 後端 Go — Mock 模式全套砍除(R5-5a)
|
|
||||||
|
|
||||||
### 2.1 整檔刪除
|
|
||||||
|
|
||||||
| 檔案 | 行數 | 說明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `server/internal/driver/mock/mock_driver.go` | 183 | `MockDriver` struct 與所有 method |
|
|
||||||
| `server/internal/camera/mock_camera.go` | 95 | `MockCamera` struct 與所有 method |
|
|
||||||
|
|
||||||
**動作**:
|
|
||||||
```
|
|
||||||
rm server/internal/driver/mock/mock_driver.go
|
|
||||||
rmdir server/internal/driver/mock # 該目錄若空就刪
|
|
||||||
rm server/internal/camera/mock_camera.go
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 修改 `server/internal/device/manager.go`
|
|
||||||
|
|
||||||
**現況**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
import (
|
|
||||||
...
|
|
||||||
mockdriver "visiona-local/server/internal/driver/mock"
|
|
||||||
...
|
|
||||||
)
|
|
||||||
|
|
||||||
type Manager struct {
|
|
||||||
...
|
|
||||||
mockMode bool
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewManager(registry *DriverRegistry, mockMode bool, mockCount int, scriptPath string) *Manager {
|
|
||||||
...
|
|
||||||
if mockMode { ... mockdriver.NewMockDriver(...) ... }
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) Scan() error {
|
|
||||||
if m.mockMode { ... }
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) someMethod() {
|
|
||||||
if m.mockMode { ... }
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**改法**:
|
|
||||||
- 刪 `mockdriver` import
|
|
||||||
- 刪 `mockMode` / `mockCount` 欄位
|
|
||||||
- 修改 `NewManager` 簽名:`func NewManager(registry *DriverRegistry, scriptPath string) *Manager`
|
|
||||||
- 砍掉所有 `if m.mockMode { ... }` 分支,只留 real path
|
|
||||||
|
|
||||||
### 2.3 修改 `server/internal/camera/manager.go`
|
|
||||||
|
|
||||||
**現況**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Manager struct {
|
|
||||||
mockMode bool
|
|
||||||
mockCamera *MockCamera
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewManager(mockMode bool) *Manager {
|
|
||||||
return &Manager{mockMode: mockMode}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) Start(...) {
|
|
||||||
if m.mockMode {
|
|
||||||
m.mockCamera = NewMockCamera(width, height)
|
|
||||||
...
|
|
||||||
}
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**改法**:
|
|
||||||
- 刪 `mockMode` 欄位、`mockCamera` 欄位
|
|
||||||
- 改簽名:`func NewManager() *Manager`
|
|
||||||
- 砍所有 `if m.mockMode { ... }`
|
|
||||||
|
|
||||||
### 2.4 修改 `server/internal/config/config.go`
|
|
||||||
|
|
||||||
**現況**:第 21-23 行
|
|
||||||
|
|
||||||
```go
|
|
||||||
MockMode bool
|
|
||||||
MockCamera bool
|
|
||||||
MockDeviceCount int
|
|
||||||
```
|
|
||||||
|
|
||||||
第 39-41 行:
|
|
||||||
|
|
||||||
```go
|
|
||||||
flag.BoolVar(&cfg.MockMode, "mock", false, "Enable mock device driver")
|
|
||||||
flag.BoolVar(&cfg.MockCamera, "mock-camera", false, "Enable mock camera")
|
|
||||||
flag.IntVar(&cfg.MockDeviceCount, "mock-devices", 1, "Number of mock devices")
|
|
||||||
```
|
|
||||||
|
|
||||||
**改法**:整組刪(三個欄位 + 三個 flag)。
|
|
||||||
|
|
||||||
### 2.5 修改 `server/main.go`
|
|
||||||
|
|
||||||
**現況**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 第 86-87 行
|
|
||||||
logger.Info("Mock mode: %v, Mock camera: %v, Dev mode: %v, Python mode: %s",
|
|
||||||
cfg.MockMode, cfg.MockCamera, cfg.DevMode, cfg.PythonMode)
|
|
||||||
|
|
||||||
// 第 142 行
|
|
||||||
deviceMgr := device.NewManager(registry, cfg.MockMode, cfg.MockDeviceCount, bridgeScript)
|
|
||||||
|
|
||||||
// 第 147 行
|
|
||||||
cameraMgr := camera.NewManager(cfg.MockCamera)
|
|
||||||
```
|
|
||||||
|
|
||||||
**改法**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
logger.Info("Dev mode: %v, Python mode: %s", cfg.DevMode, cfg.PythonMode)
|
|
||||||
|
|
||||||
deviceMgr := device.NewManager(registry, bridgeScript)
|
|
||||||
|
|
||||||
cameraMgr := camera.NewManager()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.6 修改 `server/internal/device/manager_test.go`
|
|
||||||
|
|
||||||
如果測試檔案裡有 mockMode 相關 case → 整段刪。若整個測試檔都是 mock-based → 整檔刪。
|
|
||||||
|
|
||||||
**動作**:`cat server/internal/device/manager_test.go` 確認後決定。
|
|
||||||
|
|
||||||
### 2.7 修改 `server/internal/api/api_e2e_test.go`
|
|
||||||
|
|
||||||
如果 e2e test 開啟 mock mode 跑 → 改為 skip(沒硬體時 skip),或改寫成不依賴 mock。
|
|
||||||
|
|
||||||
**動作**:`grep -n 'Mock\|mock' server/internal/api/api_e2e_test.go` 查看,M8-2 執行者決定保留哪些測試。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Wails app.go — Mock 模式砍除
|
|
||||||
|
|
||||||
### 3.1 `visiona-local/app.go` 修改
|
|
||||||
|
|
||||||
**現況**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 第 83 行
|
|
||||||
type App struct {
|
|
||||||
...
|
|
||||||
mockMode bool
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第 119-120 行
|
|
||||||
// M7:預設真實硬體模式(使用者決策 Q8)
|
|
||||||
// 若要強制 mock 模式(無 Kneron 裝置環境下 debug),設環境變數 VISIONA_MOCK=1
|
|
||||||
mock := os.Getenv("VISIONA_MOCK") == "1"
|
|
||||||
return &App{
|
|
||||||
pythonMode: mode,
|
|
||||||
mockMode: mock,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第 429 行(在 startServer 中)
|
|
||||||
if err != nil && !a.mockMode {
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第 441 行
|
|
||||||
if !a.mockMode && pyBin != "" {
|
|
||||||
if err := a.ensureDriverInstalled(pyBin); err != nil {
|
|
||||||
...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第 472-479 行(組 args)
|
|
||||||
if a.mockMode {
|
|
||||||
args = append(args, "--mock")
|
|
||||||
} else {
|
|
||||||
args = append(args, "--python-mode", string(pyMode))
|
|
||||||
if pyBin != "" {
|
|
||||||
args = append(args, "--python", pyBin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第 502 行
|
|
||||||
if !a.mockMode && pyBin != "" {
|
|
||||||
env = append(env, "VISIONA_PYTHON="+pyBin)
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**改法**:
|
|
||||||
- 刪 `mockMode` struct field
|
|
||||||
- 刪 `NewApp()` 的 env 讀取 + 初始化
|
|
||||||
- 刪所有 `!a.mockMode` / `a.mockMode` 條件
|
|
||||||
- 組 args 的邏輯簡化:一定走 real path,`--python-mode` 與 `--python` 一定帶
|
|
||||||
- `err != nil && !a.mockMode` → 直接 `err != nil`(失敗就 return 錯誤)
|
|
||||||
|
|
||||||
### 3.2 `visiona-local/frontend/app.js`
|
|
||||||
|
|
||||||
若有任何提及 mock 的內容(M7-B 後應該沒有) → 刪
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 打包流程砍除
|
|
||||||
|
|
||||||
### 4.1 `Makefile` — 砍 yt-dlp
|
|
||||||
|
|
||||||
**刪除 targets**:
|
|
||||||
|
|
||||||
| 行數 | 內容 |
|
|
||||||
|------|------|
|
|
||||||
| ~22 | `vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ytdlp \` 中的 `vendor-ytdlp` |
|
|
||||||
| ~23 | `vendor-python-windows ... vendor-ytdlp-windows` 同上 |
|
|
||||||
| ~24 | `vendor-python-linux ... vendor-ytdlp-linux` 同上 |
|
|
||||||
| 39 | help 文字中 `/yt-dlp` |
|
|
||||||
| 70-71 | `YTDLP_URL_DARWIN := ...` |
|
|
||||||
| 73 | `vendor-sync` 依賴移除 `vendor-ytdlp` |
|
|
||||||
| 134-144 | `vendor-ytdlp:` target 整段 |
|
|
||||||
| 182 | `payload-macos` 依賴移除 `vendor-ytdlp` |
|
|
||||||
| 188-189 | `cp vendor/yt-dlp/darwin/yt-dlp payload/darwin/bin/` + `chmod +x payload/darwin/bin/yt-dlp` |
|
|
||||||
| 194 | help 文字 `+ yt-dlp` |
|
|
||||||
| 219 | `YTDLP_URL_WINDOWS := ...` |
|
|
||||||
| 288-296 | `vendor-ytdlp-windows:` target 整段 |
|
|
||||||
| 298 | `payload-windows` 依賴移除 `vendor-ytdlp-windows` |
|
|
||||||
| 299 | help 文字 `+ yt-dlp` |
|
|
||||||
| 307 | `cp vendor/yt-dlp/windows/yt-dlp.exe payload/windows/bin/` |
|
|
||||||
| 332 | `YTDLP_URL_LINUX := ...` |
|
|
||||||
| 381-390 | `vendor-ytdlp-linux:` target 整段 |
|
|
||||||
| 392 | `payload-linux` 依賴移除 `vendor-ytdlp-linux` |
|
|
||||||
| 393 | help 文字 `+ yt-dlp` |
|
|
||||||
| 401 | `cp vendor/yt-dlp/linux/yt-dlp ...` 整行 |
|
|
||||||
|
|
||||||
**同時**:刪 `/vendor/yt-dlp/` 整個目錄(若 Makefile 已經產出過)
|
|
||||||
|
|
||||||
```
|
|
||||||
rm -rf vendor/yt-dlp/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 `installer/windows/visiona-local.iss`
|
|
||||||
|
|
||||||
**刪除**:第 74 行的區段標題 `; ── ffmpeg + yt-dlp ───` 改為 `; ── ffmpeg + ffprobe ───`。
|
|
||||||
|
|
||||||
第 76 行:
|
|
||||||
|
|
||||||
```
|
|
||||||
Source: "..\..\payload\windows\bin\yt-dlp.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
|
|
||||||
```
|
|
||||||
|
|
||||||
**刪除此行**。同時新增 ffprobe 和 LGPL license 行(見 `v2/ffmpeg-lgpl.md` §8)。
|
|
||||||
|
|
||||||
### 4.3 `installer/linux/build-appimage.sh`
|
|
||||||
|
|
||||||
**修改**:第 61-62 行
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ffmpeg / yt-dlp
|
|
||||||
for tool in ffmpeg yt-dlp; do
|
|
||||||
```
|
|
||||||
|
|
||||||
改為:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ffmpeg / ffprobe
|
|
||||||
for tool in ffmpeg ffprobe; do
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 `scripts/bootstrap-linux.sh`
|
|
||||||
|
|
||||||
**修改**:第 61 行
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux vendor-ytdlp-linux
|
|
||||||
```
|
|
||||||
|
|
||||||
改為:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.5 `scripts/bootstrap-windows.ps1`
|
|
||||||
|
|
||||||
**修改**:
|
|
||||||
|
|
||||||
- 第 191 行註解:`保留 vendor/ 快取(Python runtime / wheels / ffmpeg / yt-dlp)以免重下 200MB` → `... 200MB)` 改為 `... (Python runtime / wheels / ffmpeg)`
|
|
||||||
- 第 202 行註解:同上
|
|
||||||
- 第 207 行:`'make vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows vendor-ytdlp-windows'` 移除 `vendor-ytdlp-windows`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 前端 TypeScript / React — yt-dlp URL tab 砍除
|
|
||||||
|
|
||||||
### 5.1 `frontend/src/components/camera/source-selector.tsx`
|
|
||||||
|
|
||||||
**現況**:260 行,使用 `videoMode` state + `pasteUrl` / `urlPlaceholder` / `urlHelpText` i18n、`handleUrlSubmit` / `startFromUrl` 呼叫。
|
|
||||||
|
|
||||||
**改動**:
|
|
||||||
|
|
||||||
1. 刪 import:`startFromUrl` 從 `useCameraStore` destructure 中移除(第 28 行附近)
|
|
||||||
2. 刪 state:第 51 行 `const [videoMode, setVideoMode] = useState<'file' | 'url'>('file');` 整行刪
|
|
||||||
3. 刪 state:同一段若有 `const [videoUrl, setVideoUrl] = useState('');` 也刪
|
|
||||||
4. 刪函式:第 94-96 行 `handleUrlSubmit` 整個函式刪
|
|
||||||
5. 刪 UI:第 185-253 行的 video tab 內的 mode toggle + `videoMode === 'file' ? ... : ...` 整塊改為「只剩 file 選擇」:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
{activeTab === 'video' && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
onClick={() => videoFileRef.current?.click()}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
{isUploading ? t('common.uploading') : t('camera.selectVideo')}
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t('camera.mp4AviMovMpeg')}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
ref={videoFileRef}
|
|
||||||
type="file"
|
|
||||||
accept=".mp4,.avi,.mov,.mpeg,.mpg" {/* R5-6 / 三方共識 #11:新增 mpeg/mpg */}
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleVideoSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
砍掉「上傳檔案 / 貼上連結」兩個 mode Button + URL input + help text + loading text。
|
|
||||||
|
|
||||||
### 5.2 `frontend/src/stores/camera-store.ts`
|
|
||||||
|
|
||||||
**刪除**:
|
|
||||||
|
|
||||||
- 第 32 行 type 定義:`startFromUrl: (url: string, deviceId: string) => Promise<void>;`
|
|
||||||
- 第 167-196 行:`startFromUrl` 函式的整個實作(30 行)
|
|
||||||
|
|
||||||
### 5.3 `frontend/src/lib/i18n/types.ts`
|
|
||||||
|
|
||||||
**刪除**:
|
|
||||||
|
|
||||||
- 第 210-212 行:`pasteUrl`, `urlPlaceholder`, `urlHelpText` 三個 key 的 type 定義
|
|
||||||
- 第 422 行:`cannotOpenVideoUrl` key 的 type 定義
|
|
||||||
|
|
||||||
**新增**:
|
|
||||||
- `mp4AviMovMpeg: string;`(取代 v1 的 `mp4AviMov` 或並存)— 決定改不改 key:**建議改名成 `videoFormats`**(通用化),以便未來擴充時不用再改型別名。
|
|
||||||
|
|
||||||
### 5.4 `frontend/src/lib/i18n/zh-TW.ts`
|
|
||||||
|
|
||||||
**刪除/修改**:
|
|
||||||
|
|
||||||
- 第 212 行 `pasteUrl: '貼上連結',` 刪
|
|
||||||
- 第 213 行 `urlPlaceholder: 'https://example.com/video.mp4',` 刪
|
|
||||||
- 第 214 行 `urlHelpText: '支援 YouTube、直接影片 URL(.mp4 等)及 RTSP 串流。',` 刪
|
|
||||||
- 第 217 行 `mp4AviMov: 'MP4, AVI, MOV',` → **改為** `videoFormats: 'MP4 / AVI / MOV / MPEG / MPG',`
|
|
||||||
- 第 424 行 `cannotOpenVideoUrl: '無法開啟影片連結',` 刪(若 store 沒有呼叫處)
|
|
||||||
|
|
||||||
### 5.5 `frontend/src/lib/i18n/en.ts`
|
|
||||||
|
|
||||||
同 §5.4,英文版對應修改:
|
|
||||||
- 刪 `pasteUrl`, `urlPlaceholder`, `urlHelpText`, `cannotOpenVideoUrl`
|
|
||||||
- `mp4AviMov: 'MP4, AVI, MOV',` → `videoFormats: 'MP4 / AVI / MOV / MPEG / MPG',`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 前端 TypeScript / React — Mock 模式砍除
|
|
||||||
|
|
||||||
### 6.1 `frontend/src/app/settings/page.tsx`
|
|
||||||
|
|
||||||
**刪除區塊**:第 140-156 行
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('settings.hardware.runtimeMode')}</Label>
|
|
||||||
{/* TODO: 連接 backend GET /api/system/config ... */}
|
|
||||||
<Select value="real" disabled>
|
|
||||||
<SelectTrigger className="w-[420px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="mock">{t('settings.hardware.runtimeModeMock')}</SelectItem>
|
|
||||||
<SelectItem value="real">{t('settings.hardware.runtimeModeReal')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('settings.hardware.runtimeModeHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
```
|
|
||||||
|
|
||||||
→ 整段 `<div>` 與其後的 `<Separator />` 一起刪(Separator 是用來分隔 runtimeMode 與 pythonMode 的)。
|
|
||||||
|
|
||||||
### 6.2 `frontend/src/lib/i18n/types.ts`
|
|
||||||
|
|
||||||
**刪除**:第 272-275 行
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
runtimeMode: string;
|
|
||||||
runtimeModeMock: string;
|
|
||||||
runtimeModeReal: string;
|
|
||||||
runtimeModeHint: string;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 `frontend/src/lib/i18n/zh-TW.ts`
|
|
||||||
|
|
||||||
**刪除**:第 274-277 行 4 個 key。
|
|
||||||
|
|
||||||
### 6.4 `frontend/src/lib/i18n/en.ts`
|
|
||||||
|
|
||||||
**刪除**:同 §6.3,第 274-277 行英文版。
|
|
||||||
|
|
||||||
### 6.5 `frontend/src/lib/i18n/zh-TW.ts` + `en.ts` 的 `noDevices` 文字
|
|
||||||
|
|
||||||
**現況**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// zh-TW.ts:70
|
|
||||||
noDevices: '未偵測到裝置。請確認已啟用 Mock 模式或連接裝置。',
|
|
||||||
|
|
||||||
// en.ts:70
|
|
||||||
noDevices: 'No devices detected. Make sure mock mode is enabled or connect a device.',
|
|
||||||
```
|
|
||||||
|
|
||||||
**改為**(對應 R-v2-7 的 empty state 建議):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// zh-TW
|
|
||||||
noDevices: '未偵測到 Kneron 裝置。請連接 KL520 / KL720 後按「掃描」。',
|
|
||||||
|
|
||||||
// en
|
|
||||||
noDevices: 'No Kneron devices detected. Please connect a KL520 / KL720 device and click "Scan".',
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Wails 控制台(改寫而非刪)
|
|
||||||
|
|
||||||
`visiona-local/frontend/index.html` / `app.js` / `style.css` — **改寫**而非刪,詳見 `v2/control-panel.md` §7「檔案系統變化」。
|
|
||||||
|
|
||||||
**提醒**:改寫步驟屬於 M8-5,不在本文件的 M8-1 / M8-2 範圍。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 驗收 grep
|
|
||||||
|
|
||||||
M8-1 + M8-2 全部完成後,執行以下 grep 應該全部 clean(只剩註解或非相關的 match):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# yt-dlp
|
|
||||||
grep -rn 'yt-dlp\|ytdlp\|YTDLP\|ResolveWithYTDLP\|friendlyYTDLPError\|ytdlpHosts\|urlYTDLP\|classifyVideoURL\|StartFromURL\|cannotOpenVideoUrl\|pasteUrl\|urlPlaceholder\|urlHelpText' \
|
|
||||||
/Users/jimchen/visionA/local-tool/server/ \
|
|
||||||
/Users/jimchen/visionA/local-tool/frontend/ \
|
|
||||||
/Users/jimchen/visionA/local-tool/visiona-local/ \
|
|
||||||
/Users/jimchen/visionA/local-tool/Makefile \
|
|
||||||
/Users/jimchen/visionA/local-tool/installer/ \
|
|
||||||
/Users/jimchen/visionA/local-tool/scripts/
|
|
||||||
|
|
||||||
# Mock
|
|
||||||
grep -rn 'MockMode\|mockMode\|MockCamera\|MockDriver\|NewMockDriver\|NewMockCamera\|VISIONA_MOCK\|runtimeModeMock' \
|
|
||||||
/Users/jimchen/visionA/local-tool/server/ \
|
|
||||||
/Users/jimchen/visionA/local-tool/frontend/ \
|
|
||||||
/Users/jimchen/visionA/local-tool/visiona-local/
|
|
||||||
```
|
|
||||||
|
|
||||||
**預期**:
|
|
||||||
- `yt-dlp` grep:**完全無輸出**(或只有 v1 文件 `.autoflow/` 內的歷史紀錄,忽略)
|
|
||||||
- `Mock` grep:**完全無輸出**
|
|
||||||
|
|
||||||
若有殘留 → 補刀刪除或保留但註記原因。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 砍除後的 compile 確認
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Go server
|
|
||||||
cd /Users/jimchen/visionA/local-tool/server && go build ./...
|
|
||||||
cd /Users/jimchen/visionA/local-tool/server && go test ./...
|
|
||||||
|
|
||||||
# Wails app
|
|
||||||
cd /Users/jimchen/visionA/local-tool/visiona-local && go build .
|
|
||||||
|
|
||||||
# 前端
|
|
||||||
cd /Users/jimchen/visionA/local-tool/frontend && pnpm install && pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
四個都要綠才算 M8-1 / M8-2 完成,可送 Reviewer。
|
|
||||||
@ -1,461 +0,0 @@
|
|||||||
# v2/ffmpeg-lgpl.md — ffmpeg LGPL 打包策略
|
|
||||||
|
|
||||||
> 所屬:TDD v2 §2.2
|
|
||||||
> 決策依據:R5-6(LGPL 方案 B 混合)、R5-6a(macOS decoder-only ~20 MB)、R5-6b(macOS binary commit 到 repo)、R5-6c(三平台都打包 ffprobe)
|
|
||||||
> 對應 milestone:M8-3
|
|
||||||
> 前置研究:`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/ffmpeg-lgpl-research.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 目的與範圍
|
|
||||||
|
|
||||||
把 v1 的「三平台 GPL ffmpeg build + `VISIONA_ALLOW_GPL_FFMPEG=1` 逃生門」換成 LGPL 方案 B(混合):
|
|
||||||
|
|
||||||
| 平台 | v1 來源 | v1 授權 | v2 來源 | v2 授權 | 存放位置 |
|
|
||||||
|------|--------|---------|--------|---------|---------|
|
|
||||||
| macOS x86_64 | evermeet.cx / zip | GPL | **自 build**(ffmpeg n7.1 + decoder-only configure) | **LGPLv3** | `vendor/ffmpeg/macos/`(進 git) |
|
|
||||||
| Windows x86_64 | BtbN / `…-win64-gpl.zip` | GPL | BtbN / `…-win64-lgpl.zip` | **LGPLv3** | `vendor/ffmpeg/windows/`(不進 git,Makefile 下載) |
|
|
||||||
| Linux x86_64 | johnvansickle static | GPL | BtbN / `…-linux64-lgpl-7.1.tar.xz` | **LGPLv3** | `vendor/ffmpeg/linux/`(不進 git,Makefile 下載) |
|
|
||||||
|
|
||||||
三平台都包 **ffmpeg + ffprobe** 兩支 binary(R5-6c)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. macOS:從源碼 build decoder-only(~20 MB)
|
|
||||||
|
|
||||||
### 2.1 選型理由
|
|
||||||
|
|
||||||
- 三平台只有 macOS x86_64 沒有現成的 LGPL static binary(完整分析見 `ffmpeg-lgpl-research.md` §2.3)
|
|
||||||
- R5-6a 決定「最小 decoder-only build(~20 MB)」,configure 用 `--disable-everything` + 主動 enable 需要的 decoder/demuxer
|
|
||||||
- R5-6b 決定 binary 直接 commit 到 `vendor/ffmpeg/macos/`,避免開發者每次 build 都跑一遍編譯(單次 build ~15 分鐘)
|
|
||||||
|
|
||||||
### 2.2 需要的 feature set(對應「瀏覽器能吃的格式」)
|
|
||||||
|
|
||||||
PRD v2 支援的上傳影片副檔名:`.mp4 / .avi / .mov / .mpeg / .mpg`。對應的 ffmpeg configure 能力:
|
|
||||||
|
|
||||||
| 能力 | 必要項目 | 來源 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 容器解析 | `demuxer=mov,avi,mpeg,mpegps,mpegts,matroska` | 內建(LGPL) |
|
|
||||||
| 影像解碼 | `decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores` | libavcodec native(LGPL) |
|
|
||||||
| 音訊解碼 | `decoder=aac,mp2,mp3,pcm_s16le,pcm_s16be` | libavcodec native(LGPL) |
|
|
||||||
| pixel format 轉換 | `filter=scale,format,fps` | libavfilter(LGPL) |
|
|
||||||
| 輸出為 image pipe | `muxer=image2pipe` / `encoder=mjpeg` | libavcodec(LGPL;mjpeg encoder 不是 libx264,LGPL 安全) |
|
|
||||||
| 協議 | `protocol=file` | 夠用(只處理本地上傳檔) |
|
|
||||||
| parser | `parser=h264,hevc,mpeg4video,mpegaudio,aac` | 必要,否則有些 decoder 會 fail |
|
|
||||||
|
|
||||||
**ffprobe**:ffprobe 是 ffmpeg 主 source tree 的一部分,同一次 `./configure && make` 會同時產出 `ffmpeg` 和 `ffprobe`,不用另外處理。
|
|
||||||
|
|
||||||
### 2.3 `Makefile` 新 target:`vendor-ffmpeg-macos-build`
|
|
||||||
|
|
||||||
新增到 `Makefile`(位置:現有 `vendor-ffmpeg` target 旁邊):
|
|
||||||
|
|
||||||
```makefile
|
|
||||||
FFMPEG_VERSION := n7.1
|
|
||||||
FFMPEG_SRC_URL := https://github.com/FFmpeg/FFmpeg/archive/refs/tags/$(FFMPEG_VERSION).tar.gz
|
|
||||||
FFMPEG_SRC_SHA256 := <填 sha256,build 第一次時用 `shasum -a 256` 計算後記錄,之後每次 build 用 sha256sum 驗證>
|
|
||||||
|
|
||||||
# 這個 target 只有要升級 ffmpeg 時才跑一次;平常開發者不需要跑,
|
|
||||||
# 因為 vendor/ffmpeg/macos/ffmpeg 已經 commit 到 repo(R5-6b)。
|
|
||||||
vendor-ffmpeg-macos-build: ## 從源碼 build LGPL decoder-only ffmpeg for macOS x86_64(只有升級 ffmpeg 時才跑)
|
|
||||||
@if [ "$$(uname -s)" != "Darwin" ]; then \
|
|
||||||
echo "❌ vendor-ffmpeg-macos-build 只能在 macOS 上跑"; exit 1; \
|
|
||||||
fi
|
|
||||||
@command -v pkg-config >/dev/null 2>&1 || { echo "❌ 需要 pkg-config(brew install pkg-config)"; exit 1; }
|
|
||||||
@command -v yasm >/dev/null 2>&1 || command -v nasm >/dev/null 2>&1 || { echo "❌ 需要 yasm 或 nasm(brew install yasm)"; exit 1; }
|
|
||||||
@mkdir -p vendor/ffmpeg/macos build/ffmpeg-macos
|
|
||||||
@echo "==> 下載 ffmpeg source $(FFMPEG_VERSION)..."
|
|
||||||
curl -fL -o build/ffmpeg-macos/source.tar.gz "$(FFMPEG_SRC_URL)"
|
|
||||||
@echo "==> 驗證 source tarball sha256..."
|
|
||||||
@echo "$(FFMPEG_SRC_SHA256) build/ffmpeg-macos/source.tar.gz" | shasum -a 256 -c || { \
|
|
||||||
echo "❌ sha256 不符,請更新 FFMPEG_SRC_SHA256 或檢查來源"; exit 1; }
|
|
||||||
@rm -rf build/ffmpeg-macos/src
|
|
||||||
@mkdir -p build/ffmpeg-macos/src
|
|
||||||
tar xzf build/ffmpeg-macos/source.tar.gz -C build/ffmpeg-macos/src --strip-components=1
|
|
||||||
@echo "==> configure(decoder-only LGPL)..."
|
|
||||||
cd build/ffmpeg-macos/src && ./configure \
|
|
||||||
--prefix="$$(pwd)/../install" \
|
|
||||||
--enable-version3 \
|
|
||||||
--disable-debug \
|
|
||||||
--disable-doc \
|
|
||||||
--disable-ffplay \
|
|
||||||
--disable-network \
|
|
||||||
--disable-autodetect \
|
|
||||||
--disable-shared \
|
|
||||||
--enable-static \
|
|
||||||
--disable-everything \
|
|
||||||
--enable-small \
|
|
||||||
--enable-protocol=file,pipe \
|
|
||||||
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
|
|
||||||
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
|
|
||||||
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
|
|
||||||
--enable-filter=scale,format,fps,null,anull \
|
|
||||||
--enable-muxer=image2pipe,image2,null \
|
|
||||||
--enable-encoder=mjpeg \
|
|
||||||
--enable-swscale \
|
|
||||||
--enable-swresample \
|
|
||||||
--extra-cflags="-arch x86_64 -mmacosx-version-min=10.15" \
|
|
||||||
--extra-ldflags="-arch x86_64 -mmacosx-version-min=10.15 -Wl,-search_paths_first" \
|
|
||||||
--arch=x86_64 \
|
|
||||||
--target-os=darwin \
|
|
||||||
--cc="clang -arch x86_64"
|
|
||||||
cd build/ffmpeg-macos/src && make -j$$(sysctl -n hw.ncpu)
|
|
||||||
cd build/ffmpeg-macos/src && make install
|
|
||||||
@echo "==> 複製 ffmpeg + ffprobe 到 vendor/ffmpeg/macos/..."
|
|
||||||
cp build/ffmpeg-macos/install/bin/ffmpeg vendor/ffmpeg/macos/ffmpeg
|
|
||||||
cp build/ffmpeg-macos/install/bin/ffprobe vendor/ffmpeg/macos/ffprobe
|
|
||||||
@strip -S -x vendor/ffmpeg/macos/ffmpeg
|
|
||||||
@strip -S -x vendor/ffmpeg/macos/ffprobe
|
|
||||||
@chmod +x vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
|
|
||||||
@echo "==> ad-hoc 簽章..."
|
|
||||||
codesign --force --sign - vendor/ffmpeg/macos/ffmpeg
|
|
||||||
codesign --force --sign - vendor/ffmpeg/macos/ffprobe
|
|
||||||
@echo "==> 驗證授權(configuration line 不含 --enable-gpl / libx264 / libx265)..."
|
|
||||||
@if vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -E -- '--enable-gpl|libx264|libx265'; then \
|
|
||||||
echo "❌ LGPL 驗證失敗:build 不該出現 gpl / x264 / x265"; exit 1; \
|
|
||||||
fi
|
|
||||||
@echo "==> 複製 COPYING.LGPLv3 到 vendor/ffmpeg/macos/..."
|
|
||||||
cp build/ffmpeg-macos/src/COPYING.LGPLv3 vendor/ffmpeg/macos/COPYING.LGPLv3
|
|
||||||
@echo "==> ffmpeg 大小:$$(du -h vendor/ffmpeg/macos/ffmpeg | cut -f1)"
|
|
||||||
@echo "==> ffprobe 大小:$$(du -h vendor/ffmpeg/macos/ffprobe | cut -f1)"
|
|
||||||
@echo ""
|
|
||||||
@echo "✅ macOS LGPL ffmpeg build 完成。請:1) 更新 vendor/ffmpeg/macos/BUILD.md 的 binary sha256"
|
|
||||||
@echo " 2) git add vendor/ffmpeg/macos/{ffmpeg,ffprobe,COPYING.LGPLv3,BUILD.md}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**關鍵 flags 解釋**:
|
|
||||||
|
|
||||||
| flag | 為什麼 |
|
|
||||||
|------|-------|
|
|
||||||
| `--enable-version3` | 使用 LGPLv3(不是 v2.1),和 BtbN 對齊 |
|
|
||||||
| `--disable-debug` / `--disable-doc` | 縮 binary |
|
|
||||||
| `--disable-network` | 我們只處理本地檔案,網路 protocol(rtmp/rtsp/http)用不到,可關 |
|
|
||||||
| `--disable-autodetect` | 不自動偵測系統上的外部 lib(libopus/libvpx 等);LGPL 合規稽核時更乾淨 |
|
|
||||||
| `--disable-shared --enable-static` | 產出 self-contained binary,不依賴 macOS 上任何外部 lib |
|
|
||||||
| `--disable-everything` | 先關全部,白名單 enable,確保不額外 link 任何東西 |
|
|
||||||
| `--enable-small` | 優化大小而非速度,進一步縮 binary |
|
|
||||||
| `--enable-protocol=file,pipe` | 只開 file:// 和 pipe(ffmpeg 內部 input/output),不開 http/rtsp |
|
|
||||||
| `--extra-cflags=-mmacosx-version-min=10.15` | 相容 macOS 10.15+(PRD v1 的 macOS 14/15 之外也能跑,無痛) |
|
|
||||||
|
|
||||||
### 2.4 `vendor/ffmpeg/macos/BUILD.md`(進 git,稽核用)
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# macOS LGPL ffmpeg build record
|
|
||||||
|
|
||||||
This directory contains a pre-built LGPL ffmpeg + ffprobe binary for macOS x86_64.
|
|
||||||
|
|
||||||
## Reproducibility
|
|
||||||
|
|
||||||
- ffmpeg release: n7.1
|
|
||||||
- Source tarball: https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.1.tar.gz
|
|
||||||
- Source sha256: <填值>
|
|
||||||
- Build host: macOS 14.4, Xcode Command Line Tools 15.3 (clang 1500.3.9.4)
|
|
||||||
- Build date: 2026-04-14
|
|
||||||
- Build flags: 見 `Makefile` 的 `vendor-ffmpeg-macos-build` target
|
|
||||||
|
|
||||||
## Binary sha256
|
|
||||||
|
|
||||||
- ffmpeg: <填值>
|
|
||||||
- ffprobe: <填值>
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
LGPLv3 — 完整授權條款見 `COPYING.LGPLv3`(與 binary 同目錄)。
|
|
||||||
|
|
||||||
## How to rebuild
|
|
||||||
|
|
||||||
```
|
|
||||||
make vendor-ffmpeg-macos-build
|
|
||||||
```
|
|
||||||
|
|
||||||
會:
|
|
||||||
1. 從 GitHub 下載 ffmpeg source tarball(n7.1)
|
|
||||||
2. 驗證 sha256
|
|
||||||
3. configure(只啟用 decoder/demuxer/filter 白名單)
|
|
||||||
4. make
|
|
||||||
5. 複製 ffmpeg + ffprobe 到這個目錄
|
|
||||||
6. strip + ad-hoc codesign
|
|
||||||
7. 驗證 `ffmpeg -version` 不含 `--enable-gpl` / `libx264` / `libx265`
|
|
||||||
|
|
||||||
Build 完成後請手動更新本檔的 Binary sha256 區塊,並 git commit。
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```
|
|
||||||
# 確認授權
|
|
||||||
vendor/ffmpeg/macos/ffmpeg -version | grep -E -- '--enable-gpl|libx264|libx265'
|
|
||||||
# 預期:沒有任何輸出
|
|
||||||
|
|
||||||
# 確認能解 5 種格式
|
|
||||||
for f in sample.mp4 sample.avi sample.mov sample.mpeg sample.mpg; do
|
|
||||||
vendor/ffmpeg/macos/ffmpeg -hide_banner -i "$f" -f null - 2>&1 | tail -5
|
|
||||||
done
|
|
||||||
|
|
||||||
# 確認 Gatekeeper 可以過
|
|
||||||
spctl --assess --verbose vendor/ffmpeg/macos/ffmpeg
|
|
||||||
# 預期:accepted(ad-hoc signed)
|
|
||||||
```
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.5 現有 `vendor-ffmpeg` target 的處理
|
|
||||||
|
|
||||||
v1 現有的 `Makefile:106-132` `vendor-ffmpeg` target 是從 evermeet.cx 下載 GPL build,**整段刪除**,改為:
|
|
||||||
|
|
||||||
```makefile
|
|
||||||
vendor-ffmpeg: ## macOS:LGPL binary 已 commit 到 vendor/ffmpeg/macos/,此 target 只驗證存在
|
|
||||||
@if [ ! -f vendor/ffmpeg/macos/ffmpeg ]; then \
|
|
||||||
echo "❌ vendor/ffmpeg/macos/ffmpeg 不存在。請執行 'make vendor-ffmpeg-macos-build' 從源碼 build"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
@if [ ! -f vendor/ffmpeg/macos/ffprobe ]; then \
|
|
||||||
echo "❌ vendor/ffmpeg/macos/ffprobe 不存在。請執行 'make vendor-ffmpeg-macos-build'"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
@echo "==> vendor/ffmpeg/macos/ffmpeg 存在:$$(du -h vendor/ffmpeg/macos/ffmpeg | cut -f1)"
|
|
||||||
@echo "==> vendor/ffmpeg/macos/ffprobe 存在:$$(du -h vendor/ffmpeg/macos/ffprobe | cut -f1)"
|
|
||||||
@# 驗證 LGPL(沒有 --enable-gpl / libx264 / libx265)
|
|
||||||
@if vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -qE -- '--enable-gpl|libx264|libx265'; then \
|
|
||||||
echo "❌ LGPL 驗證失敗"; exit 1; \
|
|
||||||
fi
|
|
||||||
@echo "==> LGPL 驗證通過"
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**:原本 `vendor-ffmpeg` 是把 binary 放在 `vendor/ffmpeg/darwin/`,v2 改為 `vendor/ffmpeg/macos/`(與 PRD 術語對齊 + 與 git commit 的目錄名一致)。payload 階段也要同步改(§5)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Windows:換 BtbN LGPL(改一行 URL)
|
|
||||||
|
|
||||||
修改 `Makefile:218`:
|
|
||||||
|
|
||||||
```makefile
|
|
||||||
# 原:
|
|
||||||
FFMPEG_URL_WINDOWS := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
|
|
||||||
|
|
||||||
# 改為:
|
|
||||||
FFMPEG_URL_WINDOWS := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-win64-lgpl-7.1.zip
|
|
||||||
```
|
|
||||||
|
|
||||||
**為什麼用 n7.1 穩定分支而非 master-latest**:見 `ffmpeg-lgpl-research.md` §5.3,n7.1 綁定 7.1 release + 每日 backport bugfix,避免 master 的隨機 regression。
|
|
||||||
|
|
||||||
修改 `Makefile:261-286` `vendor-ffmpeg-windows` target:
|
|
||||||
|
|
||||||
```makefile
|
|
||||||
vendor-ffmpeg-windows: ## 下載 Windows LGPL ffmpeg + ffprobe → vendor/ffmpeg/windows/
|
|
||||||
@mkdir -p vendor/ffmpeg/windows
|
|
||||||
@if [ -f vendor/ffmpeg/windows/ffmpeg.exe ] && [ -f vendor/ffmpeg/windows/ffprobe.exe ]; then \
|
|
||||||
echo "==> ffmpeg.exe + ffprobe.exe 已存在,跳過"; \
|
|
||||||
else \
|
|
||||||
echo "==> 下載 BtbN LGPL ffmpeg (Windows, n7.1)..."; \
|
|
||||||
curl -fL -o vendor/ffmpeg/windows/ffmpeg-win.zip "$(FFMPEG_URL_WINDOWS)"; \
|
|
||||||
PY=""; \
|
|
||||||
for candidate in "$$VISIONA_PYTHON" "py -3" python3 python; do \
|
|
||||||
[ -z "$$candidate" ] && continue; \
|
|
||||||
if $$candidate --version >/dev/null 2>&1; then PY="$$candidate"; break; fi; \
|
|
||||||
done; \
|
|
||||||
[ -n "$$PY" ] || { echo "❌ 需要 python"; exit 1; }; \
|
|
||||||
$$PY -c "import zipfile; z=zipfile.ZipFile('vendor/ffmpeg/windows/ffmpeg-win.zip'); \
|
|
||||||
names=[n for n in z.namelist() if n.endswith('/bin/ffmpeg.exe') or n.endswith('/bin/ffprobe.exe') or n.endswith('/LICENSE.txt') or n.endswith('/COPYING.LGPLv3')]; \
|
|
||||||
import os; os.makedirs('vendor/ffmpeg/windows', exist_ok=True); \
|
|
||||||
for m in names: \
|
|
||||||
src = z.open(m); \
|
|
||||||
base = m.rsplit('/',1)[1]; \
|
|
||||||
dst = open(f'vendor/ffmpeg/windows/{base}', 'wb'); \
|
|
||||||
dst.write(src.read()); dst.close(); src.close(); \
|
|
||||||
print('extracted:', names)"; \
|
|
||||||
rm -f vendor/ffmpeg/windows/ffmpeg-win.zip; \
|
|
||||||
[ -f vendor/ffmpeg/windows/ffmpeg.exe ] || { echo "❌ ffmpeg.exe 沒被寫出"; exit 1; }; \
|
|
||||||
[ -f vendor/ffmpeg/windows/ffprobe.exe ] || { echo "❌ ffprobe.exe 沒被寫出"; exit 1; }; \
|
|
||||||
echo "==> ffmpeg.exe 大小:$$(du -h vendor/ffmpeg/windows/ffmpeg.exe | cut -f1)"; \
|
|
||||||
echo "==> ffprobe.exe 大小:$$(du -h vendor/ffmpeg/windows/ffprobe.exe | cut -f1)"; \
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Linux:換 BtbN LGPL(改一行 URL + 解壓路徑調整)
|
|
||||||
|
|
||||||
修改 `Makefile:331`:
|
|
||||||
|
|
||||||
```makefile
|
|
||||||
# 原:
|
|
||||||
FFMPEG_URL_LINUX := https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
|
||||||
|
|
||||||
# 改為:
|
|
||||||
FFMPEG_URL_LINUX := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz
|
|
||||||
```
|
|
||||||
|
|
||||||
修改 `Makefile:362-379` `vendor-ffmpeg-linux` target:
|
|
||||||
|
|
||||||
```makefile
|
|
||||||
vendor-ffmpeg-linux: ## 下載 Linux LGPL ffmpeg + ffprobe → vendor/ffmpeg/linux/
|
|
||||||
@mkdir -p vendor/ffmpeg/linux
|
|
||||||
@if [ -f vendor/ffmpeg/linux/ffmpeg ] && [ -f vendor/ffmpeg/linux/ffprobe ]; then \
|
|
||||||
echo "==> ffmpeg + ffprobe (Linux) 已存在,跳過"; \
|
|
||||||
else \
|
|
||||||
echo "==> 下載 BtbN LGPL ffmpeg (Linux, n7.1)..."; \
|
|
||||||
curl -fL -o /tmp/ffmpeg-linux.tar.xz "$(FFMPEG_URL_LINUX)"; \
|
|
||||||
mkdir -p /tmp/ffmpeg-linux-extract; \
|
|
||||||
tar xf /tmp/ffmpeg-linux.tar.xz -C /tmp/ffmpeg-linux-extract --strip-components=1; \
|
|
||||||
cp /tmp/ffmpeg-linux-extract/bin/ffmpeg vendor/ffmpeg/linux/; \
|
|
||||||
cp /tmp/ffmpeg-linux-extract/bin/ffprobe vendor/ffmpeg/linux/; \
|
|
||||||
cp /tmp/ffmpeg-linux-extract/LICENSE.txt vendor/ffmpeg/linux/LICENSE.txt 2>/dev/null || true; \
|
|
||||||
chmod +x vendor/ffmpeg/linux/ffmpeg vendor/ffmpeg/linux/ffprobe; \
|
|
||||||
rm -rf /tmp/ffmpeg-linux* ; \
|
|
||||||
echo "==> ffmpeg (Linux) 大小:$$(du -h vendor/ffmpeg/linux/ffmpeg | cut -f1)"; \
|
|
||||||
echo "==> ffprobe (Linux) 大小:$$(du -h vendor/ffmpeg/linux/ffprobe | cut -f1)"; \
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
**差別於 v1**:
|
|
||||||
- URL 來源換掉
|
|
||||||
- `--strip-components` 從 1 改為 1(BtbN tar 的 `strip-components=1` 後頂層就是 `bin/`,一樣)— **注意**:BtbN 的 Linux tar 頂層目錄是 `ffmpeg-n7.1-latest-linux64-lgpl-7.1/`,解壓後 `strip-components=1` 會進到 `bin/`、`doc/`、`lib/`、`include/`、`share/`、`LICENSE.txt`、`README.txt`。`cp /tmp/ffmpeg-linux-extract/bin/ffmpeg` 路徑正確
|
|
||||||
- 同時抓 `ffprobe`
|
|
||||||
- 複製 `LICENSE.txt` 作為授權檔
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Vendor directory layout
|
|
||||||
|
|
||||||
```
|
|
||||||
vendor/ffmpeg/
|
|
||||||
├── macos/ ← R5-6b:commit 到 git
|
|
||||||
│ ├── ffmpeg ← ~10 MB(進 git)
|
|
||||||
│ ├── ffprobe ← ~10 MB(進 git)
|
|
||||||
│ ├── COPYING.LGPLv3 ← 進 git
|
|
||||||
│ └── BUILD.md ← 進 git,reproducibility 稽核用
|
|
||||||
├── windows/ ← 不進 git,Makefile 下載
|
|
||||||
│ ├── ffmpeg.exe ← ~100 MB(BtbN LGPL 打包含很多 LGPL extra libs,未 strip)
|
|
||||||
│ ├── ffprobe.exe ← ~100 MB
|
|
||||||
│ ├── LICENSE.txt ← BtbN 自帶
|
|
||||||
│ └── COPYING.LGPLv3 ← BtbN 自帶
|
|
||||||
└── linux/ ← 不進 git,Makefile 下載
|
|
||||||
├── ffmpeg ← ~80 MB
|
|
||||||
├── ffprobe ← ~80 MB
|
|
||||||
└── LICENSE.txt ← BtbN 自帶
|
|
||||||
```
|
|
||||||
|
|
||||||
**為什麼 Windows/Linux 不自 build 省體積?** — BtbN LGPL 還 link 了 libopus / libvpx / libaom / libdav1d / libopenh264 / libmp3lame / libvorbis 等 LGPL-safe extra lib,binary 較大。對 Windows / Linux installer(~380 / 320 MB 現況)多出 50-100 MB 屬可接受範圍;要把 Windows/Linux 也降到 20 MB 就要跑三平台 CI matrix 自 build(方案 C,`ffmpeg-lgpl-research.md` §4.1 已分析,多花 1-1.5 人天,不划算)。
|
|
||||||
|
|
||||||
**macOS 自 build 是不得不做**(沒有現成 LGPL 來源),順便做到 20 MB 是 bonus。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. `.gitignore` 更新
|
|
||||||
|
|
||||||
修改 `.gitignore`:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
# ── 第三方依賴(由 make vendor-sync 下載,不進 git;第三輪決策 Q-D=D2) ──
|
|
||||||
/vendor/**
|
|
||||||
!/vendor/.gitkeep
|
|
||||||
!/vendor/README.md
|
|
||||||
+# R5-6b:macOS LGPL ffmpeg binary 進 git(沒有現成來源,自 build 成本高)
|
|
||||||
+!/vendor/ffmpeg/
|
|
||||||
+!/vendor/ffmpeg/macos/
|
|
||||||
+!/vendor/ffmpeg/macos/**
|
|
||||||
```
|
|
||||||
|
|
||||||
(`!` 規則的順序對 git 很敏感,這四行必須一起加;實測前需要用 `git check-ignore vendor/ffmpeg/macos/ffmpeg` 驗證)
|
|
||||||
|
|
||||||
**注意**:`!/vendor/ffmpeg/macos/**` 會取消忽略 macos/ 底下所有檔,包括未來可能意外丟進來的 `.o` 或 build artifact。為了防呆,BUILD.md 要明示「只 commit ffmpeg / ffprobe / COPYING.LGPLv3 / BUILD.md 四個檔」,程式碼 review 時檢查。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Payload 階段變化
|
|
||||||
|
|
||||||
修改 `Makefile:182-203` `payload-macos` target:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
-payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg vendor-ytdlp
|
|
||||||
+payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg
|
|
||||||
@echo "==> 建立 macOS payload..."
|
|
||||||
rm -rf payload/darwin
|
|
||||||
mkdir -p payload/darwin/bin payload/darwin/data payload/darwin/scripts payload/darwin/python payload/darwin/wheels
|
|
||||||
cp dist/visiona-local-server payload/darwin/bin/
|
|
||||||
- cp vendor/ffmpeg/darwin/ffmpeg payload/darwin/bin/
|
|
||||||
- cp vendor/yt-dlp/darwin/yt-dlp payload/darwin/bin/
|
|
||||||
- chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/yt-dlp
|
|
||||||
+ cp vendor/ffmpeg/macos/ffmpeg payload/darwin/bin/
|
|
||||||
+ cp vendor/ffmpeg/macos/ffprobe payload/darwin/bin/
|
|
||||||
+ cp vendor/ffmpeg/macos/COPYING.LGPLv3 payload/darwin/bin/ffmpeg-COPYING.LGPLv3
|
|
||||||
+ chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/ffprobe
|
|
||||||
```
|
|
||||||
|
|
||||||
Windows(`payload-windows`)與 Linux(`payload-linux`)同步:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
# payload-windows
|
|
||||||
- cp vendor/ffmpeg/windows/ffmpeg.exe payload/windows/bin/
|
|
||||||
- cp vendor/yt-dlp/windows/yt-dlp.exe payload/windows/bin/
|
|
||||||
+ cp vendor/ffmpeg/windows/ffmpeg.exe payload/windows/bin/
|
|
||||||
+ cp vendor/ffmpeg/windows/ffprobe.exe payload/windows/bin/
|
|
||||||
+ cp vendor/ffmpeg/windows/COPYING.LGPLv3 payload/windows/bin/ffmpeg-COPYING.LGPLv3 2>/dev/null || true
|
|
||||||
|
|
||||||
# payload-linux
|
|
||||||
- @cp vendor/ffmpeg/linux/ffmpeg payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffmpeg || echo "!! WARN: ffmpeg 缺失"
|
|
||||||
- @cp vendor/yt-dlp/linux/yt-dlp payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/yt-dlp || echo "!! WARN: yt-dlp 缺失"
|
|
||||||
+ @cp vendor/ffmpeg/linux/ffmpeg payload/linux/bin/ && chmod +x payload/linux/bin/ffmpeg
|
|
||||||
+ @cp vendor/ffmpeg/linux/ffprobe payload/linux/bin/ && chmod +x payload/linux/bin/ffprobe
|
|
||||||
+ @cp vendor/ffmpeg/linux/LICENSE.txt payload/linux/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
|
|
||||||
```
|
|
||||||
|
|
||||||
`vendor-ytdlp*` / `vendor/yt-dlp/` 整塊砍除(見 `v2/deletions.md` §4)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 授權檔案在 installer 中的呈現
|
|
||||||
|
|
||||||
每個 installer 都要附上 ffmpeg 的 LGPL 條款與 source 對應指引。
|
|
||||||
|
|
||||||
**macOS**:`installer/macos/`(目前沒有這個目錄,dmgbuild 直接從 `.app` 建)— 把 `ffmpeg-COPYING.LGPLv3` 放在 `.app/Contents/Resources/bin/` 底下(已透過 payload-macos 自動 cp),加上 About dialog 裡的 link(v2 後續 design agent 設計)。
|
|
||||||
|
|
||||||
**Windows Inno Setup**:在 `installer/windows/visiona-local.iss` 的 `[Files]` 段加一行:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
Source: "..\..\payload\windows\bin\ffmpeg.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
|
|
||||||
-Source: "..\..\payload\windows\bin\yt-dlp.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
|
|
||||||
+Source: "..\..\payload\windows\bin\ffprobe.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
|
|
||||||
+Source: "..\..\payload\windows\bin\ffmpeg-COPYING.LGPLv3"; DestDir: "{app}\bin"; Flags: ignoreversion
|
|
||||||
```
|
|
||||||
|
|
||||||
**Linux AppImage**:`installer/linux/build-appimage.sh` 第 61-62 行原本迭代 `ffmpeg / yt-dlp`,改為 `ffmpeg / ffprobe`,並額外 copy `ffmpeg-LICENSE.txt` 到 `AppDir/usr/share/doc/visiona-local/`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 取得 LGPL source 的 offer
|
|
||||||
|
|
||||||
LGPLv3 要求散發者提供對應原始碼。兩種做法:
|
|
||||||
|
|
||||||
1. **Written offer**(省空間):在 About dialog / Help menu / PRD 公告處提供 URL:
|
|
||||||
- macOS LGPL build → `https://github.com/<org>/visiona-local` repo 的 `vendor/ffmpeg/macos/BUILD.md` 指向 ffmpeg n7.1 source tarball 與確切 configure flags(任何人可依此重現 build)
|
|
||||||
- Windows / Linux BtbN build → `https://github.com/BtbN/FFmpeg-Builds`(BtbN 的 source 對應)
|
|
||||||
2. **Bundle source**:不採用 — 會讓 installer 多幾十 MB
|
|
||||||
|
|
||||||
選 1。詳細文字由 PMM / PM Agent 寫成 About dialog 內容;本 TDD 只需確認技術可行。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 驗收條件
|
|
||||||
|
|
||||||
| 檢查 | 指令 | 預期 |
|
|
||||||
|------|------|------|
|
|
||||||
| macOS ffmpeg 為 LGPL | `vendor/ffmpeg/macos/ffmpeg -version \| grep -- --enable-gpl` | 無輸出 |
|
|
||||||
| macOS ffmpeg 不含 libx264 | `vendor/ffmpeg/macos/ffmpeg -version \| grep -- libx264` | 無輸出 |
|
|
||||||
| macOS ffmpeg 大小 < 25 MB | `du -h vendor/ffmpeg/macos/ffmpeg` | < 25 MB |
|
|
||||||
| macOS ffprobe 存在 | `test -f vendor/ffmpeg/macos/ffprobe && echo ok` | ok |
|
|
||||||
| macOS Gatekeeper | `spctl --assess --verbose vendor/ffmpeg/macos/ffmpeg` | accepted |
|
|
||||||
| 可解 mp4 | `vendor/ffmpeg/macos/ffmpeg -i sample.mp4 -f null -` | 正常完成 |
|
|
||||||
| 可解 avi | 同上 | 正常完成 |
|
|
||||||
| 可解 mov | 同上 | 正常完成 |
|
|
||||||
| 可解 mpeg/mpg | 同上 | 正常完成 |
|
|
||||||
| Windows LGPL | `ffmpeg.exe -version \| findstr -- --enable-gpl` | 無輸出 |
|
|
||||||
| Linux LGPL | 同上 | 無輸出 |
|
|
||||||
| installer 含 COPYING.LGPLv3 | 裝完後檢查 `<install-dir>/bin/ffmpeg-COPYING.LGPLv3` 存在 | 存在 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 待確認
|
|
||||||
|
|
||||||
1. **ffmpeg `--disable-network` 會不會影響既有的 `NewVideoSourceFromURL` / RTSP 路徑?** — v1 的 `camera/video_source.go:86-88` 有 `NewVideoSourceFromURLWithSeek` 吃 URL。v2 砍掉 yt-dlp 後是否還保留 URL 推論路徑?查 `v2/deletions.md`:URL 推論整條砍(R5-7 前置,`StartFromURL` handler 砍),**但 `NewVideoSourceFromURL` 函式本身可能還有其他呼叫者**。M8-1 執行時要先 grep 確認,若確實沒其他呼叫者就一起砍;若有就要保留 `--enable-protocol=http,https` 之類。**Architect 建議 M8-1 執行前先做這個 grep 掃描並回報**。
|
|
||||||
2. **ffmpeg n7.1 的 sha256** — 第一次 build 時由開發者執行 `shasum -a 256 build/ffmpeg-macos/source.tar.gz` 後填進 Makefile + BUILD.md,之後每次 build 用它驗證。
|
|
||||||
3. **codesign ad-hoc 在 notarize 階段** — 目前全案採 ad-hoc sign 沒做 notarize。若未來要 notarize,ffmpeg + ffprobe 屬於 app bundle 內的執行檔,會跟著 `.app` 一起送,不需單獨處理。
|
|
||||||
@ -1,513 +0,0 @@
|
|||||||
# v2/milestone-plan.md — M8 開發 milestone 拆分
|
|
||||||
|
|
||||||
> 所屬:TDD v2 §2.7
|
|
||||||
> 版本:v2.1(2026-04-14 吸收 PM 審閱 + R5-D + R5-E)
|
|
||||||
> 目的:給 Orchestrator 調度工程師 Agent 用。每個 milestone = 一個 Reviewer 審查單位。
|
|
||||||
> 總工時估算:**~12.0 人天**(v2.0 是 10 人天;R5-E 階段化啟動 +1 天 / PM Q4 7+1 秒 shutdown modal +0.2 天 / R5-D1 OS 通知 +0.3 天 / Minor 4 WebSocket 廣播 +0.3 天 / M8-10 驗收項目擴充 +0.2 天,合計 +2 人天。與 §3 合計 12.0 一致)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. M8 全景
|
|
||||||
|
|
||||||
v2.1 的開發總共拆成 11 個 milestone,編號 M8-1 ~ M8-10 + M8-4b。依賴關係:
|
|
||||||
|
|
||||||
```
|
|
||||||
M8-1 (砍 yt-dlp) ──────┐
|
|
||||||
├──▶ M8-4 (server lifecycle) ──▶ M8-4b (階段化啟動) ──▶ M8-5 (Wails UI 改寫) ──▶ M8-9 (boot-id + 重連)
|
|
||||||
M8-2 (砍 Mock) ────────┘ ▲
|
|
||||||
│
|
|
||||||
M8-3 (ffmpeg LGPL vendor) ─────────────────────────────────────────────────────────────────────────────────┼──▶ M8-10 (end-to-end)
|
|
||||||
│
|
|
||||||
M8-6 (source-selector 調整) ────────────────────────────────────────────────────────────────────────────────┤
|
|
||||||
│
|
|
||||||
M8-7 (Offline Overlay) ─────────────────────────────────────────────────────────────────────────────────────┤
|
|
||||||
│
|
|
||||||
M8-8 (CORS) ────────────────────────────────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- M8-1 / M8-2 / M8-3 / M8-8 可以同時開工(互不依賴)
|
|
||||||
- M8-4 依賴 M8-1(因為 app.go 內的 mockMode field 會在 M8-2 砍掉;但 M8-4 主要碰 server_control.go / log_buffer.go 不衝突,實務可平行)
|
|
||||||
- **M8-4b 依賴 M8-4**(需要 ServerController 已存在才能插入 pipeline hook)
|
|
||||||
- M8-5 必須等 M8-4b 完成(bindings + event schema 定義完)
|
|
||||||
- M8-6 可以獨立進行
|
|
||||||
- M8-7 依賴 M8-4(需要 boot-id server 端 + WebSocket hub,會在 M8-4 一併做)
|
|
||||||
- M8-9 依賴 M8-4 + M8-4b + M8-7
|
|
||||||
- M8-10 必須全部完成後
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Milestone 明細
|
|
||||||
|
|
||||||
### M8-1:砍 yt-dlp 全套
|
|
||||||
|
|
||||||
**預估**:0.5 人天
|
|
||||||
|
|
||||||
**負責 Agent**:Backend Agent + Frontend Agent(同時進行,因為跨 Go + TS)
|
|
||||||
|
|
||||||
**依賴**:無
|
|
||||||
|
|
||||||
**任務**:
|
|
||||||
1. 依 `v2/deletions.md` §1 刪後端 Go(`video_source.go` / `camera_handler.go` / `router.go` / `deps/checker.go` / `main.go` 註解)
|
|
||||||
2. 依 `v2/deletions.md` §5 刪前端 TS(`source-selector.tsx` 的 URL tab / `camera-store.ts` 的 `startFromUrl` / i18n 4 個 key)
|
|
||||||
3. 依 `v2/deletions.md` §4.1 + §4.2 + §4.3 + §4.4 + §4.5 刪打包流程(Makefile / installer / bootstrap)
|
|
||||||
4. 刪除 `/vendor/yt-dlp/` 目錄(若存在)
|
|
||||||
|
|
||||||
**驗收條件**:
|
|
||||||
- `go build ./...` PASS
|
|
||||||
- `go test ./server/...` PASS
|
|
||||||
- `pnpm --dir frontend build` PASS
|
|
||||||
- `git grep -i 'yt-dlp\|ytdlp\|ResolveWithYTDLP\|StartFromURL\|classifyVideoURL\|pasteUrl\|urlPlaceholder'` 無業務程式碼 match(僅 `.autoflow/` 內的歷史文件可接受)
|
|
||||||
- `make payload-macos` 可成功(不再 `cp yt-dlp`)
|
|
||||||
|
|
||||||
**Reviewer 檢查重點**:
|
|
||||||
- `camera_handler.go` 砍掉後 import 清理乾淨
|
|
||||||
- i18n types.ts / zh-TW.ts / en.ts 三檔同步刪
|
|
||||||
- Makefile 三個平台的 `vendor-ytdlp*` target 都清
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M8-2:砍 Mock 模式全套
|
|
||||||
|
|
||||||
**預估**:0.5 人天
|
|
||||||
|
|
||||||
**負責 Agent**:Backend Agent + Frontend Agent
|
|
||||||
|
|
||||||
**依賴**:無(理論上可與 M8-1 平行)
|
|
||||||
|
|
||||||
**任務**:
|
|
||||||
1. 依 `v2/deletions.md` §2 + §3 刪 Go(兩個檔整檔刪、`device/manager.go` / `camera/manager.go` / `config.go` / `main.go` / `app.go` 所有 `mockMode` 條件砍掉)
|
|
||||||
2. 依 `v2/deletions.md` §6 刪前端 Mock 切換 UI + 4 個 i18n key
|
|
||||||
3. 更新 `noDevices` 文字(empty state 友善化)
|
|
||||||
|
|
||||||
**驗收條件**:
|
|
||||||
- Go + 前端 build PASS
|
|
||||||
- `git grep -i 'VISIONA_MOCK\|MockMode\|mockMode\|MockCamera\|MockDriver\|NewMockDriver\|runtimeModeMock'` 無業務程式碼 match
|
|
||||||
- 實機啟動 server(沒插 Kneron 硬體)→ Devices 頁顯示友善 empty state「未偵測到 Kneron 裝置」
|
|
||||||
|
|
||||||
**Reviewer 檢查重點**:
|
|
||||||
- `NewManager` 簽名改動後所有呼叫點都更新
|
|
||||||
- 舊 `--mock` / `--mock-camera` / `--mock-devices` CLI flag 真的拿掉(server `--help` 不再列)
|
|
||||||
- 任何剩下的 test 檔案不再依賴 mockMode
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M8-3:ffmpeg LGPL vendor 切換
|
|
||||||
|
|
||||||
**預估**:1.5 人天
|
|
||||||
|
|
||||||
**負責 Agent**:DevOps Agent(主)+ Backend Agent(支援 macOS build 環境)
|
|
||||||
|
|
||||||
**依賴**:無
|
|
||||||
|
|
||||||
**任務**:
|
|
||||||
1. **Windows / Linux**(0.5 人天):依 `v2/ffmpeg-lgpl.md` §3 + §4 修改 `Makefile` 的 URL 與 vendor target,實測 `make vendor-ffmpeg-windows` / `vendor-ffmpeg-linux` 可成功
|
|
||||||
2. **macOS build script**(1 人天):
|
|
||||||
- 依 `v2/ffmpeg-lgpl.md` §2.3 新增 `vendor-ffmpeg-macos-build` Makefile target
|
|
||||||
- 第一次跑:下載 ffmpeg n7.1 source、算 sha256 填進 Makefile、跑 configure + make、驗證 binary size < 25 MB、驗證 `ffmpeg -version` 不含 `--enable-gpl` / libx264 / libx265
|
|
||||||
- 把 ffmpeg + ffprobe + COPYING.LGPLv3 + BUILD.md 進 git commit 到 `vendor/ffmpeg/macos/`
|
|
||||||
- 修改 `.gitignore` 讓 `vendor/ffmpeg/macos/` 成為 git tracked
|
|
||||||
- 修改 `vendor-ffmpeg` target 改為「驗證 binary 存在且是 LGPL」
|
|
||||||
3. **Payload 階段同步**(§7):三個平台的 `payload-*` target 改為 copy `ffmpeg + ffprobe + COPYING.LGPLv3`,不再 copy yt-dlp(M8-1 會處理刪)
|
|
||||||
4. **Installer 同步**(§8):
|
|
||||||
- `installer/windows/visiona-local.iss` 新增 ffprobe 和 LGPL license 行
|
|
||||||
- `installer/linux/build-appimage.sh` 迭代 tool 改為 `ffmpeg ffprobe`
|
|
||||||
|
|
||||||
**驗收條件**:
|
|
||||||
- `make vendor-ffmpeg` PASS(macOS),binary size OK(見 `v2/ffmpeg-lgpl.md` §10 驗收表)
|
|
||||||
- `vendor/ffmpeg/macos/ffmpeg -version | grep -- --enable-gpl` 無輸出
|
|
||||||
- `spctl --assess --verbose vendor/ffmpeg/macos/ffmpeg` → accepted
|
|
||||||
- `make vendor-ffmpeg-windows` + `vendor-ffmpeg-linux` 可成功,檔案解出後 version 無 `--enable-gpl`
|
|
||||||
- `make payload-macos` 成功,`payload/darwin/bin/` 內有 `ffmpeg / ffprobe / ffmpeg-COPYING.LGPLv3`
|
|
||||||
- 實測用 `vendor/ffmpeg/macos/ffmpeg` 解 5 種格式(mp4/avi/mov/mpeg/mpg)正常
|
|
||||||
|
|
||||||
**Reviewer 檢查重點**:
|
|
||||||
- `vendor/ffmpeg/macos/BUILD.md` 內容正確、可重現
|
|
||||||
- 開發者用單一 `make vendor-ffmpeg-macos-build` 指令真的能從零 build 出來(不依賴本機 cached state)
|
|
||||||
- `.gitignore` 的 `!` rule 順序正確
|
|
||||||
- Makefile 的 configure flags 與 `v2/ffmpeg-lgpl.md` §2.3 完全一致
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M8-4:Server lifecycle + log buffer + bindings + OS notify + Preferences
|
|
||||||
|
|
||||||
**預估**:2 人天(v2.0 是 1.5 天;+0.3 天 R5-D1 OS 通知、+0.2 天 PM Q4 7+1 秒 shutdown modal)
|
|
||||||
|
|
||||||
**負責 Agent**:Backend Agent(Go / Wails)
|
|
||||||
|
|
||||||
**依賴**:M8-1(避免 mockMode 欄位被砍後衝突)。實務上可與 M8-2 平行。
|
|
||||||
|
|
||||||
**任務**:
|
|
||||||
|
|
||||||
1. **新增檔案**(`v2/control-panel.md` §7 + §4):
|
|
||||||
- `visiona-local/log_buffer.go`(~120 行 ring buffer)
|
|
||||||
- `visiona-local/server_control.go`(~280 行 ServerController + state machine + startServerV2 + logPump + **7+1 秒 shutdown modal timer**,見 `server-lifecycle.md` §8.2)
|
|
||||||
- `visiona-local/preferences.go`(~80 行)— **R5-D2**:必須實作 `DefaultPreferences()` 依 `runtime.GOOS` 分平台預設(macOS/Windows `AutoOpenBrowser=true`、Linux `false`)+ atomic write-rename(`server-lifecycle.md` §11.3)
|
|
||||||
- `visiona-local/notify.go`(~100 行)— **R5-D1**:三平台 OS 通知,macOS `osascript display notification` / Linux `notify-send` / Windows PowerShell BurntToast + `msg *` fallback,詳見 `server-lifecycle.md` §10
|
|
||||||
2. **修改 `visiona-local/app.go`**:
|
|
||||||
- 新增 `ctrl` / `logBuf` / `prefs` 欄位(**不要** `autoOpenedThisSession` — R5-D3 已砍掉 per-session-once 概念)
|
|
||||||
- 砍 `GetBootstrapStatus` / `setBootstrapStatus` / `bootstrapStatus` 欄位
|
|
||||||
- 改 `watchServer()` 失敗後行為(不 os.Exit,改設 Error state + **goroutine 呼叫 `sendCrashNotification`** — R5-D1)
|
|
||||||
- 新增 12 個 Wails bindings(`v2/control-panel.md` §4.2)
|
|
||||||
3. **修改 `visiona-local/main.go`**:
|
|
||||||
- 加 `OnBeforeClose` handler
|
|
||||||
- `shutdownGracePeriod` 5 s → **7 s**(PM Q4 定案,不是 10 s)
|
|
||||||
4. **修改 `server/main.go` + `system_handler.go` + `router.go`**:
|
|
||||||
- 新增 boot-id 生成(使用 `crypto/rand` 16 bytes → hex,Architect Q3 決定,不引 google/uuid)
|
|
||||||
- 新增 `GET /api/system/boot-id` endpoint
|
|
||||||
- **Minor 4**:server 新增 WebSocket hub 廣播 `server:shutdown-imminent` 的能力(新增 `/ws/system` endpoint 或擴充現有 `/ws/server-logs`);Wails 的 `ctrl.Stop()` 在開始 SIGTERM 前透過 HTTP 或 server 內部 API 觸發此廣播
|
|
||||||
- **對齊 shutdown timeout**:`server/main.go:166` 的 `shutdownFn` timeout 10 s → **6 s**(Wails 7 s 減 1 s,確保 server 先完成 cleanup)
|
|
||||||
|
|
||||||
**驗收條件**:
|
|
||||||
- `cd visiona-local && go build .` PASS
|
|
||||||
- `cd server && go build ./...` PASS
|
|
||||||
- 12 個 bindings 在 `visiona-local/frontend/wailsjs/go/main/App.d.ts` 正確生成
|
|
||||||
- 手動測試:新 bindings 可從 Wails dev 模式的 devtools console 呼叫
|
|
||||||
- 手動測試:Start → server 啟動 → 看 log 噴進 LogBuffer → 用 `GetRecentLogs(20)` 取回
|
|
||||||
- 手動測試:Stop → 1 秒內顯示「停止中…」modal(若 server 未秒 exit)+ 7 秒內 SIGKILL(若卡死)
|
|
||||||
- 手動測試:Restart → state: Running → Stopping → Stopped → Starting → Running
|
|
||||||
- 手動測試:殺 server process → watchServer 3 次失敗後 state → Error + **收到 OS 原生通知**(三平台都要)
|
|
||||||
- **R5-D2 驗收**:全新使用者第一次在 macOS/Windows 開 app → `preferences.json` 不存在 → `DefaultPreferences()` 回傳 `AutoOpenBrowser=true`;Linux 上回傳 `false`
|
|
||||||
- `curl http://127.0.0.1:<port>/api/system/boot-id` 回傳 JSON 含 bootId(hex 32 字元)
|
|
||||||
- WebSocket `server:shutdown-imminent` 廣播:用 websocat 連 `/ws/system`,按 Stop 後秒收到廣播
|
|
||||||
|
|
||||||
**Reviewer 檢查重點**:
|
|
||||||
- LogBuffer thread-safety(mutex 正確)
|
|
||||||
- ServerController 的 `txMu` / `mu` 互斥
|
|
||||||
- logPump 在 server 死掉 / pipe EOF / 高頻 stdout 下都不 leak goroutine
|
|
||||||
- `parseLogLevel` 不會 panic on 空字串或非 ASCII
|
|
||||||
- boot-id 生成使用 `crypto/rand` + `encoding/hex`(不引 google/uuid)
|
|
||||||
- Preferences JSON atomic write(tmp + rename,見 §11.3)
|
|
||||||
- `DefaultPreferences()` 依 `runtime.GOOS` 正確切換(Linux 分支有測試)
|
|
||||||
- `sendCrashNotification` 三平台檔案(darwin/linux/windows build tag)齊全且不阻塞
|
|
||||||
- shutdown modal 的 1 秒 timer 與 7 秒 grace timer 正確(goroutine 泄漏檢查)
|
|
||||||
- server 端 WebSocket hub 廣播邏輯與 Wails 端呼叫的契約(傳遞 `reason` 欄位)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M8-4b:階段化啟動管線(R5-E)
|
|
||||||
|
|
||||||
**預估**:1 人天(v2.0 未拆出,v2.1 新增)
|
|
||||||
|
|
||||||
**負責 Agent**:Backend Agent(Go / Wails)+ Frontend Agent(vanilla JS startup panel)
|
|
||||||
|
|
||||||
**依賴**:M8-4(需要 ServerController + bindings)
|
|
||||||
|
|
||||||
**任務**:依 `v2/startup-pipeline.md`(若該檔不存在則落在 `control-panel.md` §3.1 + `server-lifecycle.md` §2.1)
|
|
||||||
|
|
||||||
1. **新增 `visiona-local/startup_pipeline.go`**(~180 行):
|
|
||||||
- `StartupPipeline` struct(含 currentStage / stageStartedAt / startedAt / softTimeout / hardTimeout)
|
|
||||||
- `NewStartupPipeline(ctx)` / `Start(stage int)` / `Complete(stage int)` / `Fail(stage int, err)` / `Ready()` 方法
|
|
||||||
- `watcher(ctx)` goroutine:每秒檢查 soft timeout (20 s) + hard timeout (60 s),emit `startup:stage-timeout` / `startup:error` event
|
|
||||||
- 4 個 event schema:`startup:progress` / `startup:stage-timeout` / `startup:error` / `startup:ready`(見 `v2/startup-pipeline.md` §1)
|
|
||||||
2. **修改 `visiona-local/app.go`** 的 `startup(ctx)`:
|
|
||||||
- 在 `OnStartup` 最前面初始化 pipeline 並 Start(1)
|
|
||||||
- 每個既有階段的對應處呼叫 `pipeline.Complete(N)` + `pipeline.Start(N+1)`
|
|
||||||
- 6 個階段對應:1=Wails init、2=Python runtime、3=server binary spawn + health check、4=first `/api/devices` 查詢、5=OpenInBrowser call、6=WebSocket 首個 client connect
|
|
||||||
- 最後 `pipeline.Ready()` → emit `startup:ready`
|
|
||||||
3. **修改 server 端**:
|
|
||||||
- `pkg/ws`(或對應的 WebSocket hub)新增 `OnClientConnected` callback,讓 Wails 能收到「第一個 client 連上」的通知(透過 WebSocket 或 HTTP poll)
|
|
||||||
- Wails 的 `startup_pipeline.go` 訂閱此通知完成階段 6
|
|
||||||
4. **失敗處理**:任一階段失敗 → `pipeline.Fail(stage, err)` → `ctrl.setState(Error, ...)` + `sendCrashNotification` + emit `startup:error`
|
|
||||||
5. **非阻塞**:所有 `EventsEmit` 呼叫都走 buffered channel 或 fire-and-forget goroutine,不阻塞啟動流程
|
|
||||||
6. **前端(Wails 控制台)**:
|
|
||||||
- `visiona-local/frontend/components/startup-panel.js`(~100 行)
|
|
||||||
- 訂閱 4 個 event 更新 DOM
|
|
||||||
- 收到 `startup:ready` 淡出(300 ms ease)
|
|
||||||
- i18n key 從 `i18n/zh-TW.json` / `en-US.json` 讀(文案由 Design Spec v2.1 敲定)
|
|
||||||
|
|
||||||
**驗收條件**:
|
|
||||||
- `cd visiona-local && go build .` PASS
|
|
||||||
- 正常冷啟動(樂觀情境):4 s 內看到「啟動進度面板」6 階段逐一 completed,最後淡出顯示主控台
|
|
||||||
- 慢啟動情境:mock 階段 2 jam 25 秒 → 看到「階段 2 正在重試 …」副文字
|
|
||||||
- 失敗情境:mock 階段 3 fail → 看到進度面板變紅、切 Error state、收到 OS 通知、發 `startup:error` event
|
|
||||||
- 總時 > 60 秒情境:mock 每個階段都等 12 秒 → 60 秒後進 Error state(watcher 總時檢查有效)
|
|
||||||
- 日常啟動(非首次):~3 s 內就緒,符合 PM AC-2.1
|
|
||||||
|
|
||||||
**Reviewer 檢查重點**:
|
|
||||||
- watcher goroutine 在 pipeline.Ready() / Fail() 後正確停止,不 leak
|
|
||||||
- `EventsEmit` 非阻塞(若 Wails IPC 慢也不拖啟動)
|
|
||||||
- 6 階段的 labelKey 與 design-spec v2.1 一致
|
|
||||||
- 失敗後 Error state 透過 ctrl.setState 觸發,不繞過 state machine
|
|
||||||
- startup-panel.js 的淡出動畫不會卡住主控台 UI
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M8-5:Wails 控制台 vanilla UI 改寫
|
|
||||||
|
|
||||||
**預估**:2 人天
|
|
||||||
|
|
||||||
**負責 Agent**:Frontend Agent(vanilla JS)
|
|
||||||
|
|
||||||
**依賴**:M8-4(需要 bindings + events)
|
|
||||||
|
|
||||||
**任務**:
|
|
||||||
1. **改寫 `visiona-local/frontend/index.html`** 成控制台 layout(`v2/control-panel.md` §3)
|
|
||||||
2. **改寫 `visiona-local/frontend/app.js`** 成控制台主程式(§5)
|
|
||||||
3. **改寫 `visiona-local/frontend/style.css`** 新增 status card / log panel / action bar / preferences 樣式,並加 light/dark mode CSS variables
|
|
||||||
4. **新增 components**:
|
|
||||||
- `components/status-card.js`
|
|
||||||
- `components/log-panel.js`(含 virtual scroll lite + batch render)
|
|
||||||
- `components/action-bar.js`(含 disable 矩陣)
|
|
||||||
- `components/preferences.js`
|
|
||||||
5. **新增 i18n**:
|
|
||||||
- `i18n/en-US.json` / `zh-TW.json`
|
|
||||||
- `i18n/loader.js`
|
|
||||||
6. **新增 icons**:`icons/*.svg` × 6
|
|
||||||
|
|
||||||
**驗收條件**:
|
|
||||||
- Wails dev 模式下開 app:
|
|
||||||
- ✅ 看到控制台 UI(status / log / actions / preferences),不是 splash / blank / Next.js
|
|
||||||
- ✅ Start 按鈕能啟動 server,狀態卡片變 Running 綠色 badge
|
|
||||||
- ✅ Log panel 即時顯示 server stdout(看到 Gin log)
|
|
||||||
- ✅ Stop 按鈕能停 server,badge 變灰
|
|
||||||
- ✅ Restart 按鈕能重啟(狀態過程正確)
|
|
||||||
- ✅ Open in Browser 開瀏覽器到正確 URL
|
|
||||||
- ✅ Reveal Logs 開啟 `<dataDir>/logs/` 資料夾
|
|
||||||
- ✅ Clear Logs 清畫面但不動磁碟檔
|
|
||||||
- ✅ Preferences 切 `openBrowserOnStart` 持久化到 `preferences.json`
|
|
||||||
- ✅ Dark mode 跟隨系統(macOS 切換 Dark Mode 後控制台自動變色)
|
|
||||||
- ✅ Log panel 按 Pause 後自動捲動停止,解除後恢復
|
|
||||||
- `make wails-macos` 能 build 出 .app,打開後看到控制台 UI(M7-B 教訓)
|
|
||||||
|
|
||||||
**Reviewer 檢查重點**:
|
|
||||||
- vanilla JS 沒引入任何 npm 依賴(`package.json` 不存在於 visiona-local/frontend/)
|
|
||||||
- Log panel 在高頻(1000 行/秒)下不會 freeze UI(batch render 有效)
|
|
||||||
- i18n loader SSR-safe(不適用,但 Wails 沒 SSR 概念)
|
|
||||||
- icons/ 下的 SVG 符合 inline 使用的簡潔規則(viewBox + single path)
|
|
||||||
- Action bar 在每個 state 下 button enable/disable 正確(disable 矩陣全部驗證)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M8-6:Web UI source-selector + 副檔名擴充
|
|
||||||
|
|
||||||
**預估**:0.5 人天
|
|
||||||
|
|
||||||
**負責 Agent**:Frontend Agent
|
|
||||||
|
|
||||||
**依賴**:無(與其他 milestone 平行)
|
|
||||||
|
|
||||||
**任務**:
|
|
||||||
1. `frontend/src/components/camera/source-selector.tsx` 依 `v2/deletions.md` §5.1 移除 URL tab + mode toggle
|
|
||||||
2. 把 `accept=".mp4,.avi,.mov"` 改為 `accept=".mp4,.avi,.mov,.mpeg,.mpg"`
|
|
||||||
3. i18n 新增 `videoFormats` key 或改既有 `mp4AviMov`
|
|
||||||
4. **後端同步**:`server/internal/api/handlers/camera_handler.go:251` 把 extension whitelist 從 `.mp4 / .avi / .mov` 擴充為 5 個:
|
|
||||||
```diff
|
|
||||||
-if ext != ".mp4" && ext != ".avi" && ext != ".mov" {
|
|
||||||
- c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "only MP4/AVI/MOV files are supported"}})
|
|
||||||
+if ext != ".mp4" && ext != ".avi" && ext != ".mov" && ext != ".mpeg" && ext != ".mpg" {
|
|
||||||
+ c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "only MP4/AVI/MOV/MPEG/MPG files are supported"}})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**驗收條件**:
|
|
||||||
- 前端 build PASS
|
|
||||||
- 手動測試:5 種副檔名都能上傳且正常開始推論
|
|
||||||
- UI 不再有「Paste URL」按鈕
|
|
||||||
|
|
||||||
**Reviewer 檢查重點**:
|
|
||||||
- handler 裡的 extension 比對是 `strings.ToLower` 後再比,大小寫不敏感
|
|
||||||
- 錯誤訊息中英文同步
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M8-7:Web UI Server Offline Overlay
|
|
||||||
|
|
||||||
**預估**:1 人天
|
|
||||||
|
|
||||||
**負責 Agent**:Frontend Agent
|
|
||||||
|
|
||||||
**依賴**:M8-4(需要 server 端 boot-id endpoint)
|
|
||||||
|
|
||||||
**任務**:依 `v2/web-ui-offline-overlay.md` §4 的 7 個檔案清單:
|
|
||||||
1. 新增 `frontend/src/stores/system-store.ts`
|
|
||||||
2. 新增 `frontend/src/hooks/use-boot-id-watcher.ts`
|
|
||||||
3. 新增 `frontend/src/components/server-offline-overlay.tsx`
|
|
||||||
4. 新增 `frontend/src/components/boot-id-watcher-mount.tsx`
|
|
||||||
5. 修改 `frontend/src/app/layout.tsx`(掛 overlay + watcher)
|
|
||||||
6. 修改 `frontend/src/lib/i18n/{types,zh-TW,en}.ts`(新增 serverOffline 區塊)
|
|
||||||
|
|
||||||
**驗收條件**:
|
|
||||||
- `pnpm --dir frontend build` PASS(含 SSR 相容性驗證)
|
|
||||||
- 手動測試:
|
|
||||||
- 正常啟動沒 overlay
|
|
||||||
- 關 Wails 視窗後 15 s 內瀏覽器 tab 顯示 overlay
|
|
||||||
- 重新開 Wails app → Web UI 的「重試」按鈕有效(overlay 消失)
|
|
||||||
- 控制台按 Restart → boot-id 變 → 自動 `window.location.reload()`
|
|
||||||
- 切到別的 tab 5 分鐘再切回 → polling 立即 probe 不用等
|
|
||||||
|
|
||||||
**Reviewer 檢查重點**:
|
|
||||||
- Zustand store 的 failures state 遞增正確
|
|
||||||
- `use-boot-id-watcher.ts` 的 cleanup(useEffect return)正確 cancel
|
|
||||||
- `AbortSignal.timeout(3000)` 在舊瀏覽器相容性(若需支援 Chrome < 103 則 fallback)
|
|
||||||
- Overlay 元件 a11y(`role="alertdialog"` + `aria-labelledby`)
|
|
||||||
- i18n 新增 key 三個檔同步(types / zh-TW / en)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M8-8:CORS + origin check middleware
|
|
||||||
|
|
||||||
**預估**:0.5 人天
|
|
||||||
|
|
||||||
**負責 Agent**:Backend Agent
|
|
||||||
|
|
||||||
**依賴**:無
|
|
||||||
|
|
||||||
**任務**:依 `v2/cors-security.md` §4 + §5:
|
|
||||||
1. 覆寫 `server/internal/api/middleware.go`(白名單版)
|
|
||||||
2. 新增 `server/internal/api/ws/origin.go`
|
|
||||||
3. 修改所有 WS handler 的 upgrader CheckOrigin
|
|
||||||
4. 新增 `server/internal/api/middleware_test.go` 單元測試(`TestIsAllowedOrigin`)
|
|
||||||
5. 在 `router.go` 掛 `requireSameOriginOrNoOrigin` middleware 到 `/api/*`
|
|
||||||
|
|
||||||
**驗收條件**:
|
|
||||||
- `go test ./server/internal/api/... -run TestIsAllowedOrigin` PASS
|
|
||||||
- `v2/cors-security.md` §9 所有 curl 驗收條件通過
|
|
||||||
- WS websocat 測試:白名單 origin 可連、非白名單擋
|
|
||||||
- 既有 Next.js Web UI 的 fetch 仍正常運作(same-origin + white list)
|
|
||||||
|
|
||||||
**Reviewer 檢查重點**:
|
|
||||||
- `isAllowedOrigin` 對 edge case 處理正確(空字串 / null / https / 不合規 URL)
|
|
||||||
- 不 accidentally 把 `X-Relay-Token` header 保留(relay 早已砍)
|
|
||||||
- WS upgrade 在非白名單 origin 下回 403,不是靜默失敗
|
|
||||||
- Middleware 套用順序(middleware.go 的 CORSMiddleware 要在 requireSameOriginOrNoOrigin 之前)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M8-9:Boot-ID 端到端整合 + Restart 重連
|
|
||||||
|
|
||||||
**預估**:1 人天
|
|
||||||
|
|
||||||
**負責 Agent**:Backend Agent + Frontend Agent
|
|
||||||
|
|
||||||
**依賴**:M8-4 + M8-7
|
|
||||||
|
|
||||||
**任務**:
|
|
||||||
1. **整合驗證**:M8-4 的 server 端 boot-id API + M8-7 的前端 polling 串起來
|
|
||||||
2. **Restart 情境驗證**:
|
|
||||||
- 開 app → Open in Browser
|
|
||||||
- 在 Wails 控制台按 Restart
|
|
||||||
- 瀏覽器 tab 在 3-5 s 內自動 reload
|
|
||||||
- Reload 後 server 是新的 port 也能正常連(因為 Next.js 是 static export,URL path 不變)
|
|
||||||
3. **自動開瀏覽器**(R5-4 + R5-D2 + R5-D3):
|
|
||||||
- **R5-D2(分平台預設)**:macOS/Windows 預設 `AutoOpenBrowser=true`;Linux 預設 `false`
|
|
||||||
- **R5-D3(每次都開)**:只要 `AutoOpenBrowser=true`,每次 Start/Restart 成功都呼叫 `OpenInBrowser("")` — **取消**原 v2.0 的 per-session-once 設計
|
|
||||||
- 使用者在 Preferences 切換 → 立即持久化到 `preferences.json`(atomic write)
|
|
||||||
4. **Restart 期間 port 行為**(F-2 強制保留):
|
|
||||||
- Restart 不允許 port fallback(`startWithPort(oldPort, forceMatch=true)`)
|
|
||||||
- 舊 port 被佔用 → 進 Error state + 發 OS 通知
|
|
||||||
- 正常 Restart → 新 server 用原 port → 瀏覽器 tab 偵測 boot-id 變 → reload → URL 原 port 仍有效 → 自動連上
|
|
||||||
- **不會**發生 v2.0 所述「port 變動後瀏覽器連不上」的情境
|
|
||||||
|
|
||||||
**驗收條件**:
|
|
||||||
- 關機情境(Wails 關 → server 停 → 瀏覽器 tab):**WebSocket shutdown-imminent 秒內顯示 overlay**(不是 15 s 了,Minor 4)
|
|
||||||
- Restart 情境(Wails 按 Restart):3-5 s 內瀏覽器自動 reload,**URL 原 port 仍有效**(F-2),UI 正常
|
|
||||||
- 首次開 app(macOS/Windows):瀏覽器自動跳出新 tab
|
|
||||||
- 首次開 app(Linux):瀏覽器**不**自動開(R5-D2 預設 false)
|
|
||||||
- Preferences 關閉後再開 app:瀏覽器不自動開
|
|
||||||
- **多次按 Restart**:若 `AutoOpenBrowser=true`,每次 Restart **都會呼叫 `OpenInBrowser`**(R5-D3)。OS 瀏覽器通常聚焦既有 tab 而不是開新 tab,使用者可接受
|
|
||||||
- R5-E 啟動進度面板:Starting 期間顯示 6 階段進度,完成後淡出
|
|
||||||
|
|
||||||
**Reviewer 檢查重點**:
|
|
||||||
- `app.go` 中**不存在** `autoOpenedThisSession` 欄位(v2.0 已砍)
|
|
||||||
- Restart 呼叫 `OpenInBrowser` 的行為有在 M8-9 手動測試紀錄中驗證
|
|
||||||
- `DefaultPreferences()` 三平台分支在單元測試或手動測試中涵蓋
|
|
||||||
- Restart 的 port 強制保留邏輯正確(錯的 mock 情境下會進 Error state 而非 fallback)
|
|
||||||
- boot-id 在 server 重啟後真的變(不會意外 memoize 舊值)
|
|
||||||
- polling 的 `consecutiveFailures` 在 Restart 期間不會錯誤累積到 3(Restart 約 3 s,只有 0-1 次失敗)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M8-10:端到端 build + smoke test
|
|
||||||
|
|
||||||
**預估**:1 人天
|
|
||||||
|
|
||||||
**負責 Agent**:DevOps Agent + Testing Agent(QA)
|
|
||||||
|
|
||||||
**依賴**:M8-1 到 M8-9 全部完成
|
|
||||||
|
|
||||||
**任務**:
|
|
||||||
1. **macOS**:`make clean-all && make dmg` → `dist/visiona-local.dmg`
|
|
||||||
- 安裝到全新 mac(或 VM)
|
|
||||||
- 8 核心驗收(v2.1 擴充):
|
|
||||||
- ✅ 打開 app 先看到**啟動進度面板**(6 階段,R5-E),完成後淡出顯示**控制台 UI**(不是 splash / wizard / 白畫面)
|
|
||||||
- ✅ 點 Open in Browser 後瀏覽器載入 Next.js Web UI
|
|
||||||
- ✅ Web UI 沒有 URL tab(source-selector 清乾淨)
|
|
||||||
- ✅ Web UI 沒有 Mock 模式切換(Settings > Hardware 乾淨)
|
|
||||||
- ✅ 點 Stop 後瀏覽器**秒內看到 Offline Overlay**(WebSocket 廣播,不是 15 s 才顯示)
|
|
||||||
- ✅ **手動殺 server process → 收到 macOS 原生通知 + 控制台 Error banner**(R5-D1)
|
|
||||||
- ✅ **R5-D2:首次開 app 時 `preferences.json` 不存在,macOS 預設自動開瀏覽器**(Linux 相對要反過來驗證)
|
|
||||||
- ✅ 按 Restart 兩次,每次都會觸發 `OpenInBrowser`(R5-D3,砍掉 per-session-once)
|
|
||||||
2. **Windows**:在 Windows 機器上 `make clean-all && make exe`
|
|
||||||
- 同樣 5 核心驗收
|
|
||||||
- **注意**:R5-7 同意 M7 Windows 先不管,但這次 M8-10 要順帶驗證(做完再驗)
|
|
||||||
3. **Linux**:在 Ubuntu 上 `make clean-all && make appimage`
|
|
||||||
- 同樣 5 核心驗收
|
|
||||||
4. **LGPL 稽核**:三個平台的 installer 安裝後,`<install>/bin/` 下都有 `ffmpeg + ffprobe + ffmpeg-COPYING.LGPLv3`
|
|
||||||
5. **Installer size 對照**:
|
|
||||||
- macOS .dmg:預期 ~80-100 MB(v1 是 220 MB,砍 yt-dlp 35 MB + 砍 GPL ffmpeg 77 MB 換成 LGPL ~20 MB = 約 128 MB 降到 ~100 MB)
|
|
||||||
- Windows .exe:預期 ~280-320 MB(v1 是 ~380 MB)
|
|
||||||
- Linux .AppImage:預期 ~240-280 MB(v1 是 ~317 MB)
|
|
||||||
6. **回歸測試**:
|
|
||||||
- Kneron KL520 / KL720 實機跑推論(若有硬體)
|
|
||||||
- 5 種副檔名上傳影片
|
|
||||||
- 批次影像上傳
|
|
||||||
- Camera(webcam)串流
|
|
||||||
|
|
||||||
**驗收條件**:
|
|
||||||
- 三平台 installer 全部能裝、能啟動、能進入控制台、能 Open Browser、能跑推論
|
|
||||||
- LGPL 合規稽核:`grep -r 'enable-gpl' vendor/ffmpeg/` 無輸出
|
|
||||||
- 5 核心驗收 checklist 全綠
|
|
||||||
- Installer size 在預期範圍內
|
|
||||||
|
|
||||||
**Reviewer 檢查重點**:
|
|
||||||
- 驗收 checklist 每一項都有實測截圖 / log
|
|
||||||
- 回歸測試涵蓋 PRD v2 所列的核心 user story
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 風險與緩衝
|
|
||||||
|
|
||||||
- **M8-3 macOS 自 build ffmpeg 可能踩坑**(configure flag 組合、pkg-config 環境、codesign):預估 1.5 人天但可能實際花 2 人天。buffer 放在 M8-10 的 1 人天裡
|
|
||||||
- **M8-4b R5-E 6 階段 event emit 的非阻塞保證**:Wails v2 IPC 若慢,可能把啟動流程本身拖慢。需在 M8-4b 後做 mock 測試(每個階段插 dummy 延遲)確認 watcher goroutine 與 event emit 不互相阻塞
|
|
||||||
- **M8-4 OS 通知三平台實測**(R5-D1):macOS 首次呼叫會彈出通知授權對話框;Windows 沒裝 BurntToast 會 fallback `msg *`(某些版本可能 block);Linux 需要 `libnotify-bin` 已安裝。這三者都需要實機驗證,預估 0.3 天
|
|
||||||
- **M8-5 控制台 UI 的 dark mode** 可能需要反覆調整:若實測後使用者不喜歡預設配色,留給 Design Agent 一輪調整(不在本次範圍內)
|
|
||||||
- **M8-4 高頻 log 壓測**(R-v2-3):若 Wails v2 EventsEmit 真的 flaky,要切 micro-batch 升級成 throttling + coalescing,可能再多 0.5 人天
|
|
||||||
|
|
||||||
### 總工時重估
|
|
||||||
|
|
||||||
| Milestone | v2.0 | v2.1 | 差異原因 |
|
|
||||||
|-----------|------|------|---------|
|
|
||||||
| M8-1 | 0.5 | 0.5 | — |
|
|
||||||
| M8-2 | 0.5 | 0.5 | — |
|
|
||||||
| M8-3 | 1.5 | 1.5 | — |
|
|
||||||
| M8-4 | 1.5 | **2.0** | +0.3 R5-D1 OS 通知、+0.2 Q4 7+1 秒 shutdown modal |
|
|
||||||
| **M8-4b** | — | **1.0** | **新增,R5-E 階段化啟動** |
|
|
||||||
| M8-5 | 2.0 | 2.0 | — |
|
|
||||||
| M8-6 | 0.5 | 0.5 | — |
|
|
||||||
| M8-7 | 1.0 | **1.3** | +0.3 Minor 4 WebSocket shutdown-imminent 訂閱 |
|
|
||||||
| M8-8 | 0.5 | 0.5 | — |
|
|
||||||
| M8-9 | 1.0 | 1.0 | — |
|
|
||||||
| M8-10 | 1.0 | 1.2 | +0.2 核心驗收項目從 5 個擴為 8 個 |
|
|
||||||
| **合計** | **10.0** | **12.0** | **+2 人天** |
|
|
||||||
|
|
||||||
加上 buffer(M8-3 可能踩坑 + M8-4 高頻 log 壓測可能重構)~1 人天 → **建議 Orchestrator 對使用者回報 ~13 人天**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 交棒給 Orchestrator 的清單
|
|
||||||
|
|
||||||
| Milestone | 誰 | 觸發條件 |
|
|
||||||
|-----------|-----|---------|
|
|
||||||
| M8-1 | Backend + Frontend(並行)| 使用者確認 TDD v2.1 |
|
|
||||||
| M8-2 | Backend + Frontend(並行)| 同 M8-1 |
|
|
||||||
| M8-3 | DevOps + Backend(macOS host 可用)| 同上,不依賴 M8-1/2 |
|
|
||||||
| M8-4 | Backend | M8-1 + M8-2 砍完 |
|
|
||||||
| **M8-4b** | **Backend + Frontend(vanilla JS)** | **M8-4 完成(需要 ServerController + WebSocket hub)** |
|
|
||||||
| M8-5 | Frontend | M8-4b bindings + event schema 可用 |
|
|
||||||
| M8-6 | Frontend | 可與 M8-1/2/5 平行 |
|
|
||||||
| M8-7 | Frontend | M8-4 boot-id endpoint + WebSocket `/ws/system` 就緒 |
|
|
||||||
| M8-8 | Backend | 可獨立進行 |
|
|
||||||
| M8-9 | Backend + Frontend | M8-4 + M8-4b + M8-7 完成 |
|
|
||||||
| M8-10 | DevOps + Testing | 全部完成 |
|
|
||||||
|
|
||||||
**提醒**:每個 milestone 完成後,Orchestrator 啟動 Reviewer 審查,審查通過才進下一個 milestone(per 根目錄 CLAUDE.md 「強制 Review 規則」)。
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,719 +0,0 @@
|
|||||||
# v2/startup-pipeline.md — R5-E 階段化啟動管線
|
|
||||||
|
|
||||||
> 所屬:TDD v2 §2.9(v2.1 新增)
|
|
||||||
> 版本:v2.1(2026-04-14 R5-E 實作細節)
|
|
||||||
> 決策依據:R5-E1 ~ R5-E6(AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度)
|
|
||||||
> 對應 milestone:M8-4b(Backend Go + Frontend vanilla JS)
|
|
||||||
> 相關文件:`v2/control-panel.md` §3.1(啟動進度面板 UI)、`v2/server-lifecycle.md` §2.1(冷啟動時間軸)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 目的與決策回顧
|
|
||||||
|
|
||||||
v2.0 PM §11-2 提問 AC-1.3「10 秒可達性」,Architect 樂觀 4 s / 悲觀 8 s / 最壞 11 s。PM 審閱後將此硬指標**取代**為 R5-E 的新規則:
|
|
||||||
|
|
||||||
| R5-E | 內容 |
|
|
||||||
|------|------|
|
|
||||||
| R5-E1 | AC-1.3 上限 **60 秒**(軟硬門檻) |
|
|
||||||
| R5-E2 | 分成 **6 個階段**,逐階段顯示進度 |
|
|
||||||
| R5-E3 | 單一階段卡 > **20 秒**(soft timeout)→ 顯示「階段 N 正在重試」副文字,**不中斷流程** |
|
|
||||||
| R5-E4 | 總時 > **60 秒**(hard timeout)→ 進 Error state |
|
|
||||||
| R5-E5 | 階段文字由 Design Spec v2.1 決定(本文件只定 event schema + labelKey) |
|
|
||||||
| R5-E6 | 「瀏覽器就緒」定義 = WebSocket 連上 server(`OnClientConnected` 第一次觸發) |
|
|
||||||
|
|
||||||
v2.0 的樂觀/悲觀估算仍有意義,作為每階段的預算參考(見 `server-lifecycle.md` §2.1 的階段預算表)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Event Schema
|
|
||||||
|
|
||||||
Wails 控制台(vanilla JS)透過 `EventsOn` 訂閱以下 4 個 event。Payload 使用 JSON-serializable Go struct。
|
|
||||||
|
|
||||||
### 1.1 `startup:progress`
|
|
||||||
|
|
||||||
每個階段的狀態變化都 emit 一次,可能是 `running`(階段開始)、`completed`(階段完成)、`failed`(階段失敗)。
|
|
||||||
|
|
||||||
```go
|
|
||||||
type StartupProgressEvent struct {
|
|
||||||
Stage int `json:"stage"` // 1-6
|
|
||||||
TotalStages int `json:"totalStages"` // 固定 6
|
|
||||||
LabelKey string `json:"labelKey"` // i18n key,見 §2
|
|
||||||
Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped"
|
|
||||||
StartedAt int64 `json:"startedAt"` // Unix ms,該階段開始時間
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Status 值**(v2.1 二次審閱新增 `skipped`):
|
|
||||||
- `pending`:階段尚未開始(pipeline render 初始狀態)
|
|
||||||
- `running`:階段進行中(watcher 會對此階段檢查 soft / hard timeout)
|
|
||||||
- `completed`:階段成功完成
|
|
||||||
- `failed`:階段失敗 → pipeline 停止、進 Error state
|
|
||||||
- `skipped`(v2.1 新增):該階段依偏好設定或平台規則被跳過,不需執行也不檢查 timeout
|
|
||||||
- 目前使用情境:階段 5「開啟瀏覽器」在 `prefs.AutoOpenBrowser == false` 時(Linux 預設 OFF,或使用者手動關閉)→ status=`skipped`
|
|
||||||
- 前端收到 skipped → 顯示 ⏭ 圖示 + 文字「跳過(依偏好設定)」(Design Spec v2.1 §4.1 已定)
|
|
||||||
- Watcher 看到 skipped 狀態時不檢查 soft timeout;進入下一階段的邏輯同 completed
|
|
||||||
|
|
||||||
前端 render 邏輯:收到 event → 更新 `#startup-stage-<N>` DOM 的 status icon 與時間。
|
|
||||||
|
|
||||||
### 1.2 `startup:stage-timeout`
|
|
||||||
|
|
||||||
某階段 > 20 秒(soft timeout)未完成時 emit 一次(**不重複 emit**)。僅作提示,不中斷流程。
|
|
||||||
|
|
||||||
```go
|
|
||||||
type StartupStageTimeoutEvent struct {
|
|
||||||
Stage int `json:"stage"`
|
|
||||||
SoftTimeoutSeconds int `json:"softTimeoutSeconds"` // 固定 20
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
前端 render 邏輯:收到 event → 在該階段行下方插入 `<p class="startup__retry-hint">{i18n("startup.retrying", { stage })}</p>`。
|
|
||||||
|
|
||||||
### 1.3 `startup:error`
|
|
||||||
|
|
||||||
任一階段失敗或總時 > 60 秒 emit 一次。之後 pipeline 停止,後續不會再有任何 `startup:*` event。
|
|
||||||
|
|
||||||
```go
|
|
||||||
type StartupErrorEvent struct {
|
|
||||||
Stage int `json:"stage"`
|
|
||||||
Error string `json:"error"` // 技術性錯誤訊息
|
|
||||||
Cause string `json:"cause"` // "stage-failure" | "total-timeout"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
前端 render 邏輯:切到 Error state 顯示,同時 `server:state-change` 會被觸發(因為 `ctrl.setState(Error, ...)` 已呼叫)。
|
|
||||||
|
|
||||||
### 1.4 `startup:ready`
|
|
||||||
|
|
||||||
6 個階段都 `completed` 後 emit,payload 為空。
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 無 struct,EventsEmit(ctx, "startup:ready", nil)
|
|
||||||
```
|
|
||||||
|
|
||||||
前端 render 邏輯:淡出啟動進度面板(300 ms ease)→ 顯示主控台 UI。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. i18n Key 清單
|
|
||||||
|
|
||||||
文案由 Design Spec v2.1 敲定。TDD 只定義 key 名稱:
|
|
||||||
|
|
||||||
| Key | 用途 |
|
|
||||||
|-----|------|
|
|
||||||
| `startup.title` | 標題「正在啟動 visionA Local」 |
|
|
||||||
| `startup.stage.1.label` | 階段 1:初始化 Wails 控制台 |
|
|
||||||
| `startup.stage.2.label` | 階段 2:檢查 Python runtime |
|
|
||||||
| `startup.stage.3.label` | 階段 3:啟動本機伺服器 |
|
|
||||||
| `startup.stage.4.label` | 階段 4:偵測 Kneron 裝置 |
|
|
||||||
| `startup.stage.5.label` | 階段 5:開啟瀏覽器 |
|
|
||||||
| `startup.stage.6.label` | 階段 6:等待 Web UI 連線 |
|
|
||||||
| `startup.retrying` | 「第 {stage} 階段正在重試 …」副文字 |
|
|
||||||
| `startup.elapsed` | 「已耗時 {seconds} s · 上限 60 s」 |
|
|
||||||
| `startup.error.stageFailure` | 「第 {stage} 階段失敗:{error}」 |
|
|
||||||
| `startup.error.totalTimeout` | 「啟動超過 60 秒,請檢查系統資源」 |
|
|
||||||
|
|
||||||
檔案位置:`visiona-local/frontend/i18n/zh-TW.json` / `en-US.json`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 6 階段對應的 Go 實作點
|
|
||||||
|
|
||||||
| # | 階段 | 在哪裡 `pipeline.Start(N)` | 在哪裡 `pipeline.Complete(N)` |
|
|
||||||
|---|------|---------------------------|------------------------------|
|
|
||||||
| 1 | 初始化 Wails 控制台 | `app.go:startup` 最前面(`a.ctx != nil` 之後第一行)| 同一個 function 的 `seedUserDataDir()` 返回後 |
|
|
||||||
| 2 | 檢查 Python runtime | `startServerV2` 開頭、在 `ensurePythonRuntime()` 前 | `ensurePythonRuntime()` 返回後 |
|
|
||||||
| 3 | 啟動本機伺服器 | 階段 2 完成後立即 | `waitHealthy(port, 30s)` 返回後(server HTTP OK)|
|
|
||||||
| 4 | 偵測 Kneron 裝置 | 階段 3 完成後立即 | 呼叫 server 的 `GET /api/devices` 第一次收到 response(無論是否有硬體,秒回即算完成)|
|
|
||||||
| 5 | 開啟瀏覽器 | 階段 4 完成後 | 呼叫 `OpenInBrowser("")` 返回後(不等瀏覽器真的開,只等 `open`/`start`/`xdg-open` 命令 return);若 `AutoOpenBrowser=false` 則**不呼叫 OpenInBrowser**,直接 emit status=`skipped` 並進入階段 6 |
|
|
||||||
| 6 | 等待 Web UI 連線 | 階段 5 完成後 | server 的 WebSocket hub `OnClientConnected` callback 第一次觸發(透過 HTTP 或 channel 通知 Wails)|
|
|
||||||
|
|
||||||
**階段 6 的實作細節(v2.1 定版:sentinel file 方案)**:
|
|
||||||
|
|
||||||
**決定採用 sentinel file**:`<dataDir>/.first-ws-connected`,理由見下方「為什麼不用 channel / callback 直接串」。
|
|
||||||
|
|
||||||
**流程**:
|
|
||||||
|
|
||||||
1. **Server 端**:WebSocket hub 的 `OnClientConnected` callback 第一次觸發時:
|
|
||||||
```go
|
|
||||||
// server/internal/websocket/hub.go
|
|
||||||
func (h *Hub) OnClientConnected(c *Client) {
|
|
||||||
h.firstConnOnce.Do(func() {
|
|
||||||
sentinelPath := filepath.Join(h.dataDir, ".first-ws-connected")
|
|
||||||
f, err := os.Create(sentinelPath)
|
|
||||||
if err == nil {
|
|
||||||
_, _ = fmt.Fprintf(f, "bootId=%s\nts=%d\n", h.bootID, time.Now().UnixMilli())
|
|
||||||
_ = f.Close()
|
|
||||||
}
|
|
||||||
// 檔案內容存 boot-id + timestamp 便於 debug;寫失敗不影響功能(Wails 端 poll 不到也會超時 fail)
|
|
||||||
})
|
|
||||||
// ... 其他既有邏輯
|
|
||||||
}
|
|
||||||
```
|
|
||||||
使用 `sync.Once` 確保僅第一次連線觸發;後續連線不重複寫檔。
|
|
||||||
|
|
||||||
2. **Wails 端**:`StartupPipeline.watcher` goroutine 每秒 tick 時額外檢查 sentinel 檔案:
|
|
||||||
```go
|
|
||||||
// 當 current == 6 時,每次 tick 檢查 sentinel 檔案
|
|
||||||
if cur == 6 {
|
|
||||||
sentinelPath := filepath.Join(p.app.dataDir, ".first-ws-connected")
|
|
||||||
if _, err := os.Stat(sentinelPath); err == nil {
|
|
||||||
p.Complete(6) // → markReady() → emit startup:ready
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **下次 Start 前清檔**:
|
|
||||||
- `RestartStartupSequence()` 執行時先 `os.Remove(sentinelPath)`(避免殘留檔案導致新階段 6 瞬間完成)
|
|
||||||
- `ServerController.Stop()` 時也清檔(正常停機的清理)
|
|
||||||
- 冷啟動時不需特別清:第一次啟動時檔案不存在,第二次冷啟動前 app 已經整個退出,看情況:v2.1 決定 **每次 `StartServer` 的前置步驟就呼叫一次 `os.Remove(sentinelPath)`**(最保險,重複 remove 不會錯,反正 `os.IsNotExist` 當正常情況)
|
|
||||||
|
|
||||||
**為什麼用 sentinel file 而非 channel / callback 直接串**:
|
|
||||||
- **Go server 和 Wails app 是兩個 process**:server 是由 `exec.Command(server binary, ...)` spawn 出來的子程序,跟 Wails 的 Go runtime 完全隔離 → 不能共享 Go channel、mutex 或任何 runtime 記憶體
|
|
||||||
- **IPC 替代方案比較**:
|
|
||||||
- HTTP long-poll endpoint → 需要佔一個 HTTP connection + 處理 timeout,實作較重
|
|
||||||
- Unix domain socket / named pipe → 跨平台(macOS/Linux/Windows)實作差異大,Windows 下處理 pipe 權限繁瑣
|
|
||||||
- Sentinel file → 跨平台(`os.Stat` 到處都行)、零依賴、檔案內容可存 boot-id + timestamp 做 debug → **最簡**
|
|
||||||
- **可觀測性**:使用者遇到問題時可以直接檢查 `<dataDir>/.first-ws-connected` 是否存在,判斷階段 6 是否真的完成
|
|
||||||
|
|
||||||
**備選方案**(若未來 Go server 改為 in-process module,不再 spawn 子程序):可以改走 Go channel / callback 直接串,屆時在本文件註記即可;v2.1 的前提是子程序模型。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. StartupPipeline Go struct
|
|
||||||
|
|
||||||
檔案:`visiona-local/startup_pipeline.go`
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
startupTotalStages = 6
|
|
||||||
startupSoftTimeout = 20 * time.Second
|
|
||||||
startupHardTimeout = 60 * time.Second
|
|
||||||
startupWatcherTickMs = 1000
|
|
||||||
)
|
|
||||||
|
|
||||||
type stageState struct {
|
|
||||||
status string // "pending" | "running" | "completed" | "failed"
|
|
||||||
startedAt time.Time
|
|
||||||
completedAt time.Time
|
|
||||||
softTimeoutEmitted bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type StartupPipeline struct {
|
|
||||||
app *App
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
stages [startupTotalStages + 1]stageState // 1-indexed
|
|
||||||
current int // 0 = not started, 1-6 = in progress, 7 = ready, -1 = failed
|
|
||||||
startedAt time.Time
|
|
||||||
|
|
||||||
watcherCancel context.CancelFunc
|
|
||||||
watcherDone chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStartupPipeline(app *App) *StartupPipeline {
|
|
||||||
return &StartupPipeline{
|
|
||||||
app: app,
|
|
||||||
current: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start 啟動整個 pipeline(從階段 1 開始),並開啟 watcher goroutine。
|
|
||||||
// 只能呼叫一次。
|
|
||||||
func (p *StartupPipeline) Start(ctx context.Context) {
|
|
||||||
p.mu.Lock()
|
|
||||||
p.startedAt = time.Now()
|
|
||||||
p.current = 1
|
|
||||||
p.stages[1].status = "running"
|
|
||||||
p.stages[1].startedAt = time.Now()
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
p.emitProgress(1)
|
|
||||||
|
|
||||||
watcherCtx, cancel := context.WithCancel(ctx)
|
|
||||||
p.watcherCancel = cancel
|
|
||||||
p.watcherDone = make(chan struct{})
|
|
||||||
go p.watcher(watcherCtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete 標記當前階段完成,並自動切到下一階段(若還有)。
|
|
||||||
func (p *StartupPipeline) Complete(stage int) {
|
|
||||||
p.mu.Lock()
|
|
||||||
if p.current != stage || p.current <= 0 {
|
|
||||||
p.mu.Unlock()
|
|
||||||
return // 重複呼叫或順序錯誤,忽略
|
|
||||||
}
|
|
||||||
p.stages[stage].status = "completed"
|
|
||||||
p.stages[stage].completedAt = time.Now()
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
p.emitProgress(stage)
|
|
||||||
|
|
||||||
if stage == startupTotalStages {
|
|
||||||
p.markReady()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 進入下一階段
|
|
||||||
p.mu.Lock()
|
|
||||||
next := stage + 1
|
|
||||||
p.current = next
|
|
||||||
p.stages[next].status = "running"
|
|
||||||
p.stages[next].startedAt = time.Now()
|
|
||||||
p.mu.Unlock()
|
|
||||||
p.emitProgress(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SkipStage 標記某階段為 "skipped",並自動切到下一階段(行為類似 Complete)。
|
|
||||||
// v2.1 新增:用於階段 5 在 AutoOpenBrowser=false 時跳過 OpenInBrowser 呼叫。
|
|
||||||
// Watcher 看到 skipped 狀態時不檢查 soft / hard timeout。
|
|
||||||
func (p *StartupPipeline) SkipStage(stage int) {
|
|
||||||
p.mu.Lock()
|
|
||||||
if p.current != stage || p.current <= 0 {
|
|
||||||
p.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.stages[stage].status = "skipped"
|
|
||||||
p.stages[stage].completedAt = time.Now()
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
p.emitProgress(stage) // emit status=skipped
|
|
||||||
|
|
||||||
if stage == startupTotalStages {
|
|
||||||
p.markReady()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p.mu.Lock()
|
|
||||||
next := stage + 1
|
|
||||||
p.current = next
|
|
||||||
p.stages[next].status = "running"
|
|
||||||
p.stages[next].startedAt = time.Now()
|
|
||||||
p.mu.Unlock()
|
|
||||||
p.emitProgress(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail 標記當前階段失敗,pipeline 停止。
|
|
||||||
func (p *StartupPipeline) Fail(stage int, err error) {
|
|
||||||
p.mu.Lock()
|
|
||||||
if p.current <= 0 {
|
|
||||||
p.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.stages[stage].status = "failed"
|
|
||||||
p.current = -1
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
p.emitProgress(stage)
|
|
||||||
p.emitError(stage, err, "stage-failure")
|
|
||||||
p.stopWatcher()
|
|
||||||
}
|
|
||||||
|
|
||||||
// markReady 所有階段完成後觸發。
|
|
||||||
func (p *StartupPipeline) markReady() {
|
|
||||||
p.mu.Lock()
|
|
||||||
p.current = startupTotalStages + 1
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
if p.app.ctx != nil {
|
|
||||||
wailsRuntime.EventsEmit(p.app.ctx, "startup:ready", nil)
|
|
||||||
}
|
|
||||||
p.stopWatcher()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *StartupPipeline) emitProgress(stage int) {
|
|
||||||
p.mu.Lock()
|
|
||||||
st := p.stages[stage]
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
if p.app.ctx == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 使用 goroutine + select 避免阻塞(萬一 Wails IPC 慢)
|
|
||||||
go func() {
|
|
||||||
wailsRuntime.EventsEmit(p.app.ctx, "startup:progress", StartupProgressEvent{
|
|
||||||
Stage: stage,
|
|
||||||
TotalStages: startupTotalStages,
|
|
||||||
LabelKey: fmt.Sprintf("startup.stage.%d.label", stage),
|
|
||||||
Status: st.status,
|
|
||||||
StartedAt: st.startedAt.UnixMilli(),
|
|
||||||
})
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *StartupPipeline) emitError(stage int, err error, cause string) {
|
|
||||||
if p.app.ctx == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
wailsRuntime.EventsEmit(p.app.ctx, "startup:error", StartupErrorEvent{
|
|
||||||
Stage: stage,
|
|
||||||
Error: err.Error(),
|
|
||||||
Cause: cause,
|
|
||||||
})
|
|
||||||
}()
|
|
||||||
// 同步通知 ServerController 進 Error state
|
|
||||||
if p.app.ctrl != nil {
|
|
||||||
p.app.ctrl.setState(ServerStateError, err.Error())
|
|
||||||
}
|
|
||||||
// R5-D1:發 OS 通知
|
|
||||||
go sendCrashNotification(
|
|
||||||
"visionA Local — 啟動失敗",
|
|
||||||
fmt.Sprintf("第 %d 階段失敗:%s", stage, err.Error()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// watcher 每秒檢查 soft timeout、hard timeout、階段 6 sentinel file。
|
|
||||||
func (p *StartupPipeline) watcher(ctx context.Context) {
|
|
||||||
defer close(p.watcherDone)
|
|
||||||
ticker := time.NewTicker(startupWatcherTickMs * time.Millisecond)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
p.mu.Lock()
|
|
||||||
if p.current <= 0 || p.current > startupTotalStages {
|
|
||||||
p.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cur := p.current
|
|
||||||
st := p.stages[cur]
|
|
||||||
curStatus := st.status
|
|
||||||
sinceStage := time.Since(st.startedAt)
|
|
||||||
sinceTotal := time.Since(p.startedAt)
|
|
||||||
softEmitted := st.softTimeoutEmitted
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
// v2.1:階段 6 sentinel file 檢查(取代既有的 OnClientConnected IPC)
|
|
||||||
if cur == 6 {
|
|
||||||
sentinelPath := filepath.Join(p.app.dataDir, ".first-ws-connected")
|
|
||||||
if _, err := os.Stat(sentinelPath); err == nil {
|
|
||||||
p.Complete(6)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// v2.1:skip hard / soft timeout 的情境(必須在 hard timeout 檢查前先判斷)
|
|
||||||
// 1) 該階段已標記為 "skipped"(例如階段 5 在 AutoOpenBrowser=false 時)
|
|
||||||
// 2) 階段 6 且 AutoOpenBrowser=false → 使用者必須手動點「Open in Browser」才會觸發 WebSocket
|
|
||||||
// 連線,等待時間可能很長(使用者去倒杯咖啡),不計入 60 s 上限,也不觸發 soft timeout
|
|
||||||
skipTimeout := false
|
|
||||||
if curStatus == "skipped" {
|
|
||||||
skipTimeout = true
|
|
||||||
}
|
|
||||||
if cur == 6 && p.app.prefs != nil && !p.app.prefs.AutoOpenBrowser {
|
|
||||||
skipTimeout = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hard timeout(總時 > 60 s)— 跳過時不檢查
|
|
||||||
if !skipTimeout && sinceTotal > startupHardTimeout {
|
|
||||||
p.Fail(cur, fmt.Errorf("startup total timeout: %s > %s", sinceTotal, startupHardTimeout))
|
|
||||||
p.emitError(cur, fmt.Errorf("total timeout"), "total-timeout")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if skipTimeout {
|
|
||||||
continue // 不檢查 soft timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
// Soft timeout(單一階段 > 20 s)
|
|
||||||
if sinceStage > startupSoftTimeout && !softEmitted {
|
|
||||||
p.mu.Lock()
|
|
||||||
p.stages[cur].softTimeoutEmitted = true
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
if p.app.ctx != nil {
|
|
||||||
go func(stage int) {
|
|
||||||
wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-timeout", StartupStageTimeoutEvent{
|
|
||||||
Stage: stage,
|
|
||||||
SoftTimeoutSeconds: int(startupSoftTimeout.Seconds()),
|
|
||||||
})
|
|
||||||
}(cur)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *StartupPipeline) stopWatcher() {
|
|
||||||
if p.watcherCancel != nil {
|
|
||||||
p.watcherCancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**關鍵設計**:
|
|
||||||
|
|
||||||
1. **1-indexed stages**:陣列多配一格避免 off-by-one,`stages[1]` ~ `stages[6]` 使用
|
|
||||||
2. **`current` sentinel 值**:`0`=未啟動、`1-6`=進行中、`7`=ready、`-1`=已失敗;watcher 看到非 1-6 立即 return,避免 leak
|
|
||||||
3. **非阻塞 emit**:每個 `EventsEmit` 都走 `go func()`,確保 Wails IPC 慢不拖累啟動流程
|
|
||||||
4. **`softTimeoutEmitted` flag**:確保 `startup:stage-timeout` 每個階段最多 emit 一次
|
|
||||||
5. **watcher 生命週期**:`ctx.Done()` 或 `stopWatcher()` 觸發時退出;`markReady()` 與 `Fail()` 都會呼叫 `stopWatcher()`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 與 `startup(ctx)` 的整合
|
|
||||||
|
|
||||||
`visiona-local/app.go` 的 `startup()` 函式修改(示意):
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (a *App) startup(ctx context.Context) {
|
|
||||||
a.ctx = ctx
|
|
||||||
|
|
||||||
// 初始化階段化啟動管線
|
|
||||||
a.pipeline = NewStartupPipeline(a)
|
|
||||||
a.pipeline.Start(ctx) // emit startup:progress(stage=1, running)
|
|
||||||
|
|
||||||
// 階段 1:既有的初始化
|
|
||||||
if err := a.migrateOldDataDirs(); err != nil { ... }
|
|
||||||
if err := a.acquireSingleInstance(); err != nil { ... }
|
|
||||||
if err := a.startIPCServer(); err != nil { ... }
|
|
||||||
if err := a.seedUserDataDir(); err != nil { ... }
|
|
||||||
a.pipeline.Complete(1) // emit startup:progress(stage=1, completed) + startup:progress(stage=2, running)
|
|
||||||
|
|
||||||
// 載入 preferences(必須在 ctrl.Start 之前,ctrl.Start 會讀 prefs.AutoOpenBrowser)
|
|
||||||
a.prefs = LoadPreferences(a.dataDir)
|
|
||||||
|
|
||||||
// 階段 2-6:由 ctrl.Start → startServerV2 內部呼叫 pipeline.Complete(2..6)
|
|
||||||
if err := a.ctrl.Start(); err != nil {
|
|
||||||
// 失敗情境已由 pipeline.Fail / emitError 處理,這裡不需額外動作
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 成功:pipeline.Complete(6) → markReady() → emit startup:ready
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`server_control.go:startServerV2` 內部穿插 `pipeline.Complete(N)` / `pipeline.Start(N+1)` 的呼叫,在對應階段的 boundary 執行。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 前端(Wails 控制台 vanilla JS)
|
|
||||||
|
|
||||||
檔案:`visiona-local/frontend/components/startup-panel.js`(~100 行)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// startup-panel.js
|
|
||||||
import { t } from '../i18n/loader.js';
|
|
||||||
|
|
||||||
const el = () => document.getElementById('startup-panel');
|
|
||||||
const stagesEl = () => document.getElementById('startup-stages');
|
|
||||||
const elapsedEl = () => document.getElementById('startup-elapsed');
|
|
||||||
|
|
||||||
let startedAt = 0;
|
|
||||||
let elapsedTimer = null;
|
|
||||||
|
|
||||||
export function initStartupPanel() {
|
|
||||||
// 初始 render 6 個階段為 pending
|
|
||||||
const container = stagesEl();
|
|
||||||
container.innerHTML = '';
|
|
||||||
for (let i = 1; i <= 6; i++) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.id = `startup-stage-${i}`;
|
|
||||||
row.className = 'startup__stage startup__stage--pending';
|
|
||||||
row.innerHTML = `
|
|
||||||
<span class="startup__icon">⏳</span>
|
|
||||||
<span class="startup__label">${i}. ${t(`startup.stage.${i}.label`)}</span>
|
|
||||||
<span class="startup__time"></span>
|
|
||||||
<p class="startup__retry-hint" hidden></p>
|
|
||||||
`;
|
|
||||||
container.appendChild(row);
|
|
||||||
}
|
|
||||||
startedAt = Date.now();
|
|
||||||
elapsedTimer = setInterval(updateElapsed, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateElapsed() {
|
|
||||||
const seconds = Math.floor((Date.now() - startedAt) / 1000);
|
|
||||||
elapsedEl().textContent = t('startup.elapsed', { seconds });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateStartupPanel(e) {
|
|
||||||
// e = { stage, totalStages, labelKey, status, startedAt, retrying? }
|
|
||||||
const row = document.getElementById(`startup-stage-${e.stage}`);
|
|
||||||
if (!row) return;
|
|
||||||
row.className = `startup__stage startup__stage--${e.status}`;
|
|
||||||
const icon = row.querySelector('.startup__icon');
|
|
||||||
icon.textContent = {
|
|
||||||
pending: '⏳',
|
|
||||||
running: '🔄',
|
|
||||||
completed: '✅',
|
|
||||||
failed: '❌',
|
|
||||||
retrying: '🔄',
|
|
||||||
}[e.status] || '⏳';
|
|
||||||
|
|
||||||
if (e.status === 'completed' || e.status === 'failed') {
|
|
||||||
const elapsed = ((Date.now() - e.startedAt) / 1000).toFixed(1);
|
|
||||||
row.querySelector('.startup__time').textContent = `(${elapsed} s)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.status === 'retrying' || e.softTimeoutSeconds) {
|
|
||||||
const hint = row.querySelector('.startup__retry-hint');
|
|
||||||
hint.textContent = t('startup.retrying', { stage: e.stage });
|
|
||||||
hint.hidden = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showStartupError(e) {
|
|
||||||
// 最終切 Error state 會由 server:state-change 處理;這裡只做視覺標記
|
|
||||||
const row = document.getElementById(`startup-stage-${e.stage}`);
|
|
||||||
if (row) {
|
|
||||||
row.className = 'startup__stage startup__stage--failed';
|
|
||||||
row.querySelector('.startup__icon').textContent = '❌';
|
|
||||||
}
|
|
||||||
// 停止 elapsed timer
|
|
||||||
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hideStartupPanel() {
|
|
||||||
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; }
|
|
||||||
const panel = el();
|
|
||||||
if (!panel) return;
|
|
||||||
// 300 ms 淡出
|
|
||||||
panel.classList.add('startup__panel--hiding');
|
|
||||||
setTimeout(() => {
|
|
||||||
panel.hidden = true;
|
|
||||||
panel.classList.remove('startup__panel--hiding');
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`app.js` 在 `main()` 最前面呼叫 `initStartupPanel()`,然後訂閱 4 個 event(見 `control-panel.md` §5 的更新)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 驗收條件
|
|
||||||
|
|
||||||
| # | 情境 | 預期 |
|
|
||||||
|---|------|------|
|
|
||||||
| 1 | 樂觀冷啟動(所有階段 ~1 s)| 4-5 s 內看到進度面板 6 階段逐一 completed → 淡出顯示主控台 |
|
|
||||||
| 2 | 日常啟動(非首次,~3 s)| 進度面板 min-display-time 1 s 後淡出 |
|
|
||||||
| 3 | 悲觀冷啟動(Python wheels extract 慢)| 8-10 s 內完成,階段 2 可能顯示較久但不觸發 retry hint |
|
|
||||||
| 4 | 階段 2 卡 25 s(mock 測試)| 看到「第 2 階段正在重試 …」副文字、但流程繼續 |
|
|
||||||
| 5 | 階段 3 失敗(server binary 不存在)| 階段 3 變 ❌、收到 OS 通知、切 Error state |
|
|
||||||
| 6 | 總時 > 60 s(mock 每階段等 12 s)| 60 s 時切 Error state + emit `startup:error` |
|
|
||||||
| 7 | `AutoOpenBrowser=false`(Linux 預設)| 階段 5 立即 complete(跳過 OpenInBrowser 實際呼叫)|
|
|
||||||
| 8 | WebSocket 30 s 內無 client 連上(罕見)| 階段 6 失敗 → Error state |
|
|
||||||
| 9 | 進度面板淡出不卡住主控台 | 300 ms ease 後主控台 UI 顯示,無視覺 artifact |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Retry 機制(RestartStartupSequence,v2.1 新增)
|
|
||||||
|
|
||||||
**觸發情境**:Wails 控制台進入 Startup Error state(階段失敗或 60 s hard timeout),使用者在 Error state 面板點「Retry」按鈕(Design Spec v2.1 §3.7 定義)。這個行為**不是** `RestartServer()`(後者只重啟 server 子程序,保留 port),而是**整個啟動流程重跑**。
|
|
||||||
|
|
||||||
### 8.1 Binding:`RestartStartupSequence`
|
|
||||||
|
|
||||||
```go
|
|
||||||
// visiona-local/app.go
|
|
||||||
// RestartStartupSequence resets the entire startup pipeline and tries again.
|
|
||||||
// Triggered by user clicking "Retry" button in Wails console Error state.
|
|
||||||
//
|
|
||||||
// 與 RestartServer 的差別:
|
|
||||||
// RestartServer : Stop → Start(保留 port、沿用既有 StartupPipeline instance)
|
|
||||||
// RestartStartupSequence : ForceKill → 清狀態 → 重建 StartupPipeline → 從階段 2 跑(階段 1 直接 emit completed)
|
|
||||||
func (a *App) RestartStartupSequence() error {
|
|
||||||
// Step 1: 停止當前的 watcher goroutine(避免舊 watcher 把剛重跑的階段誤判為 soft timeout)
|
|
||||||
if a.pipelineCancelFn != nil {
|
|
||||||
a.pipelineCancelFn()
|
|
||||||
a.pipelineCancelFn = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: 強制殺掉 server 子程序(不等 graceful period,我們是在 recover failure)
|
|
||||||
// 直接 ForceKill 比 Stop() 快;Stop() 會走 7 s grace period 對這個情境沒必要
|
|
||||||
if a.ctrl != nil {
|
|
||||||
a.ctrl.ForceKill() // 新增 method:內部 SIGKILL + 清 c.proc + setState(Stopped) 但不 emit crash notification
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Reset state machine to Stopped(避免 Error state 殘留)
|
|
||||||
if a.ctrl != nil {
|
|
||||||
a.ctrl.setState(ServerStateStopped, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: 清 sentinel file(critical — 否則階段 6 會誤判為瞬間完成)
|
|
||||||
sentinelPath := filepath.Join(a.dataDir, ".first-ws-connected")
|
|
||||||
_ = os.Remove(sentinelPath)
|
|
||||||
|
|
||||||
// Step 5: 重建 StartupPipeline 並呼叫 StartServer
|
|
||||||
a.startupPipeline = NewStartupPipeline(a)
|
|
||||||
|
|
||||||
// 階段 1「初始化 Wails 控制台」已經是 running 狀態(我們是 Wails app 本身,不需要重做),
|
|
||||||
// 直接 emit completed 不重跑
|
|
||||||
a.startupPipeline.mu.Lock()
|
|
||||||
a.startupPipeline.startedAt = time.Now()
|
|
||||||
a.startupPipeline.current = 1
|
|
||||||
a.startupPipeline.stages[1].status = "completed"
|
|
||||||
a.startupPipeline.stages[1].startedAt = time.Now()
|
|
||||||
a.startupPipeline.stages[1].completedAt = time.Now()
|
|
||||||
a.startupPipeline.mu.Unlock()
|
|
||||||
a.startupPipeline.emitProgress(1) // emit stage=1 completed
|
|
||||||
|
|
||||||
// 啟動 watcher goroutine
|
|
||||||
watcherCtx, cancel := context.WithCancel(a.ctx)
|
|
||||||
a.pipelineCancelFn = cancel
|
|
||||||
a.startupPipeline.watcherDone = make(chan struct{})
|
|
||||||
go a.startupPipeline.watcher(watcherCtx)
|
|
||||||
|
|
||||||
// 切到階段 2 並真的跑
|
|
||||||
a.startupPipeline.mu.Lock()
|
|
||||||
a.startupPipeline.current = 2
|
|
||||||
a.startupPipeline.stages[2].status = "running"
|
|
||||||
a.startupPipeline.stages[2].startedAt = time.Now()
|
|
||||||
a.startupPipeline.mu.Unlock()
|
|
||||||
a.startupPipeline.emitProgress(2)
|
|
||||||
|
|
||||||
// Step 6: 呼叫 StartServer(內部會依序 Complete(2..6))
|
|
||||||
// Retry 情境允許 port fallback(視同 cold start;見 server-lifecycle.md §3.3)
|
|
||||||
return a.ctrl.Start()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 前端整合
|
|
||||||
|
|
||||||
Wails 控制台的 Error state 面板顯示:
|
|
||||||
- 錯誤訊息(來自 `startup:error` event 的 `error` 欄位)
|
|
||||||
- 失敗階段(`stage` 欄位)
|
|
||||||
- 「Retry」按鈕 → `RestartStartupSequence().catch(showError)`
|
|
||||||
- 「Export log」按鈕 → `ExportLog()`(方便使用者回報問題時附 log)
|
|
||||||
- 「Quit」按鈕 → `runtime.Quit(ctx)`
|
|
||||||
|
|
||||||
點 Retry 後:
|
|
||||||
1. 控制台 UI 切回 Starting state(隱藏 Error 面板、顯示 Startup Progress 面板)
|
|
||||||
2. 重跑 `initStartupPanel()` 將 6 個階段重新 render 為 pending(階段 1 立即變 completed)
|
|
||||||
3. 訂閱者會陸續收到 `startup:progress` 事件更新 DOM
|
|
||||||
|
|
||||||
### 8.3 驗收
|
|
||||||
|
|
||||||
| # | 情境 | 預期 |
|
|
||||||
|---|------|------|
|
|
||||||
| R1 | 階段 2 失敗 → 點 Retry → server 恢復可啟動 | Retry 後階段 1 顯示 completed、階段 2-6 依序完成 → `startup:ready` |
|
|
||||||
| R2 | 階段 2 失敗 → 點 Retry → 仍然失敗(Python binary 還是壞的)| 再次進 Error state,不會無限重試 |
|
|
||||||
| R3 | 60 s hard timeout → 點 Retry | 整個流程計時歸零,新一輪 60 s 上限 |
|
|
||||||
| R4 | Retry 時 port 3721 被其他程式佔用 | 允許 fallback 到 3722/3723… |
|
|
||||||
| R5 | 連點 Retry 兩次 | 第二次在 `pipelineCancelFn != nil` 檢查時安全地 cancel 舊 watcher,不會殘留 goroutine |
|
|
||||||
| R6 | 階段 6 還在 pending 時點 Retry | ForceKill 掉 server → sentinel file 被清 → 從階段 2 重跑 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 待確認
|
|
||||||
|
|
||||||
1. **階段 6 的「WebSocket 首次連線」實作方式(已定版)** — v2.1 二次審閱定案:採用 sentinel file(`<dataDir>/.first-ws-connected`)。詳見 §3 階段 6 章節。無需開發時再討論。
|
|
||||||
2. **進度面板 min-display-time** — 若啟動 < 1 s,進度面板閃一下就消失不好看。建議設 1 s min-display-time(即使 `startup:ready` 已 emit,面板也要留 1 s 才淡出)。實作細節可放在 `hideStartupPanel()` 判斷 `Date.now() - startedAt < 1000` 時延遲執行
|
|
||||||
3. **R5-E5 文案** — Design Spec v2.1 尚未敲定,i18n key 已預留。Design Agent 完成後可直接填入 `i18n/*.json`
|
|
||||||
4. **watcher goroutine 與 `ctrl.Stop()` 的交互** — 若使用者在啟動中間按了 Stop(不太可能,Starting 狀態下 action bar 禁用),pipeline 要能 cancel。目前靠 `stopWatcher()` + `ctrl.setState(Error)` 處理,實測後若有 race 再補強
|
|
||||||
5. **`ForceKill` method 需新增到 ServerController** — RestartStartupSequence 依賴這個 method,`server_control.go` 要加一個非同步版 Stop:直接 SIGKILL 不走 7 s grace、不發 crash notification。M8-4b 執行時補上
|
|
||||||
@ -1,679 +0,0 @@
|
|||||||
# v2/web-ui-offline-overlay.md — Web UI Server Offline Overlay
|
|
||||||
|
|
||||||
> 所屬:TDD v2 §2.6
|
|
||||||
> 版本:v2.1(2026-04-14 吸收 Minor 4 WebSocket shutdown-imminent 廣播)
|
|
||||||
> 決策依據:R5-2(關閉 Wails 視窗 = server 停,瀏覽器顯示 offline overlay)、三方共識 #14(boot-id + retry)、PM Minor 4(WebSocket 廣播即時觸發 overlay,消除 race condition)
|
|
||||||
> 對應 milestone:M8-7
|
|
||||||
> 相關文件:`v2/server-lifecycle.md` §8.3(Wails 端 WebSocket 廣播時機)、§9(server 端 boot-id API)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 目的
|
|
||||||
|
|
||||||
當瀏覽器 tab 偵測到 server 連不上(Wails app 被關了、使用者按 Stop、server crash)時,顯示一個全域的覆蓋層告訴使用者「Server 已離線」,並提供重試機制。同時實作 boot-id polling 讓 server 重啟後瀏覽器自動 reload。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. UI 設計
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ ⚠️ │
|
|
||||||
│ │
|
|
||||||
│ Server 已離線 │
|
|
||||||
│ │
|
|
||||||
│ visionA Local 的本機伺服器沒有回應。 │
|
|
||||||
│ 可能原因: │
|
|
||||||
│ • 你關閉了 visionA Local 控制台 │
|
|
||||||
│ • Server 被手動停止 │
|
|
||||||
│ • Server 發生錯誤 │
|
|
||||||
│ │
|
|
||||||
│ 請重新開啟 visionA Local 應用程式後按「重試」。 │
|
|
||||||
│ │
|
|
||||||
│ [重試] [關閉這個頁面] │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- 全螢幕半透明 backdrop(`rgba(0,0,0,0.65)`)
|
|
||||||
- 中央卡片(`max-w-md`)
|
|
||||||
- 中英文隨現有 i18n locale
|
|
||||||
- 不擋 devtools、不擋 keyboard(使用者可用 devtools 檢查 network 看是不是真連不上)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 偵測機制
|
|
||||||
|
|
||||||
### 3.1 偵測參數定版(v2.1 Architect 二次審閱補齊)
|
|
||||||
|
|
||||||
**Polling 間隔與失敗門檻(定版)**:
|
|
||||||
|
|
||||||
| 參數 | 值 | 說明 |
|
|
||||||
|------|-----|------|
|
|
||||||
| Health check 間隔(正常模式) | **10 秒** | server 正常時的 boot-id polling 間隔 |
|
|
||||||
| Health check 間隔(active retry 模式)| **3 秒** | overlay 顯示後自動重試的 polling 間隔 |
|
|
||||||
| 連續失敗門檻 | **2 次**(約 20 秒)| 避免偶發網路抖動誤觸發 |
|
|
||||||
| fetch 單次 timeout | 3 秒 | `AbortSignal.timeout(3000)` |
|
|
||||||
|
|
||||||
**Active retry 模式定義**:使用者第一次看到 overlay 後(或主動點 Retry 按鈕),前端**自動**每 3 秒重打一次 `/api/system/boot-id`,不需要使用者持續按按鈕。只要任一次成功 → 立即 `resetFailures()` → overlay 消失(若 bootId 變了則 `window.location.reload()`)。
|
|
||||||
|
|
||||||
**觸發管道**:Overlay 顯示由三個獨立管道觸發,任一觸發即顯示(優先序由高到低):
|
|
||||||
|
|
||||||
1. **WebSocket close event(v2.1 新增)**:瀏覽器端 WebSocket 收到 `onclose` → **立即**顯示 overlay(不等 polling);理由:close 事件代表 server 端已斷,polling 也一定會失敗,不需要等 20 秒
|
|
||||||
2. **WebSocket `server:shutdown-imminent` 事件(Minor 4)**:收到廣播訊息 → **立即**顯示 overlay,並依 `reason` 欄位決定文案:
|
|
||||||
- `reason="app-closing"` / `reason="manual-stop"` / `reason="quit"` → 立即顯示
|
|
||||||
- `reason="restart"` → 延遲 10 秒顯示(給 boot-id 變化 → `window.location.reload()` 的時間)
|
|
||||||
3. **被動偵測(polling fallback)**:連續 2 次 boot-id polling 失敗 → 顯示 overlay(當 WebSocket 已斷線或從未建立時生效)
|
|
||||||
|
|
||||||
WebSocket 管道的優勢:
|
|
||||||
- **零延遲**:Wails 開始 SIGTERM 的瞬間就發廣播(在實際 shutdown 完成前),瀏覽器 tab 幾乎同時看到 overlay
|
|
||||||
- **消除 race condition**:原 v2.0 設計下,使用者關 Wails 視窗到瀏覽器 tab 看到 overlay 有 0.5-20 s 的 window(取決於 polling 時機),這段期間使用者可能對著一個「已失效但看起來正常」的頁面操作
|
|
||||||
- **回退**:若 WebSocket 已斷線或從未建立(極罕見,例如 Next.js 載入失敗),polling fallback 仍有效
|
|
||||||
|
|
||||||
### 3.2 Polling 策略(v2.1 定版)
|
|
||||||
|
|
||||||
```
|
|
||||||
瀏覽器 tab 載入
|
|
||||||
↓
|
|
||||||
initial fetch /api/system/boot-id
|
|
||||||
↓
|
|
||||||
成功 → 記錄 initialBootId + startedAt,進 normal polling(10 s interval)
|
|
||||||
↓
|
|
||||||
下一次 poll:
|
|
||||||
├─ 成功 → 比對 bootId
|
|
||||||
│ ├─ 相同 → 無事
|
|
||||||
│ └─ 不同 → window.location.reload()(server 重啟,強制 reload)
|
|
||||||
└─ 失敗 → consecutiveFailures++
|
|
||||||
├─ < 2 → 不顯示 overlay(避免偶發網路 glitch)
|
|
||||||
└─ ≥ 2(連續 ~20 s)→ 顯示 <ServerOfflineOverlay>
|
|
||||||
+ 切 polling interval 為 3 s(active retry 模式)
|
|
||||||
↓
|
|
||||||
Active retry 模式下:
|
|
||||||
每 3 s 自動重打 /api/system/boot-id
|
|
||||||
├─ 成功(bootId 相同)→ resetFailures() + 切回 10 s interval → overlay 消失
|
|
||||||
└─ 成功(bootId 不同)→ window.location.reload()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2a WebSocket `server:shutdown-imminent` 偵測
|
|
||||||
|
|
||||||
```
|
|
||||||
瀏覽器 tab 已連上 WebSocket(例如 /ws/server-logs 或新增的 /ws/system)
|
|
||||||
↓
|
|
||||||
server 收到 Wails 的 ctrl.Stop() 呼叫
|
|
||||||
↓
|
|
||||||
server 對 WebSocket hub 廣播:
|
|
||||||
{
|
|
||||||
"type": "server:shutdown-imminent",
|
|
||||||
"reason": "app-closing" | "manual-stop" | "restart",
|
|
||||||
"ts": 1744656180123
|
|
||||||
}
|
|
||||||
↓
|
|
||||||
瀏覽器收到 message → store.forceShowOverlay()
|
|
||||||
↓
|
|
||||||
<ServerOfflineOverlay> 立即顯示(不等 polling 失敗)
|
|
||||||
↓
|
|
||||||
後續 SIGTERM、server 真的停止、polling 失敗 → 疊加在 overlay 上不重複觸發
|
|
||||||
```
|
|
||||||
|
|
||||||
**reason 欄位用途(v2.1 定版)**:
|
|
||||||
- `app-closing`:Wails OnShutdown 觸發,應用即將退出 → 立即顯示 overlay,文案「visionA Local 已關閉」
|
|
||||||
- `manual-stop`:使用者按控制台的 Stop → 立即顯示 overlay,文案「Server 已停止,請在控制台按 Start」
|
|
||||||
- `quit`:使用者從 Wails 選單選 Quit(macOS Cmd+Q 等)→ 立即顯示 overlay,文案同 `app-closing`
|
|
||||||
- `restart`:使用者按控制台的 Restart → **延遲 10 秒**再顯示 overlay(給 boot-id 變化 → `window.location.reload()` 的時間);若 10 s 內 server 回來 + boot-id 變 → reload → overlay 不會真的顯示
|
|
||||||
|
|
||||||
### 3.3 失敗計數策略(polling 管道,v2.1 定版)
|
|
||||||
|
|
||||||
- **Normal 模式**:10 s poll interval,連續 2 次失敗(= 覆蓋 ~20 s 容忍期)才觸發 overlay
|
|
||||||
- **Active retry 模式**:overlay 顯示後切 3 s poll interval,自動重試至 server 回來
|
|
||||||
- Wails 關閉視窗的 `StopServer` 過程約 0.5-1 s,瀏覽器 polling 通常靠 WebSocket close 事件或 `server:shutdown-imminent` 廣播立即顯示 overlay,而非等 polling 失敗
|
|
||||||
- 使用者正常 Restart(透過控制台)約 2-3 s,boot-id 會變但也不會連續 2 次失敗 → 下一次成功 poll 偵測 boot-id 變化 → force reload
|
|
||||||
- 網路偶發抖動(< 10 s 單次 glitch)→ 最多 1 次 failure,下一次 poll 成功即清零,不觸發 overlay
|
|
||||||
|
|
||||||
### 3.3 Page Visibility API(R-v2-6)
|
|
||||||
|
|
||||||
瀏覽器背景 tab 的 setInterval 會被降頻到 1 次/分鐘,會讓「server 重啟後使用者切回 tab」體驗不好。
|
|
||||||
|
|
||||||
解決:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const BOOT_ID_POLL_MS = 5000;
|
|
||||||
let intervalHandle: number | null = null;
|
|
||||||
|
|
||||||
function startPolling() {
|
|
||||||
if (intervalHandle !== null) return;
|
|
||||||
intervalHandle = window.setInterval(pollOnce, BOOT_ID_POLL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling() {
|
|
||||||
if (intervalHandle !== null) {
|
|
||||||
clearInterval(intervalHandle);
|
|
||||||
intervalHandle = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
pollOnce(); // 立刻 probe 一次
|
|
||||||
startPolling();
|
|
||||||
} else {
|
|
||||||
stopPolling(); // 背景 tab 不 poll,回來再說
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初始:tab 可見時才啟動
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
startPolling();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 實作
|
|
||||||
|
|
||||||
### 4.1 檔案清單
|
|
||||||
|
|
||||||
| 檔案 | 狀態 | 說明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `frontend/src/stores/system-store.ts` | **新增** | Zustand store:`serverOnline / bootId / forcedOffline / lastError` |
|
|
||||||
| `frontend/src/hooks/use-boot-id-watcher.ts` | **新增** | React hook:掛 useEffect 啟動 polling + visibility handling |
|
|
||||||
| `frontend/src/hooks/use-shutdown-watcher.ts` | **新增**(v2.1)| React hook:訂閱 WebSocket `server:shutdown-imminent` 事件(Minor 4)|
|
|
||||||
| `frontend/src/components/server-offline-overlay.tsx` | **新增** | 全域覆蓋層 UI(shadcn Dialog / Card) |
|
|
||||||
| `frontend/src/components/boot-id-watcher-mount.tsx` | **新增** | Client wrapper 掛 `useBootIdWatcher()` + `useShutdownWatcher()` |
|
|
||||||
| `frontend/src/app/layout.tsx` | **修改** | 在 root layout 掛 `<ServerOfflineOverlay />` 和 `<BootIdWatcherMount />` |
|
|
||||||
| `frontend/src/lib/i18n/types.ts` | **修改** | 新增 `serverOffline` 區塊的 i18n key 定義(含 3 種 reason 文案)|
|
|
||||||
| `frontend/src/lib/i18n/zh-TW.ts` | **修改** | 新增繁體中文字串 |
|
|
||||||
| `frontend/src/lib/i18n/en.ts` | **修改** | 新增英文字串 |
|
|
||||||
|
|
||||||
### 4.2 `frontend/src/stores/system-store.ts`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
interface SystemState {
|
|
||||||
serverOnline: boolean;
|
|
||||||
bootId: string | null;
|
|
||||||
consecutiveFailures: number;
|
|
||||||
lastProbeAt: number | null;
|
|
||||||
// Minor 4:由 WebSocket shutdown-imminent 事件強制設定,讓 overlay 立即顯示
|
|
||||||
forcedOffline: boolean;
|
|
||||||
forcedOfflineReason: 'app-closing' | 'manual-stop' | 'restart' | 'quit' | null;
|
|
||||||
|
|
||||||
setOnline: (bootId: string) => void;
|
|
||||||
recordFailure: () => void;
|
|
||||||
shouldShowOverlay: () => boolean;
|
|
||||||
resetFailures: () => void;
|
|
||||||
forceOffline: (reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit') => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FAILURE_THRESHOLD = 2; // v2.1 定版:2 次失敗(約 20 s)即觸發
|
|
||||||
|
|
||||||
export const useSystemStore = create<SystemState>((set, get) => ({
|
|
||||||
serverOnline: true,
|
|
||||||
bootId: null,
|
|
||||||
consecutiveFailures: 0,
|
|
||||||
lastProbeAt: null,
|
|
||||||
forcedOffline: false,
|
|
||||||
forcedOfflineReason: null,
|
|
||||||
|
|
||||||
setOnline: (bootId) => set({
|
|
||||||
serverOnline: true,
|
|
||||||
bootId,
|
|
||||||
consecutiveFailures: 0,
|
|
||||||
lastProbeAt: Date.now(),
|
|
||||||
// 注意:recover 時不自動清 forcedOffline,要走 resetFailures(使用者點「重試」)
|
|
||||||
}),
|
|
||||||
|
|
||||||
recordFailure: () => set((s) => ({
|
|
||||||
consecutiveFailures: s.consecutiveFailures + 1,
|
|
||||||
serverOnline: s.consecutiveFailures + 1 < FAILURE_THRESHOLD,
|
|
||||||
lastProbeAt: Date.now(),
|
|
||||||
})),
|
|
||||||
|
|
||||||
shouldShowOverlay: () => {
|
|
||||||
const s = get();
|
|
||||||
return s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Active retry 模式:overlay 顯示中時,前端自動切 3 s interval 重試
|
|
||||||
isActiveRetryMode: () => {
|
|
||||||
const s = get();
|
|
||||||
return s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD;
|
|
||||||
},
|
|
||||||
|
|
||||||
resetFailures: () => set({
|
|
||||||
consecutiveFailures: 0,
|
|
||||||
serverOnline: true,
|
|
||||||
forcedOffline: false,
|
|
||||||
forcedOfflineReason: null,
|
|
||||||
}),
|
|
||||||
|
|
||||||
forceOffline: (reason) => {
|
|
||||||
// Minor 4:restart 情境不立即顯示 overlay,給 10 s 等 boot-id 變化 reload
|
|
||||||
if (reason === 'restart') {
|
|
||||||
setTimeout(() => {
|
|
||||||
const s = get();
|
|
||||||
if (s.consecutiveFailures < FAILURE_THRESHOLD && s.bootId !== null) {
|
|
||||||
// 10 s 內 server 沒回來才強制顯示
|
|
||||||
set({ forcedOffline: true, forcedOfflineReason: 'restart' });
|
|
||||||
}
|
|
||||||
}, 10_000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
set({ forcedOffline: true, forcedOfflineReason: reason });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 `frontend/src/hooks/use-boot-id-watcher.ts`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useSystemStore } from '@/stores/system-store';
|
|
||||||
import { getBackendUrl } from '@/lib/api';
|
|
||||||
|
|
||||||
const POLL_INTERVAL_NORMAL_MS = 10_000; // v2.1 定版:正常模式 10 s
|
|
||||||
const POLL_INTERVAL_ACTIVE_RETRY_MS = 3_000; // v2.1 定版:overlay 顯示後自動每 3 s 重試
|
|
||||||
|
|
||||||
interface BootIdResponse {
|
|
||||||
success: boolean;
|
|
||||||
data: {
|
|
||||||
bootId: string;
|
|
||||||
startedAt: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollOnce(): Promise<void> {
|
|
||||||
const store = useSystemStore.getState();
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${getBackendUrl()}/api/system/boot-id`, {
|
|
||||||
cache: 'no-store',
|
|
||||||
// 短 timeout:若 server 假死要快速失敗
|
|
||||||
signal: AbortSignal.timeout(3000),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
store.recordFailure();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const json = (await res.json()) as BootIdResponse;
|
|
||||||
if (!json.success || !json.data?.bootId) {
|
|
||||||
store.recordFailure();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newBootId = json.data.bootId;
|
|
||||||
if (store.bootId !== null && store.bootId !== newBootId) {
|
|
||||||
// Server 重啟:force reload
|
|
||||||
window.location.reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
store.setOnline(newBootId);
|
|
||||||
} catch {
|
|
||||||
store.recordFailure();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBootIdWatcher(): void {
|
|
||||||
useEffect(() => {
|
|
||||||
let intervalHandle: number | null = null;
|
|
||||||
let currentMode: 'normal' | 'active-retry' = 'normal';
|
|
||||||
|
|
||||||
const intervalFor = (mode: 'normal' | 'active-retry') =>
|
|
||||||
mode === 'active-retry' ? POLL_INTERVAL_ACTIVE_RETRY_MS : POLL_INTERVAL_NORMAL_MS;
|
|
||||||
|
|
||||||
const start = (mode: 'normal' | 'active-retry') => {
|
|
||||||
if (intervalHandle !== null && currentMode === mode) return;
|
|
||||||
if (intervalHandle !== null) clearInterval(intervalHandle);
|
|
||||||
currentMode = mode;
|
|
||||||
intervalHandle = window.setInterval(pollOnce, intervalFor(mode));
|
|
||||||
};
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
if (intervalHandle !== null) {
|
|
||||||
clearInterval(intervalHandle);
|
|
||||||
intervalHandle = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 監聽 store 變化,overlay 顯示 ↔ 隱藏時切換 polling 模式
|
|
||||||
const unsubscribe = useSystemStore.subscribe((state) => {
|
|
||||||
const shouldActiveRetry = state.forcedOffline || state.consecutiveFailures >= 2;
|
|
||||||
const targetMode = shouldActiveRetry ? 'active-retry' : 'normal';
|
|
||||||
if (intervalHandle !== null && targetMode !== currentMode) {
|
|
||||||
start(targetMode); // 自動切換 interval
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const onVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
void pollOnce(); // 立即 probe
|
|
||||||
const s = useSystemStore.getState();
|
|
||||||
const mode = s.forcedOffline || s.consecutiveFailures >= 2 ? 'active-retry' : 'normal';
|
|
||||||
start(mode);
|
|
||||||
} else {
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初次載入時執行一次 probe + 啟動 normal 輪詢
|
|
||||||
void pollOnce();
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
start('normal');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
||||||
unsubscribe();
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3a `frontend/src/hooks/use-shutdown-watcher.ts`(Minor 4 新增)
|
|
||||||
|
|
||||||
訂閱 server 的 WebSocket 廣播,接收 `server:shutdown-imminent` 事件後立即設 `forcedOffline`。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useSystemStore } from '@/stores/system-store';
|
|
||||||
import { getBackendUrl } from '@/lib/api';
|
|
||||||
|
|
||||||
interface ShutdownImminentMessage {
|
|
||||||
type: 'server:shutdown-imminent';
|
|
||||||
reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit';
|
|
||||||
ts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useShutdownWatcher(): void {
|
|
||||||
useEffect(() => {
|
|
||||||
const wsUrl = getBackendUrl().replace(/^http/, 'ws') + '/ws/system';
|
|
||||||
let ws: WebSocket | null = null;
|
|
||||||
let reconnectTimer: number | null = null;
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
try {
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
} catch {
|
|
||||||
scheduleReconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(String(ev.data));
|
|
||||||
if (msg?.type === 'server:shutdown-imminent') {
|
|
||||||
const reason = (msg as ShutdownImminentMessage).reason;
|
|
||||||
useSystemStore.getState().forceOffline(reason);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 非 JSON 或格式錯誤,忽略
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onclose = (ev) => {
|
|
||||||
ws = null;
|
|
||||||
// v2.1:WebSocket close 事件也視為 server 已斷,立即顯示 overlay
|
|
||||||
// (比等 polling 失敗 20 s 更即時;若 server 正常重啟,成功 poll 後會自動清 overlay)
|
|
||||||
// 只在「曾經連上」的前提下才觸發,避免初次連線就失敗導致誤觸發
|
|
||||||
if (ev && ev.wasClean === false) {
|
|
||||||
const reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit' = 'manual-stop';
|
|
||||||
useSystemStore.getState().forceOffline(reason);
|
|
||||||
}
|
|
||||||
scheduleReconnect();
|
|
||||||
};
|
|
||||||
ws.onerror = () => {
|
|
||||||
// 忽略 — 後續 onclose 會處理
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
|
||||||
if (reconnectTimer !== null) return;
|
|
||||||
reconnectTimer = window.setTimeout(() => {
|
|
||||||
reconnectTimer = null;
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (reconnectTimer !== null) {
|
|
||||||
clearTimeout(reconnectTimer);
|
|
||||||
reconnectTimer = null;
|
|
||||||
}
|
|
||||||
if (ws) {
|
|
||||||
ws.onclose = null;
|
|
||||||
ws.onerror = null;
|
|
||||||
ws.close();
|
|
||||||
ws = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**server 端配合(server-lifecycle.md §8)**:`ctrl.Stop()` 開始時透過 server 的 WebSocket hub(建議新增 `/ws/system` endpoint 或沿用現有 `/ws/server-logs`)廣播該事件。
|
|
||||||
|
|
||||||
**注意**:此 hook 的 WebSocket 是從瀏覽器連到 server,與 Wails IPC 無關。Wails 端只需要在 `ctrl.Stop()` 時透過 HTTP / gRPC / 直接從 server 內呼叫 `hub.Broadcast(...)` 即可(具體做法視 server 內部 API 而定)。
|
|
||||||
|
|
||||||
### 4.4 `frontend/src/components/server-offline-overlay.tsx`
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useSystemStore } from '@/stores/system-store';
|
|
||||||
import { useTranslation } from '@/lib/i18n';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
async function retryOnce() {
|
|
||||||
// 重試 = 立即打一次 boot-id,若成功會自動清 failures
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/system/boot-id', { cache: 'no-store' });
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json();
|
|
||||||
if (json.success && json.data?.bootId) {
|
|
||||||
useSystemStore.getState().setOnline(json.data.bootId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useSystemStore.getState().recordFailure();
|
|
||||||
} catch {
|
|
||||||
useSystemStore.getState().recordFailure();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServerOfflineOverlay() {
|
|
||||||
const show = useSystemStore((s) => s.forcedOffline || s.consecutiveFailures >= 2);
|
|
||||||
const reason = useSystemStore((s) => s.forcedOfflineReason);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!show) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alertdialog"
|
|
||||||
aria-labelledby="server-offline-title"
|
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/65 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-2xl">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<AlertTriangle className="h-8 w-8 shrink-0 text-amber-500" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<h2 id="server-offline-title" className="mb-2 text-xl font-semibold">
|
|
||||||
{t('serverOffline.title')}
|
|
||||||
</h2>
|
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
|
||||||
{t('serverOffline.body')}
|
|
||||||
</p>
|
|
||||||
<ul className="mb-4 list-disc pl-5 text-sm text-muted-foreground">
|
|
||||||
<li>{t('serverOffline.reasons.appClosed')}</li>
|
|
||||||
<li>{t('serverOffline.reasons.manuallyStopped')}</li>
|
|
||||||
<li>{t('serverOffline.reasons.crashed')}</li>
|
|
||||||
</ul>
|
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
|
||||||
{t('serverOffline.instruction')}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={retryOnce}>{t('serverOffline.retry')}</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => window.close()}
|
|
||||||
>
|
|
||||||
{t('serverOffline.closeTab')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.5 `frontend/src/app/layout.tsx` 修改
|
|
||||||
|
|
||||||
```diff
|
|
||||||
+import { ServerOfflineOverlay } from '@/components/server-offline-overlay';
|
|
||||||
+import { BootIdWatcherMount } from '@/components/boot-id-watcher-mount';
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<html ...>
|
|
||||||
<body ...>
|
|
||||||
{/* 既有的 providers / sidebar / theme 等 */}
|
|
||||||
{children}
|
|
||||||
+ <BootIdWatcherMount />
|
|
||||||
+ <ServerOfflineOverlay />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`BootIdWatcherMount` 是一個空的 `'use client'` 元件,作用只是掛 `useBootIdWatcher()`(hook 不能直接在 layout 的 server component 呼叫):
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// frontend/src/components/boot-id-watcher-mount.tsx
|
|
||||||
'use client';
|
|
||||||
import { useBootIdWatcher } from '@/hooks/use-boot-id-watcher';
|
|
||||||
import { useShutdownWatcher } from '@/hooks/use-shutdown-watcher';
|
|
||||||
|
|
||||||
export function BootIdWatcherMount() {
|
|
||||||
useBootIdWatcher();
|
|
||||||
useShutdownWatcher(); // Minor 4:WebSocket shutdown-imminent 訂閱
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6 i18n 字串
|
|
||||||
|
|
||||||
**`frontend/src/lib/i18n/types.ts`** 新增:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
serverOffline: {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
reasons: {
|
|
||||||
appClosed: string;
|
|
||||||
manuallyStopped: string;
|
|
||||||
crashed: string;
|
|
||||||
};
|
|
||||||
instruction: string;
|
|
||||||
retry: string;
|
|
||||||
closeTab: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**`frontend/src/lib/i18n/zh-TW.ts`**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
serverOffline: {
|
|
||||||
title: 'Server 已離線',
|
|
||||||
body: 'visionA Local 的本機伺服器沒有回應。可能的原因:',
|
|
||||||
reasons: {
|
|
||||||
appClosed: '你關閉了 visionA Local 應用程式',
|
|
||||||
manuallyStopped: 'Server 被手動停止',
|
|
||||||
crashed: 'Server 發生錯誤',
|
|
||||||
},
|
|
||||||
instruction: '請重新開啟 visionA Local 應用程式後按「重試」。',
|
|
||||||
retry: '重試',
|
|
||||||
closeTab: '關閉這個頁面',
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
**`frontend/src/lib/i18n/en.ts`**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
serverOffline: {
|
|
||||||
title: 'Server Offline',
|
|
||||||
body: 'The visionA Local server is not responding. Possible reasons:',
|
|
||||||
reasons: {
|
|
||||||
appClosed: 'You closed the visionA Local app',
|
|
||||||
manuallyStopped: 'Server was manually stopped',
|
|
||||||
crashed: 'Server encountered an error',
|
|
||||||
},
|
|
||||||
instruction: 'Please relaunch visionA Local and click "Retry".',
|
|
||||||
retry: 'Retry',
|
|
||||||
closeTab: 'Close this tab',
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. SSR 相容性
|
|
||||||
|
|
||||||
Next.js 的 `static export` 會在 build 階段把頁面 render 成 HTML。以下程式碼都必須處理 SSR(`typeof window === 'undefined'` 情境):
|
|
||||||
|
|
||||||
- `use-boot-id-watcher.ts` — 標 `'use client'`,只在瀏覽器 run,OK
|
|
||||||
- `server-offline-overlay.tsx` — 標 `'use client'`,OK
|
|
||||||
- `system-store.ts` — Zustand 不需 `'use client'`,但 store 只被 client component 使用,OK
|
|
||||||
- `boot-id-watcher-mount.tsx` — `'use client'`,OK
|
|
||||||
|
|
||||||
**驗收**:`pnpm build` 在 frontend/ 目錄下要成功,沒有 `ReferenceError: window is not defined` 或其他 SSR 錯誤。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 與 ServerController 事件的關係(v2.1 更新)
|
|
||||||
|
|
||||||
Wails 控制台的 log panel 會收到 `server:state-change` 事件並更新狀態卡片;**瀏覽器 tab 無法收到 Wails event**(不同 context)。瀏覽器的真相來源有三個,優先序由高到低:
|
|
||||||
|
|
||||||
1. **WebSocket `server:shutdown-imminent`(最即時,v2.1 Minor 4 新增)**:Wails 的 `ctrl.Stop()` 在開始 SIGTERM 前透過 server 的 WebSocket hub 廣播,瀏覽器 tab 幾乎同時收到 → 立即顯示 overlay
|
|
||||||
2. **`GET /api/system/boot-id` polling**:
|
|
||||||
- 成功 + bootId 變 → `window.location.reload()`(server 重啟後同 port)
|
|
||||||
- 連續 2 次失敗 → 顯示 overlay(fallback,當 WebSocket 已斷或未建立)
|
|
||||||
3. **業務 API 呼叫的 network error**:次要,不當作 overlay 觸發器(避免單一 API 問題誤觸發)
|
|
||||||
|
|
||||||
Wails → server → WebSocket → 瀏覽器 的路徑:Wails 只與 server 進程通訊(透過 stdin/stdout 或 HTTP),server 再轉發到 WebSocket hub 廣播給所有連線 tab。這是單向 push,比 polling 更即時。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 驗收條件
|
|
||||||
|
|
||||||
| 檢查 | 操作 | 預期 |
|
|
||||||
|------|------|------|
|
|
||||||
| 正常啟動沒 overlay | 開 app → 點 Open in Browser → tab 載入 | overlay 不顯示 |
|
|
||||||
| 關 Wails → 立即顯示 overlay(WebSocket 管道)| 開 app → Open in Browser → 關 Wails 視窗 | < 1 s 內 tab 顯示 overlay(WebSocket `shutdown-imminent` 或 `onclose` 觸發)|
|
|
||||||
| 關 Wails → polling fallback | 同上但 WebSocket 未建立(mock) | 20 s 內 tab 顯示 overlay(連續 2 次 10 s poll 失敗)|
|
|
||||||
| 關 Wails → 瀏覽器 tab 顯示正確訊息 | 同上 | 看到 title「Server 已離線」 |
|
|
||||||
| Active retry 模式自動重試 | Overlay 顯示後 | 前端自動每 3 s 打 boot-id(非使用者手動按)|
|
|
||||||
| Overlay 重試按鈕 | server 關著時點「重試」 | failures +1 或維持(視結果),overlay 仍在 |
|
|
||||||
| 重試 → 成功 | 關 app → 等 overlay → 重開 app | Active retry 3 s 內自動 dismiss overlay;若 bootId 變則 reload |
|
|
||||||
| 控制台按 Restart → 自動 reload | server running → 在 Wails 按 Restart → 3 s 後 server 回來 | tab 自動 `window.location.reload()`,載入後正常;期間不顯示 overlay(`restart` reason 延遲 10 s)|
|
|
||||||
| 控制台按 Stop → 立即 overlay | Stop | WebSocket 廣播 `manual-stop` → 立即顯示 overlay |
|
|
||||||
| 背景 tab 不浪費資源 | 切到別的 tab → `chrome://performance-metrics` | tab 不在時 setInterval 停(visibility hidden) |
|
|
||||||
| 切回背景 tab → 立即 probe | 切回 tab | 立即打 boot-id,不等 10 s interval |
|
|
||||||
| 網路 glitch < 10 s 不顯示 | 人工用 DevTools Network → Offline 8 s → Online | overlay 不顯示(最多 1 次失敗,未達 2 次門檻) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 待確認
|
|
||||||
|
|
||||||
1. **`window.close()` 在非 `window.open()` 開的 tab 不能用** — 使用者從 Wails Open in Browser 進來的 tab 確實是 browser 自己開的,不是 `window.open`,Chrome 會拒絕 `window.close()`。備案:把「關閉這個頁面」按鈕改為顯示「請手動關閉此分頁」提示。M8-7 執行者實測後決定 UI 文案。
|
|
||||||
2. **boot-id cache header** — 目前用 `cache: 'no-store'`,但某些 Chrome 版本會忽略。若發現 polling 拿到快取值(bootId 不變),改加 `?_=Date.now()` query 防快取。
|
|
||||||
3. **Overlay z-index 衝突** — 9999 高於 shadcn Dialog 的 50,應夠。若未來有其他「更上層」元件要放,確認 z-index layering。
|
|
||||||
@ -1,326 +0,0 @@
|
|||||||
# Reviewer 審查 M8-1 + M8-2 合併結果(2026-04-15)
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
- **總結論**:✅ **通過**(零 Major / 零 Critical;只有若干 Minor 提醒)
|
|
||||||
- **M8-1(砍 yt-dlp)**:✅ 完整砍除,8 項 deletions.md 條目全達成
|
|
||||||
- **M8-2(砍 Mock)**:✅ 完整砍除,11 項 deletions.md 條目全達成
|
|
||||||
- **是否阻擋 M8-4**:❌ 不阻擋。M8-4 可以開動(mockMode field 已清、videoIsURL 已清、StartFromURL 已清,M8-4 做 server lifecycle 時無衝突)
|
|
||||||
- **額外觀察**:working tree 同時包含 **M8-3(ffmpeg LGPL 切換)** 的改動(Makefile / README / installer / scripts / build.yml),該部分依使用者指示**不在本次 review 範圍**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 砍除完整性
|
|
||||||
|
|
||||||
### A1. M8-1 yt-dlp 砍除清單
|
|
||||||
|
|
||||||
| # | 項目 | 預期 | 實測 | 證據 |
|
|
||||||
|---|------|------|------|------|
|
|
||||||
| 1 | `ResolveWithYTDLP` function | 消失 | ✅ | `video_source.go` diff -91..-140 整段 delete |
|
|
||||||
| 2 | `friendlyYTDLPError` function | 消失 | ✅ | 同上 |
|
|
||||||
| 3 | `NewVideoSourceFromURL` / `...WithSeek` | 消失 | ✅ | `video_source.go` diff -79..-86 |
|
|
||||||
| 4 | `VideoSource.isURL` field + `newVideoSource` 的 `isURL` 參數 | 消失 | ✅ | `video_source.go` L63/L68 簽名已簡化 |
|
|
||||||
| 5 | `ytdlpHosts` map | 消失 | ✅ | `camera_handler.go` -341..-373 |
|
|
||||||
| 6 | `urlKind` enum / `urlYTDLP` / `urlDirect` / `urlBad` | 消失 | ✅ | 同上 |
|
|
||||||
| 7 | `classifyVideoURL()` function | 消失 | ✅ | 同上 |
|
|
||||||
| 8 | `StartFromURL` handler | 消失 | ✅ | -408..-497 整段 delete |
|
|
||||||
| 9 | `CameraHandler.videoIsURL` field | 消失 | ✅ | `camera_handler.go` L33/L303/L536 三處清除 |
|
|
||||||
| 10 | `handleVideoSeek` 的 `videoIsURL` 分支 | 消失 | ✅ | L566 簡化為直接呼叫 `NewVideoSourceWithSeek` |
|
|
||||||
| 11 | `/media/url` route | 消失 | ✅ | `router.go:83` delete |
|
|
||||||
| 12 | `deps/checker.go` yt-dlp check | 消失 | ✅ | `checker.go` -30..-32 |
|
|
||||||
| 13 | `main.go` 註解改 ffmpeg/ffprobe | ✅ | L88 | 註解已改 |
|
|
||||||
| 14 | frontend `startFromUrl` function | 消失 | ✅ | `camera-store.ts` -167..-196 |
|
|
||||||
| 15 | frontend `videoMode` / `videoUrl` state | 消失 | ✅ | `source-selector.tsx` -51..-52 |
|
|
||||||
| 16 | frontend `handleUrlSubmit` function | 消失 | ✅ | `source-selector.tsx` -94..-96 |
|
|
||||||
| 17 | frontend URL tab UI(mode toggle + input + help) | 消失 | ✅ | `source-selector.tsx` -183..-253 |
|
|
||||||
| 18 | i18n `pasteUrl` / `urlPlaceholder` / `urlHelpText` / `cannotOpenVideoUrl` | 消失 | ✅ | `types.ts` / `zh-TW.ts` / `en.ts` 三檔同步 |
|
|
||||||
| 19 | i18n `camera.uploadFile`(mode 切換用) | 消失 | ✅ | M8-1 自補:三檔同步刪(本來 deletions.md 沒列,但砍完 mode toggle 就 orphan) |
|
|
||||||
| 20 | Makefile `vendor-ytdlp` / `vendor-ytdlp-windows` / `vendor-ytdlp-linux` | 消失 | ✅ | `Makefile` 全檔無 `yt-dlp` / `ytdlp` / `YTDLP` 字樣 |
|
|
||||||
| 21 | installer/windows visiona-local.iss yt-dlp.exe 行 | 消失 | ✅ | L76 已 delete |
|
|
||||||
| 22 | installer/linux build-appimage.sh yt-dlp loop | 消失 | ✅ | L61 `for tool in ffmpeg ffprobe` |
|
|
||||||
| 23 | scripts/bootstrap-linux.sh `vendor-ytdlp-linux` | 消失 | ✅ | L57 |
|
|
||||||
| 24 | scripts/bootstrap-windows.ps1 `vendor-ytdlp-windows` | 消失 | ✅ | L200 |
|
|
||||||
| 25 | visiona-local/app.go `locateBundleBinDir` 的 `yt-dlp` 字串 | 改為 `ffprobe` | ✅ | `app.go:871` fileExists("ffprobe") |
|
|
||||||
| 26 | visiona-local/app.go `startServer` 註解「ffmpeg / yt-dlp」 | 改為 `ffmpeg / ffprobe` | ✅ | L480 |
|
|
||||||
| 27 | README.md yt-dlp 三處敘述 | 消失 | ✅ | L18, L74, L128, L131, L179 五處清除(M8-1 自補) |
|
|
||||||
| 28 | .github/workflows/build.yml `vendor-ytdlp-*` | 消失 | ✅ | L135/L233 |
|
|
||||||
|
|
||||||
**A1 結論**:M8-1 砍除 **100% 達成**,且主動自補了 4 個 deletions.md 未明列的位置(README 五處、workflow、app.go bundle dir、i18n uploadFile orphan key)。
|
|
||||||
|
|
||||||
### A2. M8-2 Mock 砍除清單
|
|
||||||
|
|
||||||
| # | 項目 | 預期 | 實測 | 證據 |
|
|
||||||
|---|------|------|------|------|
|
|
||||||
| 1 | `server/internal/driver/mock/mock_driver.go` | 整檔刪 | ✅ | `git status: deleted` |
|
|
||||||
| 2 | `server/internal/driver/mock/` 目錄 | 刪 | ✅ | `find server/internal/driver/mock` 無 |
|
|
||||||
| 3 | `server/internal/camera/mock_camera.go` | 整檔刪 | ✅ | `git status: deleted` |
|
|
||||||
| 4 | `VISIONA_MOCK` env 偵測 | 消失 | ✅ | `grep -r VISIONA_MOCK` 0 match |
|
|
||||||
| 5 | `config.MockMode` / `MockCamera` / `MockDeviceCount` 欄位 | 消失 | ✅ | `config.go` L19..L26 簡化 |
|
|
||||||
| 6 | `--mock` / `--mock-camera` / `--mock-devices` flag | 消失 | ✅ | `config.go` L36..L38 已刪 |
|
|
||||||
| 7 | `device.Manager.mockMode` field | 消失 | ✅ | `device/manager.go` L17 |
|
|
||||||
| 8 | `NewManager(registry, mockMode, mockCount, scriptPath)` 簽名簡化 | `NewManager(registry, scriptPath)` | ✅ | `device/manager.go` L22 |
|
|
||||||
| 9 | `device/manager.go` 所有 `if m.mockMode {}` 分支 | 消失 | ✅ | `Start()` / `Rescan()` 已簡化 |
|
|
||||||
| 10 | `mockdriver` import | 消失 | ✅ | `device/manager.go` import 區已清 |
|
|
||||||
| 11 | `camera.Manager.mockMode` / `mockCamera` field | 消失 | ✅ | `camera/manager.go` L16..L19 |
|
|
||||||
| 12 | `camera.NewManager(mockMode)` → `NewManager()` | 簡化 | ✅ | `camera/manager.go` L23 |
|
|
||||||
| 13 | `camera.Manager.ListCameras/Open/Close/ReadFrame` mock 分支 | 消失 | ✅ | `camera/manager.go` L29-82 |
|
|
||||||
| 14 | `main.go` `Mock mode:` log | 消失 | ✅ | `main.go:86` 已改為 `Dev mode: %v, Python mode: %s` |
|
|
||||||
| 15 | `main.go` `NewManager(registry, cfg.MockMode, ...)` 呼叫 | 簡化 | ✅ | `main.go:141` |
|
|
||||||
| 16 | `main.go` `camera.NewManager(cfg.MockCamera)` 呼叫 | 簡化 | ✅ | `main.go:146` |
|
|
||||||
| 17 | `visiona-local/app.go` `mockMode` field | 消失 | ✅ | `app.go:82` 已清除 |
|
|
||||||
| 18 | `visiona-local/app.go` `NewApp()` 讀 `VISIONA_MOCK` env | 消失 | ✅ | `app.go:117` 替換為 R5-5a 註解 |
|
|
||||||
| 19 | `visiona-local/app.go` `startServer` 所有 `a.mockMode` 分支 | 消失 | ✅ | L425 / L435 / L455-465 / L488 皆清除 |
|
|
||||||
| 20 | frontend `settings/page.tsx` runtimeMode Select + Separator | 消失 | ✅ | `page.tsx` -140..-158 |
|
|
||||||
| 21 | frontend i18n `runtimeMode` / `...Mock` / `...Real` / `...Hint` | 消失 | ✅ | `types.ts` / `zh-TW.ts` / `en.ts` L270..L277 同步 |
|
|
||||||
| 22 | frontend i18n `noDevices` 文字去 Mock | 改為 R5-v2-7 empty state | ✅ | 兩語言都改為「請連接 KL520 / KL720 後按掃描」 |
|
|
||||||
| 23 | Makefile `dev-mock` target | 消失 | ✅ | `Makefile` 無 `dev-mock` |
|
|
||||||
| 24 | README Mock 段落 | 消失 | ✅ | L73 「Mock 模式」bullet 整行刪、L24 零學習成本 bullet 改為「插上 Kneron 裝置 30 秒」 |
|
|
||||||
| 25 | `manager_test.go` `TestNewManager_MockMode` | 消失 | ✅ | 已刪 |
|
|
||||||
| 26 | `manager_test.go` `TestManager_ListDevices_Empty` 新增 | 新增 | ✅ | L39..L48,R5-5a 紀念註解合法 |
|
|
||||||
| 27 | `api_e2e_test.go` 整檔刪 | 刪 | ✅ | `git status: deleted` |
|
|
||||||
| 28 | `camera-controls.tsx` `'mock-cam-0'` fallback | 改為 `disabled={cameras.length === 0}` | ✅ | L22..L28 已改 |
|
|
||||||
|
|
||||||
**A2 結論**:M8-2 砍除 **100% 達成**,且主動自補了 `api_e2e_test.go` 整檔刪、`manager_test.go` 新增空 list 測試、`camera-controls.tsx` fallback 修正。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 誤刪檢查(保留功能 red line)
|
|
||||||
|
|
||||||
| 保留項目 | 狀態 | 證據 |
|
|
||||||
|---------|------|------|
|
|
||||||
| `POST /api/camera/start` | ✅ 保留 | router gin-debug 有 `/api/camera/start` |
|
|
||||||
| `POST /api/camera/stop` | ✅ 保留 | 同上 |
|
|
||||||
| `POST /api/media/upload/image` | ✅ 保留 | 同上 |
|
|
||||||
| `POST /api/media/upload/video`(本機檔案,非 URL) | ✅ 保留 | 同上 |
|
|
||||||
| `POST /api/media/upload/batch-images` | ✅ 保留 | 同上 |
|
|
||||||
| `GET /api/media/batch-images/:index` | ✅ 保留 | 同上 |
|
|
||||||
| `POST /api/media/seek`(本機影片 seek) | ✅ 保留 | 同上 |
|
|
||||||
| `GET /api/camera/stream` | ✅ 保留 | 同上 |
|
|
||||||
| `GET /api/devices`(無硬體回空 list,非 mock) | ✅ 保留 | smoke test:`{"devices":[]}` |
|
|
||||||
| 模型管理 `/api/models*` | ✅ 保留 | router gin-debug 完整 |
|
|
||||||
| WebSocket `/ws/devices/:id/inference` | ✅ 保留 | router gin-debug |
|
|
||||||
| `VideoSource` + `NewVideoSource` + `NewVideoSourceWithSeek` | ✅ 保留 | `video_source.go` L70/L75 |
|
|
||||||
| `ProbeVideoInfo` | ✅ 保留 | M8-1 未觸碰 |
|
|
||||||
| `handleVideoSeek` 本地影片分支 | ✅ 保留 | `camera_handler.go:566` |
|
|
||||||
| `stopActivePipeline` | ✅ 保留 | L532 |
|
|
||||||
| `ffmpeg` 本身 | ✅ 保留 | deps checker + PATH 注入都還在 |
|
|
||||||
|
|
||||||
**B 結論**:**零誤刪**。所有 R5 要求保留的功能完整存在。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. 殘留 grep 結果
|
|
||||||
|
|
||||||
命令(排除 `.autoflow/` / `.git/` / `vendor/` / `node_modules/`):
|
|
||||||
|
|
||||||
### C1. yt-dlp 組
|
|
||||||
|
|
||||||
```
|
|
||||||
grep -rn 'yt-dlp\|ytdlp\|YTDLP\|ResolveWithYTDLP\|StartFromURL\|ytdlpHosts\|urlYTDLP\|videoIsURL\|startFromUrl\|pasteUrl\|urlPlaceholder\|urlHelpText'
|
|
||||||
```
|
|
||||||
|
|
||||||
**結果**:**0 match** ✅
|
|
||||||
|
|
||||||
### C2. Mock 組(嚴格:排除合法測試框架用詞)
|
|
||||||
|
|
||||||
```
|
|
||||||
grep -rn 'VISIONA_MOCK\|MockMode\|MockCamera\|NewMockDriver\|NewMockCamera\|mockdriver\|mock_camera\|runtimeModeMock\|runtimeModeReal\|mock-cam-0'
|
|
||||||
```
|
|
||||||
|
|
||||||
**結果**:**0 match** ✅
|
|
||||||
|
|
||||||
### C3. 寬鬆 Mock 組(含一般 `mock` 字樣)
|
|
||||||
|
|
||||||
`server/internal/` 下只剩:
|
|
||||||
- `server/internal/device/manager_test.go:43` — `// 無硬體時應回傳空 list(R5-5a:沒插硬體就空白,不給 Mock 資料)` ← **合法紀念註解**
|
|
||||||
|
|
||||||
`frontend/src/tests/` 下的 `vi.mock*` / `mockResolvedValue` / `clearAllMocks` — **合法 Vitest API**
|
|
||||||
|
|
||||||
`visiona-local/` / `server/main.go` / `server/internal/camera/` / `server/internal/driver/` 下 — **0 match**
|
|
||||||
|
|
||||||
**C 結論**:grep 完全 clean。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Build / test 結果
|
|
||||||
|
|
||||||
### D1. `cd server && go build ./...`
|
|
||||||
|
|
||||||
✅ **PASS**(無輸出)
|
|
||||||
|
|
||||||
### D2. `cd server && go vet ./...`
|
|
||||||
|
|
||||||
✅ **PASS**(無輸出)
|
|
||||||
|
|
||||||
### D3. `cd server && go test ./...`
|
|
||||||
|
|
||||||
✅ **PASS**
|
|
||||||
|
|
||||||
```
|
|
||||||
ok visiona-local/server/internal/device (cached)
|
|
||||||
ok visiona-local/server/internal/model (cached)
|
|
||||||
```
|
|
||||||
|
|
||||||
其餘 package 顯示 `[no test files]`,符合預期(本次沒有新增 unit test)。
|
|
||||||
|
|
||||||
### D4. `cd visiona-local && go build .`
|
|
||||||
|
|
||||||
✅ **PASS**(無輸出)
|
|
||||||
|
|
||||||
### D5. `cd frontend && pnpm build`
|
|
||||||
|
|
||||||
✅ **PASS** — `Compiled successfully in 4.2s`,TypeScript 通過,12 頁 SSG 生成完成。
|
|
||||||
|
|
||||||
**D 結論**:五項 build / test 全綠。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Smoke test 結果
|
|
||||||
|
|
||||||
啟動 `go run . --port 3722`,等 3 秒:
|
|
||||||
|
|
||||||
| 測試 | 結果 | 備註 |
|
|
||||||
|------|------|------|
|
|
||||||
| Server 啟動成功 | ✅ | 看到 `Server listening on 127.0.0.1:3722` |
|
|
||||||
| log 無 `Mock mode:` | ✅ | log 只剩 `Dev mode: false, Python mode: auto` |
|
|
||||||
| log 無 `yt-dlp:` | ✅ | deps check 只有 `ffmpeg` 和 `python3` |
|
|
||||||
| `GET /api/devices` | ✅ 200 | `{"data":{"devices":[]},"success":true}` — 空 list,非 mock 資料 |
|
|
||||||
| `POST /api/media/url` | ✅ 404 | Route 已砍,gin 回 404(符合預期) |
|
|
||||||
| router gin-debug 印出 | ✅ | 完全沒有 `StartFromURL`,只有 `UploadVideo / UploadImage / UploadBatchImages / SeekVideo / ListCameras / StartPipeline / StopPipeline / StreamMJPEG` |
|
|
||||||
| Kneron 偵測 | ✅ | `No Kneron devices detected` — 走真實硬體 path(機器上沒插就空 list) |
|
|
||||||
|
|
||||||
**E 結論**:Runtime 行為完全符合 R5-5a / R5-7 預期。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. Edge case 自補合理性
|
|
||||||
|
|
||||||
### F1. M8-1 自補
|
|
||||||
|
|
||||||
| 自補項 | 合理性 |
|
|
||||||
|--------|-------|
|
|
||||||
| README 清 yt-dlp 五處 + 改「零學習成本」文字 | ✅ 合理,deletions.md 未列但必然要改 |
|
|
||||||
| `.github/workflows/build.yml` 清 `vendor-ytdlp-*` 兩處 | ✅ 合理,否則 CI build 會叫不存在的 target |
|
|
||||||
| `visiona-local/app.go:locateBundleBinDir` 的 `fileExists("yt-dlp")` → `fileExists("ffprobe")` | ✅ 合理。ffprobe 會隨 M8-3 進 bundle bin dir,用 ffprobe 當 sentinel 比 ffmpeg 更精準(避免和 system ffmpeg 混淆) |
|
|
||||||
| 刪 `camera.uploadFile` i18n key | ✅ 合理,URL tab 砍後這個 key 變 dead key |
|
|
||||||
|
|
||||||
### F2. M8-2 自補
|
|
||||||
|
|
||||||
| 自補項 | 合理性 |
|
|
||||||
|--------|-------|
|
|
||||||
| `api_e2e_test.go` 整檔刪 | ✅ 合理。該 test 依賴 mock driver + 舊 `NewRouter` 簽名,修起來成本高於價值。deletions.md §2.7 本來就留給執行者決定 |
|
|
||||||
| `camera-controls.tsx` 的 `'mock-cam-0'` fallback 改 `disabled={cameras.length === 0}` | ✅ 合理。之前是「沒 camera 還是硬塞 mock-cam-0」,現在改為 button disabled + guard `if (firstCam)`,邏輯更嚴謹 |
|
|
||||||
| `manager_test.go` 新增 `TestManager_ListDevices_Empty` | ✅ 合理。補足了刪除 `TestNewManager_MockMode` 後的測試缺口,明確驗證 R5-5a 的空 list 行為 |
|
|
||||||
| `visiona-local/app.go` 的註解改寫(「M7:預設真實硬體模式」→「R5-5a:沒插硬體就顯示空白狀態」;「M1 預設 mock 模式...」→「R5-5a 之後:python 失敗直接擋啟動」) | ✅ 合理,註解與新行為一致 |
|
|
||||||
|
|
||||||
**F 結論**:兩個 Agent 的自補都是必要且最小化的,沒有過度也沒有不足。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. 兩個 milestone 交集檔案的正確性
|
|
||||||
|
|
||||||
### G1. `visiona-local/app.go`
|
|
||||||
|
|
||||||
- M8-1 改:`locateBundleBinDir` 的 `fileExists("yt-dlp")` → `fileExists("ffprobe")`;`startServer` 註解「ffmpeg / yt-dlp」→「ffmpeg / ffprobe」
|
|
||||||
- M8-2 改:`App.mockMode` field 砍、`NewApp()` 讀 `VISIONA_MOCK` 砍、`startServer` 所有 `a.mockMode` 分支砍、註解改寫
|
|
||||||
|
|
||||||
兩者互不干擾,merge 正確。`args` 陣列現在無條件帶 `--python-mode` 和(若 `pyBin != ""`)`--python`,符合 M8-2 deletions.md §3.1 的改法。`ensurePythonRuntime` 錯誤不再被 mockMode 遮蔽,直接 return error,符合 R5-5a 精神。
|
|
||||||
|
|
||||||
✅ **正確**
|
|
||||||
|
|
||||||
### G2. `server/main.go`
|
|
||||||
|
|
||||||
- M8-1 改:L88 PATH 注入註解「yt-dlp / ffmpeg」→「ffmpeg / ffprobe」
|
|
||||||
- M8-2 改:L86 `logger.Info("Mock mode: ...")` → `Dev mode: ..., Python mode: ...`;L141 `device.NewManager` 簽名簡化;L146 `camera.NewManager` 簽名簡化
|
|
||||||
|
|
||||||
兩者無衝突,merge 正確。
|
|
||||||
|
|
||||||
✅ **正確**
|
|
||||||
|
|
||||||
### G3. `Makefile`
|
|
||||||
|
|
||||||
- M8-1 改:`vendor-ytdlp` / `vendor-ytdlp-windows` / `vendor-ytdlp-linux` 三個 target 整段刪、`.PHONY` 移除、`vendor-sync` / `payload-*` 依賴移除、help 文字更新、`YTDLP_URL_*` 變數刪、`payload-*` 內 `cp .../yt-dlp ...` 行刪
|
|
||||||
- M8-2 改:`.PHONY` 的 `dev-mock` 移除、底部 `dev-mock:` target 整段刪
|
|
||||||
- **M8-3(不審)**:`vendor-ffmpeg` 系列從 GPL 改為 LGPL v3 + 新增 `vendor-ffmpeg-macos-build` target + ffmpeg URL 從 evermeet / johnvansickle / BtbN-gpl 改為 BtbN-lgpl
|
|
||||||
|
|
||||||
M8-1 + M8-2 在 Makefile 內的改動乾淨、無殘留。M8-3 內容雖然混入但不與 M8-1 / M8-2 衝突(操作的是不同的 line / variable / target)。
|
|
||||||
|
|
||||||
✅ **M8-1 + M8-2 在 Makefile 的部分正確**
|
|
||||||
|
|
||||||
### G4. `README.md`
|
|
||||||
|
|
||||||
- M8-1 改:L18(zero-dep bullet 去 yt-dlp)、L74(支援 URL / yt-dlp 行去掉)、L128 / L131(vendor-sync / payload-macos 描述去 yt-dlp)、L182(授權表格 yt-dlp row 刪)
|
|
||||||
- M8-2 改:L24(零學習成本 bullet 去 Mock)、L73(Mock 模式 bullet 整行刪)
|
|
||||||
- **M8-3(不審)**:L163 GPL TODO 整行刪、L180 ffmpeg GPL row 改 LGPL
|
|
||||||
|
|
||||||
M8-1 + M8-2 在 README 的部分正確且完整。
|
|
||||||
|
|
||||||
✅ **正確**
|
|
||||||
|
|
||||||
### G5. scripts/installer 類(bootstrap-linux / bootstrap-windows / visiona-local.iss / build-appimage.sh / build.yml)
|
|
||||||
|
|
||||||
- M8-1 改:清除 `vendor-ytdlp-*` / `yt-dlp*.exe` / tool loop 中的 yt-dlp
|
|
||||||
- **M8-3(不審)**:LGPL 相關的 LICENSE.txt / COPYING.LGPLv3 複製、GPL warning 去除、ffprobe 複製
|
|
||||||
|
|
||||||
M8-1 的 yt-dlp 清除動作完整。
|
|
||||||
|
|
||||||
✅ **正確**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. 問題清單
|
|
||||||
|
|
||||||
### Major(阻擋 M8-4)
|
|
||||||
|
|
||||||
**無。**
|
|
||||||
|
|
||||||
### Minor(可先往下走,後續 M8-6 / 另開處理)
|
|
||||||
|
|
||||||
| # | 檔案 | 問題描述 | 建議處理 |
|
|
||||||
|---|------|---------|---------|
|
|
||||||
| 1 | `frontend/src/components/camera/source-selector.tsx:184,189` | `accept=".mp4,.avi,.mov"` 和 `t('camera.mp4AviMov')` 仍為三種副檔名 | **不是 M8-1 的 bug**。deletions.md §5.1 的「新增 mpeg/mpg」和 milestone-plan M8-6 任務 2+3 明確把副檔名擴充列為 M8-6 的工作。留給 M8-6 處理即可 |
|
|
||||||
| 2 | `server/internal/api/handlers/camera_handler.go:~251`(未檢視) | 後端影片 extension whitelist 仍為 `.mp4 / .avi / .mov` | 同上,milestone-plan M8-6 任務 4 明確列入 |
|
|
||||||
| 3 | `server/internal/deps/checker.go` | `CheckAll()` 仍只檢查 `ffmpeg`,沒加 `ffprobe` | 不是 M8-1 範圍。M8-3 切 LGPL ffmpeg 後 ffprobe 變必備,屆時應在 deps checker 加一項 |
|
|
||||||
| 4 | working tree 混入 M8-3 內容 | Makefile / README / installer / scripts / build.yml / `.gitignore` 都有 M8-3 的改動 | 這是「working tree 目前同時有三個 milestone」的狀態,不是 bug。Orchestrator 決定要不要分 commit 即可 |
|
|
||||||
| 5 | `server/internal/api/api_e2e_test.go` 整檔刪 | 本來有 E2E HTTP 層級的煙霧測試,刪除後該層級失去測試 | 不 block M8-4,但 M8-10 前應考慮補回一份不依賴 mock 的 e2e(可改為:無硬體環境下測 `/api/devices` 回空 list + `/api/models` 回內建模型等純 read-only 路徑) |
|
|
||||||
|
|
||||||
### Suggestion(可做可不做)
|
|
||||||
|
|
||||||
| # | 檔案 | 建議 |
|
|
||||||
|---|------|------|
|
|
||||||
| 1 | `visiona-local/app.go:671` 的 docblock 「R5-5a 之後:python 失敗直接擋啟動(沒有模擬回退)」 | 文字 OK,可考慮補一句「若要無 Kneron 硬體環境 debug,請直接透過 server binary `go run` 而不透過 Wails 殼」方便開發者 |
|
|
||||||
| 2 | `server/internal/device/manager_test.go:43` 的 R5-5a 紀念註解 | 可愛的紀錄,保留 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. 通過 / 不通過結論
|
|
||||||
|
|
||||||
### ✅ **通過**
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
1. M8-1 + M8-2 的 28 + 28 = 56 項砍除清單 **100% 達成**
|
|
||||||
2. 零誤刪,所有 R5 指定保留功能完整存在
|
|
||||||
3. 殘留 grep 完全 clean
|
|
||||||
4. 五項 build / test 全綠
|
|
||||||
5. Smoke test 驗證 runtime 行為正確(空 device list、/media/url 404、log 無 mock / yt-dlp)
|
|
||||||
6. 兩個 Agent 的自補決定全部合理
|
|
||||||
7. 三個 merge 交集檔案(app.go / main.go / Makefile / README)的 M8-1 和 M8-2 改動相容無衝突
|
|
||||||
|
|
||||||
### 下一步建議給 Orchestrator
|
|
||||||
|
|
||||||
1. **M8-1 + M8-2 可以 commit**(建議兩個獨立 commit,或一個合併 commit 標示「M8-1 + M8-2 砍除」)
|
|
||||||
2. **M8-3 的 working tree 改動需另外處理** — 建議分開 commit / 分開 review
|
|
||||||
3. **M8-4 可以開動** — 無技術阻擋
|
|
||||||
4. **M8-6 日後執行時** 記得處理 Minor #1 和 #2(副檔名擴充 + 後端 whitelist + i18n 改名)
|
|
||||||
5. **M8-3 執行完** 順便處理 Minor #3(deps checker 加 ffprobe)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 優點
|
|
||||||
|
|
||||||
- **M8-1 Agent**:砍得非常乾淨,連 orphan i18n key(`uploadFile`)和 bundle bin dir 的 yt-dlp sentinel 都主動處理
|
|
||||||
- **M8-2 Agent**:`TestManager_ListDevices_Empty` 新增測試體現 R5-5a 精神;`camera-controls.tsx` 的 fallback 改為 `disabled={cameras.length === 0}` 是比原本塞 `'mock-cam-0'` 更嚴謹的寫法
|
|
||||||
- **兩個 milestone 的 merge**:沒有互相踩腳。`visiona-local/app.go` 尤其漂亮,`mockMode` 砍掉後 `args` 陣列變得非常直觀
|
|
||||||
- **grep 殘留 0 match**:連 `videoIsURL` 這種容易忘記的 dead flag 都有徹底清除
|
|
||||||
|
|
||||||
砍完這兩個 milestone 後,整個 codebase 瘦了 **~655 行** 程式碼(29 修改 + 3 刪除),直接對應 R5-7「砍掉非核心功能、focus 推論核心」的決策。砍得漂亮。
|
|
||||||
@ -1,271 +0,0 @@
|
|||||||
# Reviewer 審查 M8-3 ffmpeg LGPL 切換(2026-04-15)
|
|
||||||
|
|
||||||
對照文件:
|
|
||||||
- TDD spec:`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/v2/ffmpeg-lgpl.md`
|
|
||||||
- Research:`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/ffmpeg-lgpl-research.md`
|
|
||||||
- BUILD.md:`/Users/jimchen/visionA/local-tool/vendor/ffmpeg/macos/BUILD.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
| 項目 | 結論 |
|
|
||||||
|------|------|
|
|
||||||
| 總結論 | ✅ 通過(1 Minor + 2 Suggestion + 3 待處理事項) |
|
|
||||||
| LGPL 合規 | ✅ 三平台齊備(macOS 自 build decoder-only;Windows/Linux BtbN n7.1 LGPL) |
|
|
||||||
| Binary 大小減量 | ✅ macOS 77 MB → 11.32 MB(含 ffprobe),減量約 85% |
|
|
||||||
| 阻擋 M8-10? | ❌ 不阻擋,所有硬性條件均通過 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. Makefile 變更正確性
|
|
||||||
|
|
||||||
對照 `Makefile:1-449` ↔ TDD §2.3/§3/§4,18 項檢查全過:
|
|
||||||
|
|
||||||
| # | 驗證項 | 結果 | 位置 |
|
|
||||||
|---|-------|------|------|
|
|
||||||
| 1 | `FFMPEG_VERSION=n7.1` + sha256 常數 | ✅ | `Makefile:73-77` |
|
|
||||||
| 2 | `vendor-ffmpeg` 退化為「驗證存在 + LGPL guard」 | ✅ | `Makefile:112-130` |
|
|
||||||
| 3 | `vendor-ffmpeg-macos-build` 僅限 Darwin + `nasm`/`yasm` | ✅ | `Makefile:136-140` |
|
|
||||||
| 4 | 下載 tarball + sha256 驗證 + fail 印實際 sha | ✅ | `Makefile:142-148` |
|
|
||||||
| 5 | configure 21 個 flag 與 TDD §2.3 逐字對齊 | ✅ | `Makefile:152-178` |
|
|
||||||
| 6 | `--disable-everything` + decoder 白名單 h264/hevc/mpeg1/2video/mpeg4/mjpeg/prores/vp8/vp9/aac/mp2/mp3/pcm_* | ✅ | `Makefile:167` |
|
|
||||||
| 7 | demuxer 白名單 mov,avi,mpegps,mpegts,matroska,image2 | ✅ | `Makefile:166` |
|
|
||||||
| 8 | strip + ad-hoc codesign | ✅ | `Makefile:184-189` |
|
|
||||||
| 9 | build 後 LGPL grep guard | ✅ | `Makefile:190-193` |
|
|
||||||
| 10 | 複製 COPYING.LGPLv3 | ✅ | `Makefile:194-195` |
|
|
||||||
| 11 | `FFMPEG_URL_WINDOWS` → BtbN n7.1 LGPL zip | ✅ | `Makefile:277` |
|
|
||||||
| 12 | `FFMPEG_URL_LINUX` → BtbN n7.1 LGPL tar.xz | ✅ | `Makefile:387` |
|
|
||||||
| 13 | `vendor-ffmpeg-windows` 解壓 ffmpeg.exe + ffprobe.exe + LICENSE + COPYING.LGPLv3 | ✅ | `Makefile:319-348` |
|
|
||||||
| 14 | `vendor-ffmpeg-linux` 解壓 ffmpeg + ffprobe + LICENSE,`strip-components=1` | ✅ | `Makefile:417-434` |
|
|
||||||
| 15 | `payload-macos` 複製 ffmpeg + ffprobe + ffmpeg-COPYING.LGPLv3 | ✅ | `Makefile:244-247` |
|
|
||||||
| 16 | `payload-windows` 複製 ffprobe.exe + LICENSE/COPYING(skipifsourcedoesntexist) | ✅ | `Makefile:358-362` |
|
|
||||||
| 17 | `payload-linux` 複製 ffprobe + ffmpeg-LICENSE.txt | ✅ | `Makefile:444-446` |
|
|
||||||
| 18 | `payload-macos:241` 有 `rm -rf payload/darwin` 清舊 | ✅ | `Makefile:241` |
|
|
||||||
|
|
||||||
**五 decoder + 五 demuxer 白名單對照 TDD 驗收條件 §10**(h264/hevc/mpeg2video/mpeg4/aac;mov-mp4/avi/mpeg/mpegts/matroska):全數涵蓋。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. Binary 正確性(親自驗證)
|
|
||||||
|
|
||||||
```
|
|
||||||
$ ls -la vendor/ffmpeg/macos/
|
|
||||||
-rw-r--r-- 10500 BUILD.md
|
|
||||||
-rw-r--r-- 7651 COPYING.LGPLv3
|
|
||||||
-rwxr-xr-x 6007520 ffmpeg (5.73 MB)
|
|
||||||
-rwxr-xr-x 5865568 ffprobe (5.59 MB)
|
|
||||||
```
|
|
||||||
|
|
||||||
**sha256 與 BUILD.md `lines 26-30` 100% 吻合**:
|
|
||||||
```
|
|
||||||
c3cb9f1dad66730267c12fca92c6344d2f8939ab227889caac33005f8947992c ffmpeg
|
|
||||||
bd388fb4372ed5f7e44ee331a51be6383d702fb2c067bf562cabbdfbdd8b0c5e ffprobe
|
|
||||||
da7eabb7bafdf7d3ae5e9f223aa5bdc1eece45ac569dc21b3b037520b4464768 COPYING.LGPLv3
|
|
||||||
```
|
|
||||||
|
|
||||||
**`ffmpeg -version` configuration**:
|
|
||||||
- ✅ 無 `--enable-gpl`、無 `libx264`、無 `libx265`
|
|
||||||
- ✅ 有 `--enable-version3`、`--disable-network`、`--disable-autodetect`、`--enable-static`、`--disable-everything`
|
|
||||||
|
|
||||||
**Decoder 驗證**:`ffmpeg -decoders | grep -E ' (h264|hevc|aac|mpeg2video|mpeg4) '` 五個全中。
|
|
||||||
|
|
||||||
**Demuxer 驗證**:`ffmpeg -demuxers` 顯示 `avi` / `matroska,webm` / `mov,mp4,m4a,3gp,3g2,mj2` / `mpeg` / `mpegts` 五個全中。
|
|
||||||
|
|
||||||
**`otool -L` 動態依賴**:
|
|
||||||
```
|
|
||||||
/usr/lib/libSystem.B.dylib
|
|
||||||
/System/Library/Frameworks/CoreFoundation.framework/.../CoreFoundation
|
|
||||||
/System/Library/Frameworks/CoreVideo.framework/.../CoreVideo
|
|
||||||
/System/Library/Frameworks/CoreMedia.framework/.../CoreMedia
|
|
||||||
```
|
|
||||||
ffprobe 相同四項。**無任何第三方 dylib**(x264/x265/vpx/opus/aom 都不在),符合 LGPL static self-contained。
|
|
||||||
|
|
||||||
**`ffprobe -version`**:可執行,顯示同一 configuration。
|
|
||||||
|
|
||||||
**`codesign -v`**:ffmpeg + ffprobe 皆 exit 0(ad-hoc signing 通過)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. .gitignore 雙層
|
|
||||||
|
|
||||||
**Inner `local-tool/.gitignore:6-15`**:
|
|
||||||
```
|
|
||||||
/vendor/**
|
|
||||||
!/vendor/.gitkeep
|
|
||||||
!/vendor/README.md
|
|
||||||
!/vendor/ffmpeg/
|
|
||||||
!/vendor/ffmpeg/macos/
|
|
||||||
!/vendor/ffmpeg/macos/**
|
|
||||||
```
|
|
||||||
|
|
||||||
**Outer `visionA/.gitignore:20-27`**:同樣四行 un-ignore + `local-tool/vendor/**` 為基底。
|
|
||||||
|
|
||||||
`git check-ignore` 驗證:
|
|
||||||
|
|
||||||
| 路徑 | inner repo | outer repo |
|
|
||||||
|------|-----------|-----------|
|
|
||||||
| `vendor/ffmpeg/macos/ffmpeg` | exit 1(未 ignore ✅) | exit 1 ✅ |
|
|
||||||
| `vendor/ffmpeg/macos/BUILD.md` | exit 1 ✅ | exit 1 ✅ |
|
|
||||||
| `vendor/ffmpeg/windows/ffmpeg.exe` | exit 0(ignored ✅) | exit 0 ✅ |
|
|
||||||
| `vendor/ffmpeg/linux/ffmpeg` | exit 0 ✅ | exit 0 ✅ |
|
|
||||||
|
|
||||||
`git status --ignored vendor/` 顯示:
|
|
||||||
- `?? vendor/ffmpeg/macos/`(untracked,待 `git add`)
|
|
||||||
- `!! vendor/ffmpeg/{linux,windows}/`、`!! vendor/{python,wheels,yt-dlp}/`(ignored)
|
|
||||||
|
|
||||||
雙層規則正確;outer repo `local-tool/vendor/` 仍為整個 untracked,需在交付前 `git add` — 屬待處理事項而非實作錯誤。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. BUILD.md 可重現性
|
|
||||||
|
|
||||||
BUILD.md(295 行)完整度:
|
|
||||||
|
|
||||||
| 欄位 | 有無 |
|
|
||||||
|------|------|
|
|
||||||
| ffmpeg release n7.1 + source URL + sha256 | ✅ |
|
|
||||||
| Build host(macOS 14.7.6,Apple clang 16.0.0) | ✅ |
|
|
||||||
| Toolchain(nasm 3.01、brew 5.1.6、Xcode CLT) | ✅ |
|
|
||||||
| Configure flags 完整複製貼上 | ✅ |
|
|
||||||
| Binary sha256(三項) | ✅ |
|
|
||||||
| Binary 大小(bytes + 人類可讀) | ✅ |
|
|
||||||
| Build 耗時(2 分 44 秒,user 559s/sys 56s) | ✅ |
|
|
||||||
| 實測驗證輸出(ffmpeg -version / decoder / demuxer / otool / codesign 節錄) | ✅ |
|
|
||||||
| Rebuild 指令 `make vendor-ffmpeg-macos-build` | ✅ |
|
|
||||||
| Commit 清單(僅允許 ffmpeg/ffprobe/COPYING/BUILD.md 四檔) | ✅ |
|
|
||||||
|
|
||||||
**兩年後重現評估**:可以。source tarball URL + sha256 固定、configure flags 可直貼、系統依賴明列、Makefile 本身就是 reproducibility script。唯一風險是 Homebrew nasm/clang 版本漂移,但 ffmpeg 7.1 對 toolchain 包容度高,不預期 break。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. GPL flag 清除 grep
|
|
||||||
|
|
||||||
全 repo grep `VISIONA_ALLOW_GPL_FFMPEG` / `--enable-gpl` / `libx264` / `libx265`:
|
|
||||||
|
|
||||||
| 類別 | 殘存位置 | 評估 |
|
|
||||||
|------|---------|------|
|
|
||||||
| `.autoflow/*`(progress/TDD/research/spec) | 允許(歷史紀錄) | ✅ |
|
|
||||||
| `Makefile:126,190-193` | guard 驗證邏輯 | ✅ |
|
|
||||||
| `vendor/ffmpeg/macos/BUILD.md` | 驗證輸出說明 | ✅ |
|
|
||||||
| `build/ffmpeg-macos/src/*` | upstream source tarball 解壓產物 | ✅(非專案碼) |
|
|
||||||
| `scripts/bootstrap-{windows,linux}.*` | 已改為 `LGPL v3 build` 標語 | ✅ |
|
|
||||||
| `.github/workflows/build.yml` | 零殘留 | ✅ |
|
|
||||||
| `installer/**` | 零殘留 | ✅ |
|
|
||||||
| `server/**.go` + `visiona-local/**.go` | 零殘留 | ✅ |
|
|
||||||
|
|
||||||
**GPL flag 完全清除**,只剩文件類歷史 + guard 邏輯。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. Installer / Bootstrap / CI
|
|
||||||
|
|
||||||
- **Windows Inno Setup** `installer/windows/visiona-local.iss:74-79`:含 ffmpeg.exe + ffprobe.exe + ffmpeg-LICENSE.txt + ffmpeg-COPYING.LGPLv3(後兩者用 `skipifsourcedoesntexist` fallback)✅
|
|
||||||
- **Linux AppImage** `installer/linux/build-appimage.sh:61-76`:`for tool in ffmpeg ffprobe` 迴圈 + 把 ffmpeg-LICENSE.txt 放到 `AppDir/usr/share/doc/visiona-local/` ✅
|
|
||||||
- **macOS**:無獨立 installer,payload-macos 直接把 COPYING.LGPLv3 複製到 `payload/darwin/bin/ffmpeg-COPYING.LGPLv3`,經 stage-macos 進 `.app/Contents/Resources/bin/` ✅
|
|
||||||
- **bootstrap-windows.ps1:71 / bootstrap-linux.sh:58**:只剩 LGPL 提示語,無 GPL flag ✅
|
|
||||||
- **`.github/workflows/build.yml`**:只保留 `vendor-ffmpeg-windows`(line 135)和 `vendor-ffmpeg-linux`(line 232)呼叫,無 env var 設定 ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. Payload 流程相容性 + 真實 decode 測試
|
|
||||||
|
|
||||||
### G.1 server runtime 相容性
|
|
||||||
|
|
||||||
`server/main.go:88-93` 把 `VISIONA_BUNDLE_BIN_DIR` prepend 到 PATH;`server/internal/deps/checker.go:23-94` 的 `resolveTool` 優先在 `$VISIONA_BUNDLE_BIN_DIR/<name>` 找;`visiona-local/app.go:482-484` Wails shell 啟動 server 時注入 `VISIONA_BUNDLE_BIN_DIR = locateBundleBinDir()`(macOS→`Contents/Resources/bin`,Windows/Linux→`<exe>/bin`,開發→`cwd/payload/<os>/bin`)。
|
|
||||||
|
|
||||||
payload-macos `Makefile:244-245` 把 `ffmpeg` + `ffprobe` 放到 `payload/darwin/bin/`,stage-macos 會完整複製到 bundle Resources。`server/internal/camera/video_source.go:24` 的 `exec.Command("ffprobe", ...)` 依賴 basename lookup,加入 ffprobe 後完全相容。✅
|
|
||||||
|
|
||||||
### G.2 實際 decode 測試
|
|
||||||
|
|
||||||
先用系統 ffmpeg 產生 h264 mp4:`/usr/local/bin/ffmpeg -f lavfi -i "testsrc=duration=1:size=320x240:rate=10" -c:v libx264 ... /tmp/testsrc.mp4`。
|
|
||||||
|
|
||||||
LGPL ffmpeg 解碼 → mjpeg:
|
|
||||||
```
|
|
||||||
$ ./vendor/ffmpeg/macos/ffmpeg -i /tmp/testsrc.mp4 \
|
|
||||||
-frames:v 1 -f image2pipe -vcodec mjpeg -q:v 5 /tmp/test_out.jpg
|
|
||||||
Stream #0:0(und): Video: mjpeg, yuv444p, 320x240, 10 fps ...
|
|
||||||
frame= 1 fps=0.0 q=5.0 Lsize= 9KiB
|
|
||||||
$ file /tmp/test_out.jpg
|
|
||||||
/tmp/test_out.jpg: JPEG image data, JFIF standard 1.02, ..., 320x240
|
|
||||||
```
|
|
||||||
|
|
||||||
LGPL ffprobe probe h264 stream:
|
|
||||||
```
|
|
||||||
$ ./vendor/ffmpeg/macos/ffprobe -v error -show_streams /tmp/testsrc.mp4
|
|
||||||
codec_name=h264 codec_type=video width=320 height=240 pix_fmt=yuv444p
|
|
||||||
```
|
|
||||||
|
|
||||||
**真實 decode 路徑通過**,server extract first-frame 流程可直接使用。
|
|
||||||
|
|
||||||
### G.3 `payload/darwin/bin/` 當前為舊 GPL 77 MB binary
|
|
||||||
|
|
||||||
```
|
|
||||||
$ shasum -a 256 payload/darwin/bin/ffmpeg
|
|
||||||
b68f795f7fb4528daf697f57a2b6780846a1ae762a71907e994442ad103ee88f
|
|
||||||
```
|
|
||||||
|
|
||||||
**非 M8-3 實作錯誤**:payload-macos 尚未在 M8-3 後重跑。`Makefile:241` 的 `rm -rf payload/darwin` 與 `stage-macos:265` 的 `rm -rf visiona-local/build/darwin/Resources` 會自動清乾淨。列入待處理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. Size 比較
|
|
||||||
|
|
||||||
| 項目 | 舊 GPL | 新 LGPL | 差異 |
|
|
||||||
|------|-------|--------|------|
|
|
||||||
| macOS ffmpeg | ~77 MB(progress.md M6-1 紀錄) | **5.73 MB** | −71.3 MB(−92%) |
|
|
||||||
| macOS ffprobe | 未附 | **5.59 MB** | 新增 |
|
|
||||||
| macOS 合計 | ~77 MB | **11.32 MB** | **−65.7 MB(−85%)** |
|
|
||||||
|
|
||||||
macOS 成果超越 TDD §2.1「~20 MB」目標,`--disable-everything` + 白名單策略效益顯著。Windows/Linux BtbN LGPL build 大小待 M8-10 CI runner 下載後驗證。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. 問題清單
|
|
||||||
|
|
||||||
### Critical / Major
|
|
||||||
|
|
||||||
無。
|
|
||||||
|
|
||||||
### Minor
|
|
||||||
|
|
||||||
| # | 位置 | 問題 | 建議 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| 1 | `vendor/ffmpeg/macos/BUILD.md:186-194` §Verification §5 | 預期 `spctl --assess --verbose` 為「accepted」不準確:實測 ad-hoc signed binary 在 macOS 14.7 下被 `rejected`(exit 3)。Gatekeeper 由 outer `.app` bundle signing 決定,不對內嵌 binary 單獨 assess | 把該步驟改為 `codesign -v`(exit 0 即可),並註記「Gatekeeper 最終由 outer `.app` bundle signing 決定」 |
|
|
||||||
|
|
||||||
### Suggestion
|
|
||||||
|
|
||||||
| # | 位置 | 建議 |
|
|
||||||
|---|------|------|
|
|
||||||
| 1 | `Makefile:126-130` `vendor-ffmpeg` target | 可多驗 `shasum -a 256` 對照 BUILD.md 記錄,防 binary 被意外覆蓋 / 損毀(非必要,git 已保護) |
|
|
||||||
| 2 | `Makefile:361-362` payload-windows | 兩份授權檔都用 `skipifsourcedoesntexist`,若 BtbN zip 內 LICENSE.txt 與 COPYING.LGPLv3 同時缺失,installer 會無授權檔交付。建議補「至少其中一個存在」assertion |
|
|
||||||
|
|
||||||
### 待處理(非 M8-3 scope,但影響 release)
|
|
||||||
|
|
||||||
1. **outer repo `git add`**:`/Users/jimchen/visionA/` 的 `git status` 仍顯示 `local-tool/vendor/` 為整個 untracked。交付前須 `git add local-tool/vendor/ffmpeg/macos/{ffmpeg,ffprobe,COPYING.LGPLv3,BUILD.md}` 並 commit。
|
|
||||||
2. **`payload/darwin/bin/ffmpeg` 仍為舊 GPL 77 MB binary**:下次 `make payload-macos` 會自動清。M8-10 前應重跑。
|
|
||||||
3. **`vendor/yt-dlp/` 目錄仍在**:屬 M8-2 清理範圍,非 M8-3。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## J. 結論
|
|
||||||
|
|
||||||
**M8-3 ffmpeg LGPL 切換:✅ 通過。**
|
|
||||||
|
|
||||||
核心成果確認:
|
|
||||||
- Makefile 三平台變更逐字對齊 TDD §2-§4
|
|
||||||
- macOS binary 親測 LGPL 合規(無 gpl/x264/x265,有 version3)、五 decoder + 五 demuxer 全中、只依賴 macOS 系統 framework
|
|
||||||
- sha256 與 BUILD.md 100% 吻合
|
|
||||||
- 實測 h264 mp4 decode → mjpeg JPEG 成功;ffprobe probe 成功
|
|
||||||
- 雙層 `.gitignore` inner + outer 規則均正確
|
|
||||||
- BUILD.md 完整可重現(兩年後可 rebuild)
|
|
||||||
- GPL flag 在程式碼 / installer / bootstrap / CI 完全清除
|
|
||||||
- Installer Windows/Linux 含 ffprobe + 授權檔
|
|
||||||
- Size 減量 85%
|
|
||||||
|
|
||||||
**僅 1 Minor(BUILD.md 的 spctl 預期描述錯誤)+ 2 Suggestion + 3 待處理事項,不阻擋 M8-10。**
|
|
||||||
|
|
||||||
Orchestrator 建議:
|
|
||||||
1. Minor #1(BUILD.md spctl 描述修正)列為 M8-3 尾巴,由 Architect/DevOps 補
|
|
||||||
2. M8-10 CI 交付前執行 outer repo `git add` + commit
|
|
||||||
3. M8-10 時確保 `make payload-macos` + `stage-macos` 重跑清掉舊 GPL payload
|
|
||||||
@ -1,360 +0,0 @@
|
|||||||
# Reviewer 審查 M8-4 ServerController + Log Ring Buffer + Preferences + Notify + Boot-ID(2026-04-15)
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
- **總結論**:⚠️ 需小改(核心架構正確,有 2 個 Major 阻擋後續 milestone)
|
|
||||||
- **阻擋情況**:
|
|
||||||
- **阻擋 M8-5/M8-7/M8-9**:watchServerV2 在 Stop 後仍存活會誤觸 Error state(誤發崩潰通知)→ 直接破壞 M8-5 控制台 UX;server 端 `server:shutdown-imminent` WebSocket 廣播未實作會直接讓 M8-9 的 race 風險回來
|
|
||||||
- **不阻擋 M8-4b**:M8-4b 是擴充 ServerController 加 pipeline hook,本層 state machine 介面已就緒,可平行進行
|
|
||||||
- 親跑驗證:build / vet / test / **race detector 全綠**;smoke test boot-id + SkipPaths 完全符合預期
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. Log Ring Buffer(`log_buffer.go`)
|
|
||||||
|
|
||||||
| 檢查項 | 結論 | 備註 |
|
|
||||||
|--------|------|------|
|
|
||||||
| 容量 2000 行 | ✅ | `logBufferCap = 2000`(L26) |
|
|
||||||
| Thread-safe | ✅ | Append/Snapshot/Reset/Size/Dropped 全 mutex |
|
|
||||||
| Wrap-around | ✅ | head 環形遞增、size 飽和、dropped 計數正確(test 驗證 line-100..2099) |
|
|
||||||
| Snapshot 回 copy | ✅ | `out := make([]LogLine, 0, n)` + append(L96-105) |
|
|
||||||
| Rate limit 200/sec burst | ⚠️ Minor | 演算法本身正確(固定視窗 CAS),但**呼叫單位有偏差**(見 D 段) |
|
|
||||||
| `parseLogLevel` | ✅ | bracket / 冒號 / Gin 三種格式齊備;空字串、非 ASCII 不 panic |
|
|
||||||
| 5 個 unit test | ✅ | TestLogBuffer_*、TestParseLogLevel 全綠 |
|
|
||||||
|
|
||||||
**Minor 1(A.1)**:`Snapshot` 從 `i := 0` 走到 `b.size`,前 `skip` 行只是 continue,可以直接從 `(start + skip) % logBufferCap` 開始走 `n` 步,效率較好。當 size = 2000、n = 100 時白跑 1900 圈。**非阻擋**,效能影響小於 0.1 ms。
|
|
||||||
|
|
||||||
**Minor 2(A.2)**:`ShouldEmit` 內 CAS 與 `Store(0)` 的微 race — A 視窗內 CAS 成功還沒 Store(0) 之前,B 已先 `Add(1)`,可能讓新視窗第一行被舊 count 計算。實際只會多放 ≤ 1 行 emit,**不影響功能正確性**,可不修。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. Preferences(`preferences.go`)
|
|
||||||
|
|
||||||
| 檢查項 | 結論 | 備註 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `DefaultPreferences()` 平台分支 | ✅ | `runtime.GOOS != "linux"` → true(L42) |
|
|
||||||
| Atomic write-rename | ✅ | tmp → `os.Rename` → cleanup(L78-95) |
|
|
||||||
| 讀取失敗 fallback | ✅ | 缺檔 / JSON 損毀 → 回 default + warning(L57-71) |
|
|
||||||
| JSON 欄位齊全 | ✅ | autoOpenBrowser / locale / logRingSize |
|
|
||||||
| 5 個 unit test | ✅ | 平台預設 / missing file / corrupt JSON / roundtrip / 殘留 tmp |
|
|
||||||
|
|
||||||
**極佳**:`SavePreferences` 失敗時 `os.Remove(tmpPath)` 把垃圾 tmp 清掉(L92),符合 atomic 持久化最佳實踐。
|
|
||||||
|
|
||||||
**Minor 3(B.1)**:`SavePreferences` 路徑在跨檔案系統 rename 時會失敗(罕見邊界,例如 dataDir 是 NFS / overlayfs)。建議將來加 `io.Copy + os.Remove(tmp)` fallback,但本 milestone 不必處理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. ServerController State Machine(`server_control.go`)
|
|
||||||
|
|
||||||
### C.1 雙鎖機制
|
|
||||||
| 檢查項 | 結論 | 備註 |
|
|
||||||
|--------|------|------|
|
|
||||||
| State enum 完整 | ✅ | Idle / Starting / Running / Stopping / Stopped / Error(L43-50) |
|
|
||||||
| txMu 序列化 transition | ✅ | Start/Stop/ForceKill 都 `c.txMu.Lock(); defer c.txMu.Unlock()` |
|
|
||||||
| mu 保護欄位讀寫 | ✅ | state / proc / startedAt / lastError 全走 mu |
|
|
||||||
| setState emit Wails event | ✅ | L120 `EventsEmit("server:state-change", status)` |
|
|
||||||
| 鎖順序避免 deadlock | ✅ | a.mu → c.mu 全部單向 |
|
|
||||||
| race detector | ✅ | `go test -race ./...` 全綠 |
|
|
||||||
|
|
||||||
### C.2 Start / Stop / Restart / ForceKill 行為
|
|
||||||
|
|
||||||
| 行為 | 結論 | 備註 |
|
|
||||||
|------|------|------|
|
|
||||||
| Start 允許 cold start fallback | ✅ | `startInternal(0)` → `pickPort(defaultPreferredPort)` |
|
|
||||||
| Restart 強制保留 port | ✅ | L233-244 讀 oldPort → Stop → StartWithPort(oldPort) |
|
|
||||||
| StartWithPort 對佔 port 不 fallback | ✅ | L408-411 直接 return error |
|
|
||||||
| `killStaleServerOnPort` 嘗試清乾淨 | ✅ | L405-408(500 ms wait) |
|
|
||||||
| ForceKill 不發 crash 通知 | ✅ | 直接呼叫 `proc.forceKill()`,不走 handleWatchFailure |
|
|
||||||
| Shutdown 7 + 1 秒 modal | ✅ | `shutdownGraceV2 = 7s`、modalTimer = 1s(L319-339) |
|
|
||||||
| Windows 沒 SIGTERM 直接 Kill | ✅ | L307 `runtime.GOOS == "windows"` 分支 |
|
|
||||||
| watchServerV2 連 3 次失敗進 Error | ✅ | L644-649 `handleWatchFailure` |
|
|
||||||
| 不 os.Exit | ✅ | watchServerV2 完全沒呼叫 `reportFatal` / `os.Exit` |
|
|
||||||
|
|
||||||
### C.3 Major 與 Minor 問題(針對 ServerController)
|
|
||||||
|
|
||||||
> **Major 1(C.MAJ-1)**:**`Stop()` / `ForceKill()` 不會 cancel `watchCancel`,導致 watchServerV2 在 server 死後仍存活,30 秒(3 × 10s)後**會把 state 從 `Stopped` 翻成 `Error`,並發 OS 崩潰通知**。
|
|
||||||
>
|
|
||||||
> **檔案/行**:`server_control.go:198-229`(Stop)、`server_control.go:251-265`(ForceKill)、`server_control.go:617-652`(watchServerV2)。
|
|
||||||
>
|
|
||||||
> **重現**:
|
|
||||||
> 1. Start → Running
|
|
||||||
> 2. Stop → state = Stopped、proc = nil
|
|
||||||
> 3. watchServerV2 仍持有舊 `sp` 與舊 port,下一個 ticker 到 → health check fail → failures++
|
|
||||||
> 4. 30 秒後 failures = 3 → `handleWatchFailure` → `setState(Error)` + `EventsEmit("server:error")` + `sendCrashNotification("Server 崩潰")`
|
|
||||||
>
|
|
||||||
> **後果**:使用者按 Stop 後 30 秒看到「visionA Local — Server 崩潰」OS 通知;M8-5 控制台 UI 會把 Stopped 翻紅變 Error。**這是嚴重 UX bug**。
|
|
||||||
>
|
|
||||||
> **修法**:`Stop()` 與 `ForceKill()` 在持有 txMu 期間先 cancel `a.watchCancel`:
|
|
||||||
> ```go
|
|
||||||
> a.mu.Lock()
|
|
||||||
> if a.watchCancel != nil { a.watchCancel(); a.watchCancel = nil }
|
|
||||||
> a.mu.Unlock()
|
|
||||||
> ```
|
|
||||||
> 一行集中到 helper 函式更乾淨。
|
|
||||||
|
|
||||||
> **Major 2(C.MAJ-2)**:**`handleWatchFailure` 不持 `txMu`,會與正在進行的 Stop / ForceKill / Restart race**。
|
|
||||||
>
|
|
||||||
> **檔案/行**:`server_control.go:269-291`。
|
|
||||||
>
|
|
||||||
> **重現**:
|
|
||||||
> 1. watchServerV2 連續失敗 2 次(failures = 2)
|
|
||||||
> 2. 使用者按 Stop → ctrl.Stop() 取得 txMu → setState(Stopping) → proc.stopGraceful() → setState(Stopped)
|
|
||||||
> 3. 同時 watchServerV2 第 3 次失敗 → handleWatchFailure → setState(Error)
|
|
||||||
> 4. **競態結果**:state 可能是 Error(覆蓋 Stopped),即使使用者剛剛主動 Stop
|
|
||||||
>
|
|
||||||
> **修法**:`handleWatchFailure` 進場先取 txMu,並在 setState 前重新檢查 state(已是 Stopped/Stopping/Idle 就 return)。或更穩:watchServerV2 在 ticker 失敗時也檢查 `c.State() != ServerStateRunning` 直接退出 goroutine。
|
|
||||||
|
|
||||||
**Minor 4(C.MIN-1)**:`Restart()` 拆成獨立的 `Stop()` 與 `StartWithPort()` 兩段 txMu 持有,**中間有窗口期**讓另一個 binding 呼叫插隊。實務上前端按鈕在 Stopping 時 disable 應該擋住,但仍是潛在 race。建議 `Restart()` 自己持 txMu 整段(或加 `restartInternal` 不再呼叫 Stop / StartWithPort,而是內部 inline 邏輯)。
|
|
||||||
|
|
||||||
**Minor 5(C.MIN-2)**:`stopGraceful` 走完 `done` 路徑後立刻 `closeLogFiles()`,但 logPump goroutine 還在處理 lineCh 中 buffered 的最後幾行(會寫 fileWriter)。pipe EOF 與 stopGraceful 的時序可能有極窄的 race(race detector 沒抓到是因為 unit test 沒實際 spawn process)。**建議**:stopGraceful 等待 logPump 結束(加 done channel)再 close file,或讓 logPump 持有 file ownership 自己 defer close。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Log Pump goroutine
|
|
||||||
|
|
||||||
| 檢查項 | 結論 | 備註 |
|
|
||||||
|--------|------|------|
|
|
||||||
| 每個 pipe 一個 goroutine | ✅ | `go a.logPump(stdoutPipe, ...)` × 2(L491-492) |
|
|
||||||
| 10 ms micro-batch | ✅ | `time.NewTicker(10 * time.Millisecond)` |
|
|
||||||
| Rate limit fallback:超量只寫檔不 emit | ⚠️ 偏差 | 見下方 D.MIN-1 |
|
|
||||||
| pipe EOF 後 goroutine 自行退出 | ✅ | scanner.Scan() 結束 → close(scanDone) → main loop 收到 → flush + return |
|
|
||||||
| lineCh 沒被 close | ⚠️ Minor | 由 GC 處理;scanDone 訊號替代 |
|
|
||||||
|
|
||||||
> **D.MIN-1(rate limit 計數單位)**:TDD §4.5 + Reviewer prompt 描述「1 秒 200 行」,但 logPump 是**每個 batch 呼叫一次 `ShouldEmit()`**(`server_control.go:572`),不是每行。因為 batch 大小 1~數十不等,實際 burst 容許行數可能達 200 × 平均 batch size。
|
|
||||||
>
|
|
||||||
> 不算 bug(限流仍能擋住災難級瀑布),但**單元測試 `TestLogBuffer_RateLimit`(line 108-127)的假設與真實使用方式不符** — test 模擬「每行一次 ShouldEmit」,實際 logPump 是「每 flush 一次 ShouldEmit」。
|
|
||||||
>
|
|
||||||
> **建議**:要嘛把 `ShouldEmit(n int)` 改成接行數參數一次扣 n;要嘛在 logPump flush 時 `for i := 0; i < len(batch); i++ { if !ShouldEmit() { drop } }`。**不阻擋本 milestone**。
|
|
||||||
|
|
||||||
> **D.MAJ-1**(雖然影響不大,但屬於資料丟失 → 標 Major):**logPump 在 server 子程序剛 exit 時可能丟掉最後幾行 stderr(崩潰原因)**。
|
|
||||||
>
|
|
||||||
> **檔案/行**:`server_control.go:579-608`。
|
|
||||||
>
|
|
||||||
> **原因**:select 同時看 lineCh 與 scanDone。當 scanner goroutine 結束 → close(scanDone),**lineCh buffer 內可能仍有最多 128 行未處理**。Go select 在多個 ready case 隨機選擇,可能直接走 `<-scanDone` flush 後 return,把 lineCh 中剩餘的行**全數丟棄**,包括寫檔與 ring buffer。
|
|
||||||
>
|
|
||||||
> **後果**:server crash 時最後幾行 stderr(最有價值的 stack trace)有機率丟。
|
|
||||||
>
|
|
||||||
> **修法**:scanner goroutine 結束時 close(lineCh) 而不是 close(scanDone);main loop 用 `case line, ok := <-lineCh: if !ok { return }` 作為唯一退出條件。或更簡單:scanDone 收到後**先 drain lineCh** 再 return:
|
|
||||||
> ```go
|
|
||||||
> case <-scanDone:
|
|
||||||
> for {
|
|
||||||
> select {
|
|
||||||
> case line := <-lineCh:
|
|
||||||
> // 處理寫檔 + ring buffer + batch
|
|
||||||
> default:
|
|
||||||
> flush(); return
|
|
||||||
> }
|
|
||||||
> }
|
|
||||||
> ```
|
|
||||||
|
|
||||||
**Minor 6(D.MIN-2)**:scanner goroutine 內 `select { case lineCh <- text: default: lineCh <- text }` 寫法奇怪 — default 之後又 blocking send 同一個值,等同沒 default。建議直接 `lineCh <- text` 一行。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. OnBeforeClose handler(`main.go`)
|
|
||||||
|
|
||||||
| 檢查項 | 結論 | 備註 |
|
|
||||||
|--------|------|------|
|
|
||||||
| 呼叫關閉時觸發 graceful shutdown | ✅ | return false → Wails OnShutdown → app.shutdown → ctrl.Stop |
|
|
||||||
| return false 允許關閉 | ✅ | L34 |
|
|
||||||
| 不彈 confirm 對話框 | ✅ | 無對話框邏輯,符合 R5-2 |
|
|
||||||
|
|
||||||
**潛在 UX 風險(非 bug)**:Wails 視窗一旦開始 close,前端 JS 是否還能渲染 `shutdown:modal-show` 是不確定的(不同 OS 行為不同)。M8-5 控制台 UI 改寫時應實機驗證;現階段不阻擋。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. Notify 三平台
|
|
||||||
|
|
||||||
| 檔案 | build tag | 機制 | 結論 |
|
|
||||||
|------|----------|------|------|
|
|
||||||
| `notify_darwin.go` | `//go:build darwin` ✅ | osascript display notification + escape | ✅ |
|
|
||||||
| `notify_linux.go` | `//go:build linux` ✅ | notify-send -u critical -i dialog-error | ✅ |
|
|
||||||
| `notify_windows.go` | `//go:build windows` ✅ | PowerShell BurntToast + msg * fallback | ✅ |
|
|
||||||
|
|
||||||
| 檢查項 | 結論 | 備註 |
|
|
||||||
|--------|------|------|
|
|
||||||
| 三平台 build tag 齊全 | ✅ | 沒重複定義 / 沒缺失 |
|
|
||||||
| AppleScript escape | ✅ | 反斜線 + 雙引號 |
|
|
||||||
| PowerShell escape | ✅ | 單引號 → '' |
|
|
||||||
| fire-and-forget 呼叫 | ✅ | 呼叫端用 `go sendCrashNotification(...)` |
|
|
||||||
| 失敗只 log 不 fail | ✅ | 三平台都 fmt.Fprintf stderr |
|
|
||||||
|
|
||||||
**Minor 7(F.MIN-1)**:三平台都用 `cmd.Run()` 同步等子程序結束。雖然呼叫端用 `go` wrapper,但 goroutine 內若 osascript / powershell hang 住會 leak(極罕見)。**建議**:包一個 `context.WithTimeout(5*time.Second)` + `exec.CommandContext`,過期 Kill。本 milestone 可不修。
|
|
||||||
|
|
||||||
**Minor 8(F.MIN-2)**:notify_linux.go 假設 `notify-send` 一定在 PATH 上。雖然 prompt 提到「若不存在 log warning 不 fail」,實作確實不會 crash,**但** `exec.Command.Run()` 在 binary not found 時回的是 `*exec.Error`,輸出的 warning 對使用者不夠友善(純 path not found)。可接受,不修。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. Boot-ID + SkipPaths
|
|
||||||
|
|
||||||
| 檢查項 | 結論 | 備註 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `crypto/rand.Read` + `hex.EncodeToString` | ✅ | `system_handler.go:27-32` |
|
|
||||||
| 32 字元 hex | ✅ | smoke test 實測 `bd94ae7f976a6e526bd9840fc385b92d` |
|
|
||||||
| process-scoped(每次啟動新 ID) | ✅ | `NewSystemHandler` 內呼叫 `newBootID()` |
|
|
||||||
| `GET /api/system/boot-id` 註冊 | ✅ | `router.go:59` |
|
|
||||||
| Response shape | ✅ | `{success, data: {bootId, startedAt}}` |
|
|
||||||
| `broadcasterLoggerSkipPaths` 含 boot-id + health | ✅ | `router.go:120-123` |
|
|
||||||
| skip 邏輯只比對 path | ✅ | `router.go:140-142` |
|
|
||||||
| **Smoke test:兩個 endpoint 不寫 access log** | ✅ | 實測 stdout 只有 `[GIN] 200 ... GET /api/devices`,**沒有** boot-id 或 health 的 GIN log |
|
|
||||||
|
|
||||||
> **Minor 9(G.MIN-1)**:boot-id 產生位置偏離 TDD §9.1。TDD 寫的是「`server/main.go` 在啟動時產生 boot-id,**傳入** SystemHandler」,實作把 `newBootID()` 放在 `NewSystemHandler` 內部呼叫(`system_handler.go:42`)。功能完全等價,但讓單元測試難以注入固定 boot-id 做 deterministic test。**Minor 偏離規格**,可接受。
|
|
||||||
|
|
||||||
> **Major 3(G.MAJ-1)**:**TDD §8.1 + milestone-plan 要求 `server/main.go` 的 `shutdownFn` timeout 從 10 秒改成 6 秒,未改**。
|
|
||||||
>
|
|
||||||
> **檔案/行**:`server/main.go:166`。
|
|
||||||
>
|
|
||||||
> ```go
|
|
||||||
> ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> 應改為 `6*time.Second`。
|
|
||||||
>
|
|
||||||
> **後果**:Wails ctrl.Stop 在第 7 秒會 SIGKILL,但 server 自己內部還在 10 秒 graceful cleanup window 中 — 可能出現「server 還在 sync 檔案到一半被打斷」。對 v2 「7 + 1 秒 modal」UX 設計直接破壞。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. v1/v2 並存策略評估
|
|
||||||
|
|
||||||
實作保留了 `ServerProcess.stop()` / `kill()` / `watchServer` v1 路徑(`app.go:583-685`)+ v1 `shutdownGracePeriod = 5s` 常數,宣稱為「fallback」 + 「M8-5 前端改寫期間相容」。
|
|
||||||
|
|
||||||
**評估結論**:⚠️ **策略合理但需立即標記砍除時程**
|
|
||||||
|
|
||||||
| 問題 | 風險 | 結論 |
|
|
||||||
|------|------|------|
|
|
||||||
| v1/v2 路徑會被同時呼叫嗎? | 不會 | startup 只走 ctrl.Start → startServerV2;shutdown 只走 ctrl.Stop → stopGraceful。`app.stopServer()` 與 `App.startServer` 已被 dead code |
|
|
||||||
| v1 常數 `shutdownGracePeriod = 5s` 會被誤用嗎? | 低 | 只有 v1 ServerProcess.stop() 用到,已是 dead path |
|
|
||||||
| v1 `watchServer` 會被誤啟動嗎? | 低 | 只在 v1 startServer 的 dead path 啟動;新 startServerV2 走 watchServerV2 |
|
|
||||||
| reportFatal v1 路徑仍存在 | 中 | startup 失敗 / single-instance 失敗仍會走,這合理(startup 完全失敗時控制台還沒就緒,無 fallback 只能原生對話框) |
|
|
||||||
| 未來何時砍 | 未明確 | **必須**在 progress.md「未解決問題」記錄:M8-5 完成後**立即砍**全部 v1:`ServerProcess.stop/kill`、`watchServer`、`stopServer`、`shutdownGracePeriod`、`startServer` |
|
|
||||||
|
|
||||||
**Minor 10(H.MIN-1)**:`shutdown()`(app.go:222)的 fallback 路徑 `a.stopServer()` 是 dead code,因為 ctrl 在 startup 一定會被建立 — 直接刪 else 分支,避免讓未來的開發者誤以為兩條路徑都有效。
|
|
||||||
|
|
||||||
**Minor 11(H.MIN-2)**:`v1 watchServer` 與 `v2 watchServerV2` 內容 90% 相同,只差最後失敗的 reportFatal vs handleWatchFailure。等 M8-5 砍 v1 即可消除這份重複。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. Build / Test / Race detector 結果
|
|
||||||
|
|
||||||
```
|
|
||||||
cd server && go build ./... ✅ PASS
|
|
||||||
cd server && go vet ./... ✅ PASS
|
|
||||||
cd server && go test ./... ✅ PASS(device + model 既有 test)
|
|
||||||
cd server && go test -race ./... ✅ PASS
|
|
||||||
|
|
||||||
cd visiona-local && go build . ✅ PASS
|
|
||||||
cd visiona-local && go vet ./... ✅ PASS
|
|
||||||
cd visiona-local && go test -count=1 ./... ✅ PASS(20 tests 全綠)
|
|
||||||
cd visiona-local && go test -race ./... ✅ PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
**20 個 unit test 明細**:
|
|
||||||
- log_buffer_test.go:5 個(Append / Wrap / Reset / RateLimit / parseLogLevel)
|
|
||||||
- preferences_test.go:5 個(platform default / missing / corrupt / roundtrip / atomic rename)
|
|
||||||
- server_control_test.go:10 個(Initial / setState / Stop×2 / ForceKill / snapshotStatus / GetRecentLogs / GetSystemInfo / ClearLogs / GetServerStatusV2)
|
|
||||||
|
|
||||||
**race detector 沒抓到任何 race**,但要注意 unit test **沒有實際 spawn server binary**,因此 logPump↔stopGraceful 的 file handle race(C.MIN-2 / D.MAJ-1 / Major 1)必須靠人工 review 與 integration test 才能驗證。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## J. Smoke test 結果
|
|
||||||
|
|
||||||
執行:`go run server` 啟動真實 server,curl 各 endpoint。
|
|
||||||
|
|
||||||
| 步驟 | 結果 |
|
|
||||||
|------|------|
|
|
||||||
| 1. `curl /api/system/boot-id` | ✅ `{"data":{"bootId":"bd94ae7f976a6e526bd9840fc385b92d","startedAt":1776213642971},"success":true}`(32 字元 hex) |
|
|
||||||
| 2. `curl /api/system/health` | ✅ `{"status":"ok"}` |
|
|
||||||
| 3. `curl /api/devices` | ✅ 正常回應 |
|
|
||||||
| 4. **stdout 內 GIN access log** | ✅ **只有** `[GIN] 200 \| 137.674µs \| GET /api/devices`,**沒有** boot-id / health 的 access log |
|
|
||||||
| 5. 連續 4 次 poll boot-id 都回同一 ID(process-scoped) | ✅ |
|
|
||||||
|
|
||||||
**SkipPaths 機制完全符合 TDD §9.1a 與 milestone-plan 預期。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## K. 遺漏 / 誤解
|
|
||||||
|
|
||||||
> **Major 4(K.MAJ-1)**:**未實作 `server:shutdown-imminent` WebSocket 廣播**(milestone-plan #157,「Minor 4」明確要求)。
|
|
||||||
>
|
|
||||||
> **檔案/行**:`server_control.go:302-340`(stopGraceful 內應在 SIGTERM 前先廣播);server 端應有 `/ws/system` 或擴充 `/ws/server-logs` 來廣播 `server:shutdown-imminent`。
|
|
||||||
>
|
|
||||||
> **影響**:M8-9 的 web UI offline overlay race condition 防護機制完全缺席。M8-9 將會被迫補做、或 fall back 到「3 次 poll 失敗 = 15s 才顯示 overlay」的舊行為。
|
|
||||||
>
|
|
||||||
> **狀態**:**阻擋 M8-9,建議在 M8-4b 一併補做或拆獨立小 task**。
|
|
||||||
|
|
||||||
**Minor 12(K.MIN-1)**:milestone-plan #153 要求「`shutdownGracePeriod` 5 s → **7 s**」,實作改用新常數 `shutdownGraceV2 = 7s`、舊常數 5s 還在。功能等效但**字面違反 milestone-plan**。可接受作為 v1/v2 並存策略的副作用,但要在 H 段提到的「砍 v1」清單中一併處理。
|
|
||||||
|
|
||||||
**Minor 13(K.MIN-2)**:`ForceKillServer` 確認**不**發崩潰通知 — 走 `proc.forceKill()` 路徑直接 SIGKILL,不經過 handleWatchFailure,符合 prompt 要求。✅
|
|
||||||
|
|
||||||
**Minor 14(K.MIN-3)**:preferences 讀取失敗的 fallback 行為符合 TDD §11(不 fail、用 default)。✅
|
|
||||||
|
|
||||||
**Minor 15(K.MIN-4)**:notify 三平台 timeout 處理(F.MIN-1)— 已在 F 段提,不重複列。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L. 問題清單
|
|
||||||
|
|
||||||
### Major(阻擋後續 milestone)
|
|
||||||
|
|
||||||
| # | 檔案:行 | 問題 | 影響的 milestone | 修法 |
|
|
||||||
|---|---------|------|-----------------|------|
|
|
||||||
| MAJ-1 | server_control.go:198-229 (Stop), 251-265 (ForceKill) | `Stop()` / `ForceKill()` 不 cancel watchCancel,30 秒後誤觸 Error state + 發 OS 崩潰通知 | M8-5、M8-7 | Stop/ForceKill 進場後加 `if a.watchCancel != nil { a.watchCancel(); a.watchCancel = nil }` |
|
|
||||||
| MAJ-2 | server_control.go:269-291 | `handleWatchFailure` 不取 txMu,與 Stop/ForceKill race,可能把 Stopped 翻成 Error | M8-5 | 進場取 txMu,setState 前重新檢查 state;或 watchServerV2 ticker 內檢查 `c.State() != ServerStateRunning` 直接退出 |
|
|
||||||
| MAJ-3 | server/main.go:166 | shutdownFn timeout 仍是 10s,未對齊 TDD §8.1 + milestone-plan 的 6s | M8-5、M8-9 | 改 `6*time.Second` |
|
|
||||||
| MAJ-4 | server_control.go:302-340 + 缺 server WebSocket 廣播 | 未實作 `server:shutdown-imminent` 廣播(milestone-plan Minor 4) | M8-9 | server 端加 `/ws/system` 或擴充 `/ws/server-logs`;ctrl.Stop SIGTERM 前 HTTP/WS 觸發 |
|
|
||||||
| MAJ-5 | server_control.go:579-608 (logPump main loop) | scanDone 觸發後 lineCh 仍有 buffered 行未處理 → 資料丟失(含崩潰時最後幾行 stderr) | M8-5 (debug experience) | scanDone case 加 drain loop 或讓 scanner goroutine 自己 close lineCh |
|
|
||||||
|
|
||||||
### Minor(建議修,不阻擋)
|
|
||||||
|
|
||||||
| # | 檔案 | 問題 |
|
|
||||||
|---|------|------|
|
|
||||||
| MIN-1 | log_buffer.go:99-105 | Snapshot 從 i=0 走 size 圈,可改為從 (start+skip) 走 n 圈 |
|
|
||||||
| MIN-2 | log_buffer.go:139-151 | ShouldEmit CAS race:A 視窗 Store(0) 前 B 已 Add(1),極小機率多放 1 行 |
|
|
||||||
| MIN-3 | preferences.go:91 | 跨 filesystem rename 失敗時無 io.Copy fallback(罕見邊界) |
|
|
||||||
| MIN-4 | server_control.go:232-245 | Restart 拆兩段 txMu,中間有插隊窗口 |
|
|
||||||
| MIN-5 | server_control.go:302-340 | stopGraceful 結束時關 file 與 logPump goroutine 還在寫的時序 race |
|
|
||||||
| MIN-6 | server_control.go:556-562 | scanner goroutine `select { case lineCh<-text: default: lineCh<-text }` 寫法奇怪、等同 blocking |
|
|
||||||
| MIN-7 | notify_*.go | 三平台都用 cmd.Run() 無 timeout,極罕見情況下 hang 會 leak goroutine |
|
|
||||||
| MIN-8 | notify_linux.go | notify-send not in PATH 時 warning 不夠友善 |
|
|
||||||
| MIN-9 | system_handler.go:42 | newBootID 放在 handler 內呼叫,偏離 TDD §9.1 「main.go 注入」設計,不利於 deterministic test |
|
|
||||||
| MIN-10 | app.go:222-227 | shutdown() 的 v1 fallback 是 dead code,建議刪 |
|
|
||||||
| MIN-11 | app.go:583-622 | watchServer v1 與 watchServerV2 高度重複,待 M8-5 後砍 |
|
|
||||||
| MIN-12 | app.go:46 | shutdownGracePeriod = 5s 仍在,違反 milestone-plan #153 字面 |
|
|
||||||
| MIN-13 | log_buffer_test.go:108-127 | TestLogBuffer_RateLimit 假設「每行一次 ShouldEmit」,與 logPump 實際「每 batch 一次」不一致 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M. 結論
|
|
||||||
|
|
||||||
M8-4 的核心架構**正確且品質高**:
|
|
||||||
|
|
||||||
**做得好的地方(值得讚許):**
|
|
||||||
- 雙鎖機制 `txMu` + `mu` 設計清晰、race detector 全綠
|
|
||||||
- LogBuffer 的 ring buffer + atomic rate limit 結構漂亮
|
|
||||||
- Preferences atomic write-rename 含 tmp cleanup
|
|
||||||
- 三平台 notify 用 build tag 隔離乾淨、escape 處理到位
|
|
||||||
- boot-id 用 crypto/rand 不引 google/uuid,符合 TDD 定版決策
|
|
||||||
- broadcasterLogger SkipPaths 實測完美過 smoke test
|
|
||||||
- 20 unit test 全綠、race detector 全綠
|
|
||||||
- v1/v2 並存策略雖然增加維護成本,但邏輯上沒有衝突
|
|
||||||
- 文件註解極詳細,每個函式都標 TDD 出處
|
|
||||||
|
|
||||||
**必須處理的問題(阻擋後續 milestone):**
|
|
||||||
- **5 個 Major** — MAJ-1 ~ MAJ-5。其中 MAJ-1(watch leak 誤觸 Error)與 MAJ-2(handleWatchFailure race)是直接破壞 M8-5 UX 的 bug,**第二輪修改前必須處理**。MAJ-3(shutdownFn 6s)與 MAJ-4(shutdown-imminent 廣播)是純遺漏,補就好。MAJ-5(logPump 丟最後幾行)影響 debug 體驗。
|
|
||||||
- **15 個 Minor** — 大多可以放到 v1 砍除時一併處理,或當作技術債記錄。
|
|
||||||
|
|
||||||
**對 Orchestrator 的建議:**
|
|
||||||
|
|
||||||
1. **第二輪修改範圍**(必須):
|
|
||||||
- MAJ-1:Stop/ForceKill cancel watchCancel
|
|
||||||
- MAJ-2:handleWatchFailure 取 txMu + 二次檢查 state
|
|
||||||
- MAJ-3:server/main.go shutdownFn 6s
|
|
||||||
- MAJ-5:logPump scanDone 加 drain loop
|
|
||||||
|
|
||||||
2. **MAJ-4 處理時機**:M8-4b 一併做,因為它需要 server 端新增 `/ws/system` endpoint,與 startup-pipeline 的 event 設計可一起規劃。**或拆獨立 micro-task 立刻補做,不要等到 M8-9 才發現缺**。
|
|
||||||
|
|
||||||
3. **v1 砍除追蹤**:在 progress.md「未解決問題」加一條:「M8-4 v1 路徑(ServerProcess.stop/kill、watchServer、stopServer、shutdownGracePeriod、startServer)保留作為相容過渡,M8-5 完成後立即砍除(含相關 dead code 清理 MIN-10/11/12)」。
|
|
||||||
|
|
||||||
4. **整合測試**:M8-4 的 unit test 沒實際 spawn server binary,因此 logPump file handle race(MIN-5)、stopGraceful timing、preferences 跨檔案系統等只能靠 manual / integration test 驗證。建議在 M8-5 完成後安排一次完整 integration smoke test(Start → 噴 log → Stop → Restart → Force Kill → 模擬 crash → 各事件都要正確)。
|
|
||||||
|
|
||||||
**結論**:⚠️ **需小改後通過**。修完 5 個 Major 即可進 M8-4b / M8-5。Minor 不阻擋。
|
|
||||||
@ -1,229 +0,0 @@
|
|||||||
# Reviewer 審查 M8-4b 補丁(2026-04-15)
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
- 審查對象:M8-4b 補丁(3 Major + Stage 4 timeout 修復)
|
|
||||||
- 審查檔案:`visiona-local/startup_pipeline.go`(L385-423 新增 helpers)、`visiona-local/server_control.go`(L167-223 startInternal 守門、L630-652 probe timeout、L867-937 RestartStartupSequence + test hook)、`visiona-local/app.go`(L109-119 struct、L1676-1693 openBrowser var)、`visiona-local/startup_pipeline_test.go`(L393-660 4 個 regression test)
|
|
||||||
- 總結論:**✅ 通過** — 3 Major 全部正確修復、Stage 4 timeout 改好、4 個 regression test 完整覆蓋且全綠。Build/Vet/Test/Race(count=2)在 `visiona-local` 與 `server` 兩個 module 都 PASS。
|
|
||||||
- 3 Major 修復狀況:M-1 ✅ / M-2 ✅ / M-3 ✅
|
|
||||||
- 問題統計:Critical 0 / Major 0 / Minor 2 / Suggestion 1
|
|
||||||
- 阻擋 M8-10 嗎:**否**,可推進 M8-10。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. HasFailedStage helper 正確性
|
|
||||||
|
|
||||||
位置:`visiona-local/startup_pipeline.go` L385-404
|
|
||||||
|
|
||||||
- 邏輯正確 ✅:`current == -1`(`FailStage` 會把 current 設為 -1)或迴圈檢查 `stages[1..6].status == "failed"`(defensive path)。
|
|
||||||
- Lock 保護 ✅:`p.mu.Lock()` + defer `Unlock()`。`mu` 在 struct 是 `sync.Mutex`(L98),沒 RLock 可用 — 任務敘述提到的「RLock」不適用,用 `Lock` 是正確選擇。效能影響可忽略(只在 startInternal error 路徑讀一次)。
|
|
||||||
- 文件註解清楚 ✅:L386-391 解釋語義與對 M-1 的作用。
|
|
||||||
- Test 覆蓋 4 case ✅:`TestStartupPipeline_HasFailedStage`(L537-566)分別驗證:new / running / FailStage 後(current=-1)/ 某 stage status=failed 但 current 未同步(defensive)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. IsInColdStart helper 正確性
|
|
||||||
|
|
||||||
位置:`visiona-local/startup_pipeline.go` L406-423
|
|
||||||
|
|
||||||
- 邏輯正確 ✅:`current >= 1 && current <= startupTotalStages`(startupTotalStages=6),stage 7(ready)與 current=-1(failed)、current=0(未 Start)都回 false。
|
|
||||||
- Lock 保護 ✅:同 `HasFailedStage`,用 `Lock/Unlock`。
|
|
||||||
- Test 覆蓋邊界 ✅:`TestStartupPipeline_IsInColdStart`(L578-606)覆蓋 current ∈ {0, 1..6, 7, -1},迴圈確認 1..6 全 true,其餘全 false。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. startInternal error 分支守門
|
|
||||||
|
|
||||||
位置:`visiona-local/server_control.go` L167-194
|
|
||||||
|
|
||||||
- 正確用 `pipeline.HasFailedStage()` ✅(L176-179)。
|
|
||||||
- `pipeline == nil` fallback ✅:`pipelineHandled` 預設 false → 走原 setState + emit + sendCrashNotification 邏輯。
|
|
||||||
- 非冷啟動路徑(RestartServer 等,pipeline 已 ready current=7,若此刻 Start 失敗 HasFailedStage=false)fallback 正常發通知 ✅。
|
|
||||||
- 註解(L168-175)解釋完整:說明為什麼守門 + 哪些路徑仍需 fallback。
|
|
||||||
- 不破壞 RestartServer(走 `ctrl.Restart()` → `ctrl.Stop()` + `ctrl.Start()`)的錯誤處理 ✅:RestartServer 發生時 pipeline.current==7 或 0,`HasFailedStage()` 回 false。
|
|
||||||
|
|
||||||
**小提醒**(非 Major):此守門建立在「pipeline FailStage 一定早於 startInternal return err」的假設上。實作上 `startServerV2` 每個 stage 失敗都立即 `FailStage`(見 server_control.go L437-448 / L554-566),再 return err,順序正確。若未來新增一條「不透過 FailStage 的錯誤返回路徑」,守門會被繞過。建議記為 Minor 追蹤(見 §L)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. openBrowser 守門
|
|
||||||
|
|
||||||
位置:`visiona-local/server_control.go` L202-222
|
|
||||||
|
|
||||||
- `pipeline.IsInColdStart() == false` 才執行 R5-D3 openBrowser ✅(L213-216)。
|
|
||||||
- RestartServer 路徑(pipeline.current==7,`IsInColdStart()` 回 false)仍 openBrowser ✅。
|
|
||||||
- `pipeline == nil` 路徑 `inColdStart` 維持 false,fallback 仍 openBrowser ✅(L214-216)。
|
|
||||||
- 註解(L204-212)清楚列出三種場景(冷啟動 / RestartServer / RestartStartupSequence)的預期行為,可維護性高。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. openBrowser package-level var 改動
|
|
||||||
|
|
||||||
位置:`visiona-local/app.go` L1676-1693
|
|
||||||
|
|
||||||
- 改為 `var openBrowser = openBrowserExec` ✅(L1681)。預設值是實作函式,production 透過 var 呼叫只多一個 indirection,效能影響可忽略。
|
|
||||||
- 所有呼叫端透過 var 呼叫:`app.go` L268(runStartupStage5)、`server_control.go` L219(startInternal R5-D3)。Test 可替換 var 做 stub。
|
|
||||||
- `openBrowserExec` 保留為獨立函式(L1684-1693)作為預設實作,macOS/Windows/Linux 三分支原封不動 ✅。
|
|
||||||
- Test 重置良好 ✅:`TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce` 用 `defer func() { openBrowser = original }()`(L627)保證 test 結束還原。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. RestartStartupSequence test 重構
|
|
||||||
|
|
||||||
位置:`visiona-local/startup_pipeline_test.go` L393-522
|
|
||||||
|
|
||||||
- **直接呼叫 `a.RestartStartupSequence()`** ✅(L439)— 不是手動複製 5 步驟。
|
|
||||||
- 用 `restartStartFn` test hook 攔截 Step 6 ✅(L424-433),spy 記錄呼叫、檢查 sentinel 是否已清、snapshot 新 pipeline。
|
|
||||||
- `callLog` 驗證順序 ✅(L497-516):`pipelineCancel` 必須在 `startFn` 之前。
|
|
||||||
- 驗證新 pipeline 建立 ✅(L466-486):stage1=completed / stage2=running / current=2。
|
|
||||||
- `pipelineCancelFn` 的 `context.CancelFunc` 型別允許 assign 任意 `func()` ✅(L407-410)。
|
|
||||||
- 其他 side effect 驗證完整:proc 被清為 nil(L453-458 ForceKill 效果)、sentinel file 已刪除(L461-463)、啟動前 ctrl state 設為 Error 模擬 Retry 前置條件(L418)。
|
|
||||||
- 「若未來把 Step 4 移到 Step 2 之前」的概念驗證:test 雖無法直接抓 Step 4 ↔ Step 2 相對順序,但能抓「Step 4 在 Step 6 之前」(`sentinelClearedBeforeStart` L489-491),滿足原 Reviewer 對 M-3 的核心期待(驗證真的呼叫 method、不是手動複製)。
|
|
||||||
|
|
||||||
**Minor F-1**(見 §L):`newPipelineTestApp` 的 `a.ctx == nil`,所以 RestartStartupSequence Step 5 的 watcher goroutine 分支不會啟動,L518-521 的「清理新啟動的 watcher」註解有點誤導(實際上 pipelineCancelFn 已在 Step 1 被清為 nil,之後也沒有被重設)。無功能影響,純註解細節。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce
|
|
||||||
|
|
||||||
位置:`visiona-local/startup_pipeline_test.go` L615-660
|
|
||||||
|
|
||||||
- 正確 stub `openBrowser` var ✅(L621-627),defer 還原。
|
|
||||||
- `AutoOpenBrowser=true`(L618)確保 runStartupStage5 會走 openBrowser 分支(不 skip)。
|
|
||||||
- `restartStartFn` 內模擬 server 啟動成功:設 fake proc port=12345、setState(Running)、手動推進 pipeline stages 到 stage 5 running ✅(L630-645)。這讓 `runStartupStage5` 能從 `ctrl.proc.port` 取到 URL 並呼叫 openBrowser。
|
|
||||||
- 斷言 `openCount == 1` ✅(L652-654),錯誤訊息清楚標示 want 1。
|
|
||||||
- 驗證冷啟動路徑下「startInternal 的 R5-D3 open」不會發生 — 因為 restartStartFn 替換了 `ctrl.Start()`,完全 bypass startInternal,所以實際上這個 test 驗證的是 runStartupStage5 本身只會 open 一次。
|
|
||||||
|
|
||||||
**Minor G-1**(見 §L):此 test 沒有實際經過 startInternal 的 R5-D3 分支(restartStartFn 完全替換 ctrl.Start),所以嚴格來說它驗證的是「runStartupStage5 本身的 open 次數」,而不是「冷啟動中 startInternal + runStartupStage5 的 open 次數總和」。要完整驗證 M-2,需要一個能跑 startInternal 的整合 test(但 startInternal 會 spawn python server 無法 unit test)。現況可接受,因為 IsInColdStart 邏輯已被 B 節 `TestStartupPipeline_IsInColdStart` 單獨驗證,startInternal 的守門條件 `!inColdStart` 是純布林邏輯,靜態即可推得正確。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. Stage 4 probe timeout 2s
|
|
||||||
|
|
||||||
位置:`visiona-local/server_control.go` L630-652
|
|
||||||
|
|
||||||
- 註解說明原因 ✅:L634-636 解釋 TDD §3「秒回即算完成」+「2s 足以涵蓋正常 latency」。
|
|
||||||
- 不破壞既有 test ✅:Stage 4 相關 test 全綠(`go test -count=1` PASS)。
|
|
||||||
- 逾時仍算完成 ✅(L647-651 不論 err 或 status 都 CompleteStage(4)),符合 TDD §3 設計意圖。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. Build/Test/Race 結果
|
|
||||||
|
|
||||||
| 指令 | 結果 |
|
|
||||||
|------|------|
|
|
||||||
| `cd visiona-local && go build .` | PASS(無輸出) |
|
|
||||||
| `cd visiona-local && go vet ./...` | PASS(無輸出) |
|
|
||||||
| `cd visiona-local && go test -count=1 ./...` | PASS(8.210s) |
|
|
||||||
| `cd visiona-local && go test -race -count=2 ./...` | **PASS(16.225s)** |
|
|
||||||
| `cd server && go build ./...` | PASS |
|
|
||||||
| `cd server && go vet ./...` | PASS |
|
|
||||||
| `cd server && go test -count=1 ./...` | PASS(api/handlers/ws/device/model 全通過) |
|
|
||||||
| `cd server && go test -race ./...` | PASS |
|
|
||||||
|
|
||||||
4 個新 test 單獨跑 verbose:
|
|
||||||
```
|
|
||||||
--- PASS: TestStartupPipeline_RestartStartupSequence_StepsExecution (0.00s)
|
|
||||||
--- PASS: TestStartupPipeline_HasFailedStage (0.00s)
|
|
||||||
--- PASS: TestStartupPipeline_IsInColdStart (0.00s)
|
|
||||||
--- PASS: TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce (0.00s)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## J. 合併檢查
|
|
||||||
|
|
||||||
`git diff --stat` 看過全部變更,無衝突標記、無重複區塊。合併狀況:
|
|
||||||
|
|
||||||
- **M8-4 原版** + **M8-4 補丁**(Stop/ForceKill/handleWatchFailure/logPump):`ForceKill` L273-291 的 cancelWatcher 順序修復仍在,`RestartStartupSequence` Step 2 正確利用 ✅。
|
|
||||||
- **M8-4b 原版**(startServerV2 hook + probe + RestartStartupSequence):補丁新增 helpers 與守門邏輯未動到既有 pipeline struct 欄位與 stage hook 位置 ✅。
|
|
||||||
- **MAJ-4 補丁**(shutdown-imminent broadcast + notifyShutdownImminent):不觸及 startup pipeline / server_control 的 startInternal / RestartStartupSequence 區塊 ✅。
|
|
||||||
- `grep` 未見 `<<<<<<<` / `>>>>>>>` 合併標記。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## K. 3 Major 症狀驗證
|
|
||||||
|
|
||||||
### M-1(重複 OS 通知)
|
|
||||||
|
|
||||||
症狀:冷啟動 stage 3 失敗 → `emitError` 發通知 A → startInternal error 分支再發通知 B。
|
|
||||||
|
|
||||||
修復後的流程:
|
|
||||||
1. `startServerV2` 失敗 → `pipeline.FailStage(3, err)` → `emitError` 發通知「第 3 階段失敗」+ setState(Error) + current = -1
|
|
||||||
2. startInternal 收 err → `HasFailedStage()` 回 true → `pipelineHandled = true` → **skip** setState/emit/sendCrashNotification
|
|
||||||
3. 只剩一個通知 ✅
|
|
||||||
|
|
||||||
**症狀消失確認**:Major M-1 修復路徑正確,唯一前提是「startServerV2 的所有失敗路徑都走 FailStage」— 已確認 L437-448(stage 2)、L554-566(stage 3)、stage 4 只在 timeout 仍 complete 不 fail,符合條件。
|
|
||||||
|
|
||||||
### M-2(重複開瀏覽器)
|
|
||||||
|
|
||||||
症狀:冷啟動成功 → runStartupStage5 的 openBrowser 呼叫 1 次 + startInternal R5-D3 的 openBrowser 呼叫 1 次 = 2 次。
|
|
||||||
|
|
||||||
修復後的流程:
|
|
||||||
1. 冷啟動 → startServerV2 成功 → startInternal 檢查 `IsInColdStart()`(此時 current ∈ 1..6)→ 回 true → **skip** R5-D3 openBrowser
|
|
||||||
2. startServerV2 返回後,app.startup 呼叫 `runStartupStage5` → openBrowser 1 次 ✅
|
|
||||||
3. RestartServer 路徑:pipeline.current==7 → IsInColdStart 回 false → R5-D3 正常 open ✅
|
|
||||||
|
|
||||||
**症狀消失確認**:冷啟動 openBrowser 只被呼叫一次。`TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce` 驗證 openCount==1。
|
|
||||||
|
|
||||||
### M-3(Retry test 重構)
|
|
||||||
|
|
||||||
症狀:test 手動複製 Step 1-5,改步驟順序抓不到。
|
|
||||||
|
|
||||||
修復後:直接呼叫 `a.RestartStartupSequence()` + callLog spy。若未來調整步驟順序(cancel 晚於 start),callLog 順序斷言會 fail ✅;若把 sentinel 清除改到 start 之後(Step 4 → Step 6 之後),`sentinelClearedBeforeStart` 斷言會 fail ✅。
|
|
||||||
|
|
||||||
**概念驗證**:test 能抓到「cancel 必須早於 start」與「sentinel 必須在 start 前清」兩個順序不變量,涵蓋 M-3 的核心意圖。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L. 問題清單
|
|
||||||
|
|
||||||
### Critical
|
|
||||||
(無)
|
|
||||||
|
|
||||||
### Major
|
|
||||||
(無)
|
|
||||||
|
|
||||||
### Minor
|
|
||||||
|
|
||||||
| # | 檔案 | 行 | 問題 | 建議 |
|
|
||||||
|---|------|----|------|------|
|
|
||||||
| m-1 | `visiona-local/startup_pipeline_test.go` | L518-521 | 註解說「清理新啟動的 watcher goroutine」,但 `newPipelineTestApp` 的 `a.ctx == nil`,Step 5 的 watcher 分支不會啟動,pipelineCancelFn 在 Step 1 已被清為 nil。註解誤導但無功能影響 | 改註解為「若 ctx 存在則清理重建的 watcher(此 test 走 nil ctx 分支,為 safety)」 |
|
|
||||||
| m-2 | `visiona-local/server_control.go` | L167-194 | `pipelineHandled` 守門建立在「startServerV2 所有失敗路徑都走 FailStage」的隱性假設上,若未來新增一條不透過 FailStage return err 的路徑,M-1 症狀會復活 | 在 `startServerV2` 函式開頭加註解「所有 err return 前必須先 FailStage」;或改用 explicit flag(如 `a.ctrl.suppressFallbackError`)由 `emitError` 設,startInternal 讀後 clear |
|
|
||||||
|
|
||||||
### Suggestion
|
|
||||||
|
|
||||||
| # | 內容 |
|
|
||||||
|---|------|
|
|
||||||
| s-1 | `TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce` 嚴格來說驗證的是 runStartupStage5 本身只 open 一次(因為 restartStartFn 替換了 ctrl.Start,完全 bypass 了 startInternal 的 R5-D3 分支)。M-2 的守門邏輯(`IsInColdStart() == false` 才 open)是靠 `TestStartupPipeline_IsInColdStart` 單獨驗證的 pure boolean 邏輯。兩個 test 合起來足以推得正確性,但若希望更貼近「真實 M-2 症狀」,可另加一個 test 透過 `a.ctrl.startInternal` 直接呼叫(需要先在 ctrl 加 test-only hook 允許跳過 spawn),非必要 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M. 結論
|
|
||||||
|
|
||||||
**判定:✅ 通過**
|
|
||||||
|
|
||||||
補丁對 M8-4b 原版 Reviewer 提出的 3 Major 全部正確修復:
|
|
||||||
|
|
||||||
1. **M-1(重複通知)** — 新增 `HasFailedStage()` helper + `startInternal` error 分支守門,邏輯正確、lock 保護得當,regression test(`TestStartupPipeline_HasFailedStage`)覆蓋 4 case 全通過。
|
|
||||||
2. **M-2(重複開瀏覽器)** — 新增 `IsInColdStart()` helper + `startInternal` R5-D3 守門 + `openBrowser` 改 package-level var 以便 stub,regression test(`TestStartupPipeline_IsInColdStart` + `TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce`)驗證完整。
|
|
||||||
3. **M-3(Retry test 重構)** — 新增 `restartStartFn` test hook + 重構 `TestStartupPipeline_RestartStartupSequence_StepsExecution` 為直接呼叫 method + callLog spy 驗證順序,抓得到 cancel↔start 與 sentinel↔start 的順序不變量。
|
|
||||||
|
|
||||||
**Stage 4 probe timeout** 從 5s 改為 2s,註解說明符合 TDD 原意。
|
|
||||||
|
|
||||||
**工程品質**:
|
|
||||||
- Build / Vet / Test / Race(-count=2)在 visiona-local 與 server 兩個 module 全綠
|
|
||||||
- 合併乾淨,無衝突標記
|
|
||||||
- 註解詳盡(每個 helper 都有中文註解解釋「為什麼」與「哪些場景走 fallback」)
|
|
||||||
- Lock 使用正確(`sync.Mutex` + `Lock/Unlock`,沒有 race condition)
|
|
||||||
- Test hook 透過 struct field 注入,不污染正式路徑(`restartStartFn` 預設 nil)
|
|
||||||
|
|
||||||
**不阻擋 M8-10**。Minor 2 項 + Suggestion 1 項可記為技術債,不強制當下處理。
|
|
||||||
|
|
||||||
**優點**:
|
|
||||||
- Helper 命名清楚(`HasFailedStage` / `IsInColdStart` 語義一眼就懂)
|
|
||||||
- 守門邏輯對 `pipeline == nil` fallback 處理完整,不會因單測環境或非冷啟動路徑被誤傷
|
|
||||||
- 4 個 regression test 覆蓋面完整:helper 本身 + 實際 RestartStartupSequence 流程 + openBrowser 呼叫次數
|
|
||||||
- 透過改 `openBrowser` 為 package-level var 解決測試 stub 問題,是 Go idiomatic 的做法
|
|
||||||
- `restartStartFn` test hook 設計乾淨:預設 nil → 走正式 `a.ctrl.Start()`,單測替換即可,不會意外漏到 production
|
|
||||||
- 2s probe timeout 的註解說明了「為什麼不是 5s」— 文件化設計決策
|
|
||||||
@ -1,273 +0,0 @@
|
|||||||
# Reviewer 審查 M8-4b 啟動階段管線(2026-04-15)
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
- 審查對象:M8-4b(R5-E 6 階段啟動 pipeline + watcher + sentinel file + RestartStartupSequence)
|
|
||||||
- 審查檔案:`visiona-local/startup_pipeline.go`(394 行)、`startup_pipeline_test.go`(457 行)、`server/internal/api/ws/hub.go`(sentinel 機制)、`hub_sentinel_test.go`(3 tests)、`visiona-local/app.go`(startup / shutdown / runStartupStage5)、`visiona-local/server_control.go`(stage hooks / probeDeviceListAndComplete / RestartStartupSequence / ForceKill)、`server/main.go`(wiring)
|
|
||||||
- 總結論:**⚠️ 需小改** — 核心實作對齊 TDD v2/startup-pipeline.md,Build/Vet/Test/Race 全過,功能面沒有阻擋 M8-5 / M8-9 的問題;但發現 **2 個 Major UX 問題**(重複 OS 通知 + 重複開瀏覽器),建議在 M8-4b 收尾前順手修。
|
|
||||||
- 問題統計:Critical 0 / Major 2 / Minor 5 / Suggestion 3
|
|
||||||
- 阻擋後續 milestone 嗎:**否**。M8-5(前端控制台 UI)可直接使用現在的 event schema 與 `RestartStartupSequence` binding;M8-9(boot-id + 重連)不觸及 pipeline 內部,可平行推進。兩個 Major 屬 UX 層面,在 M8-10 end-to-end 驗收前修掉即可。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. StartupPipeline struct
|
|
||||||
|
|
||||||
| TDD §4 要求 | 實作位置 | 狀態 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 6 階段常量 / soft 20s / hard 60s / tick 1s | `startup_pipeline.go` L39-44 | ✅ |
|
|
||||||
| Event schema(`StartupProgressEvent` / `StageTimeoutEvent` / `ErrorEvent`)| L54-75 | ✅ 欄位命名、json tag 與 §1 一致 |
|
|
||||||
| Status 枚舉含 pending/running/completed/failed/skipped | L60, L82 | ✅ 和 §1.1 v2.1 新增 `skipped` 對齊 |
|
|
||||||
| 1-indexed stages 陣列 + `current` sentinel 值 | L99-101 | ✅ 0/1-6/7/-1 語義註解清楚 |
|
|
||||||
| `CompleteStage` 順序保護 + `current != stage` 忽略 | L138-163 | ✅ 符合 §4「重複呼叫或順序錯誤,忽略」 |
|
|
||||||
| `SkipStage` → 進下一階段 | L168-192 | ✅ 與 `CompleteStage` 對稱 |
|
|
||||||
| `FailStage` → `emitError` + `stopWatcher` | L200-213 | ✅ |
|
|
||||||
| `markReady` → emit `startup:ready` + `stopWatcher` | L216-226 | ✅ 成功路徑走同步 emit(註解有解釋) |
|
|
||||||
| `emitProgress` 用 goroutine 非阻塞 | L230-251 | ✅ 符合 §4 關鍵設計 3 |
|
|
||||||
| `emitError` 同步 `setState(Error)` + 非阻塞 OS 通知 | L255-277 | ✅ |
|
|
||||||
|
|
||||||
**符合度 100%**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. Watcher goroutine
|
|
||||||
|
|
||||||
| 行為 | 位置 | 狀態 |
|
|
||||||
|------|------|------|
|
|
||||||
| 每秒 tick + `ctx.Done` 退出 | L291-297 | ✅ |
|
|
||||||
| `current` 不在 1-6 範圍時 return | L300-303 | ✅ |
|
|
||||||
| 階段 6 每 tick 檢查 sentinel file | L313-318 | ✅ |
|
|
||||||
| `skipped` status / 階段 6 + AutoOpenBrowser=false 跳過 timeout | L321-327 | ✅ 雙條件都檢查 |
|
|
||||||
| Hard timeout 直接展開 mark failed + `emitError(total-timeout)` | L329-341 | ✅ cause 分流正確;不走 `FailStage` 以便 cause 設為 `total-timeout` |
|
|
||||||
| `softTimeoutEmitted` flag 每階段最多 emit 一次 | L348-362 | ✅ |
|
|
||||||
| `skipTimeout` continue 時不檢查 soft timeout | L343-345 | ✅ |
|
|
||||||
|
|
||||||
**Minor B-1**:`watcher` 讀取 `p.app.prefs.AutoOpenBrowser`(L325)未持 `a.mu`,但 `SetPreferences`(`server_control.go` L1062-1064)寫 `a.prefs` 時持 `a.mu`。因為 `Preferences` 是 struct value(含 `bool` + `string`),同時讀寫觸發 data race。實測環境罕見(使用者在 Starting state 改 pref),`go test -race` 沒觸發。建議 watcher 的 pref 讀取改走 getter 或先 snapshot。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. Stage hooks 插入位置
|
|
||||||
|
|
||||||
| Stage | 位置 | 狀態 |
|
|
||||||
|-------|------|------|
|
|
||||||
| 1 Wails 控制台 | `app.go` L224 `CompleteStage(1)`(在 `seedUserDataDir` 之後、`ctrl.Start` 之前)| ✅ 對齊 §3 |
|
|
||||||
| 2 Python runtime | `server_control.go` L437-448 `FailStage(2)` / `CompleteStage(2)` 夾 `ensurePythonRuntime` | ✅ |
|
|
||||||
| 3 Server spawn | L554-566 `FailStage(3)` / `CompleteStage(3)` 夾 `waitHealthy` | ✅ |
|
|
||||||
| 4 Device probe | L571-576 呼叫 `probeDeviceListAndComplete(port)` → HTTP GET `/api/devices` 5s timeout → 任何 response/err 都 `CompleteStage(4)` | ✅ 行為與 §3 描述一致(「秒回算完成」),但 5s timeout 過於保守 — 見 §I-2 |
|
|
||||||
| 5 Open browser | `app.go` L243-267 `runStartupStage5()` helper:`AutoOpenBrowser=false` → `SkipStage(5)`,`true` → `openBrowser(url)` + `CompleteStage(5)` | ⚠️ **見 Major C-1** |
|
|
||||||
| 6 WebSocket ready | 由 watcher poll sentinel file 觸發 | ✅ |
|
|
||||||
|
|
||||||
**Major C-1:瀏覽器會被開兩次**
|
|
||||||
`runStartupStage5`(app.go L262)呼叫 `openBrowser(url)`,但 `startInternal`(server_control.go L188-194)的 R5-D3 邏輯在 Start 成功後也 `openBrowser(url)`。冷啟動路徑會執行兩次 `openBrowser`:
|
|
||||||
- macOS:`open http://...` 同 URL 兩次通常聚合到同一 tab,但取決於瀏覽器
|
|
||||||
- Linux:`xdg-open` 可能開兩個 tab
|
|
||||||
- Windows:`start` 行為不一致
|
|
||||||
|
|
||||||
建議的修法(二擇一):
|
|
||||||
1. `startInternal` 在 `a.startupPipeline != nil && a.startupPipeline.current >= 0 && a.startupPipeline.current <= startupTotalStages`(冷啟動中)時跳過自己的 `openBrowser`,由 `runStartupStage5` 負責;`RestartServer` 情境下 pipeline 已 ready(`current==7`),R5-D3 仍執行。
|
|
||||||
2. 反過來:`runStartupStage5` 只負責 `CompleteStage(5)`,實際 open 由 startInternal 處理 — 但這樣做跟 TDD §3 階段 5 的語義(「Open browser」=呼叫 OpenInBrowser 返回)不符。
|
|
||||||
推薦方案 1。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Sentinel file 機制
|
|
||||||
|
|
||||||
| 項目 | 位置 | 狀態 |
|
|
||||||
|------|------|------|
|
|
||||||
| `Hub.writeStartupSentinel` 用 `sync.Once` | `hub.go` L79-98 | ✅ |
|
|
||||||
| Register channel 第一次收到 Subscription 觸發 | L112 | ✅ 在 `h.mu.Unlock()` 之後呼叫(避免重入) |
|
|
||||||
| 檔案內容 `bootId=... ts=...` | L95 | ✅ 符合 §3 |
|
|
||||||
| 清檔時機:startup 早期(`app.go` L175)+ shutdown(L299)+ RestartStartupSequence Step 4(L844)| | ✅ 三處都清,符合 §3 的 best-effort 策略 |
|
|
||||||
| `Hub.SetStartupSentinel` 從 `server/main.go` L133 注入 `cfg.DataDir`(L101)| | ✅ |
|
|
||||||
| DataDir 空值時 disable | `hub.go` L85-87 | ✅ 有單測驗證(`DisabledWhenDataDirEmpty`)|
|
|
||||||
|
|
||||||
**Minor D-1**:`writeStartupSentinel` 取 `h.mu.RLock()` 保護 `sentinelDataDir`,但 `SetStartupSentinel`(L68-72)用 Write lock — 這個 lock 保護與 `rooms` map 的 lock 共用。功能上正確但概念上有點混搭(同一把 lock 同時保護「訂閱狀態」與「初始化一次的 dataDir」),未來可用獨立 sync.Once 或 atomic.Pointer 分離。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. RestartStartupSequence 5 步驟
|
|
||||||
|
|
||||||
| Step | 位置 | 狀態 |
|
|
||||||
|------|------|------|
|
|
||||||
| 1. cancel watcher goroutine | `server_control.go` L832-835 `a.pipelineCancelFn()` + 清 nil | ✅ |
|
|
||||||
| 2. ForceKill server subprocess | L838 `ctrl.ForceKill()`,該 method 在 L273-291 已實作(cancel watcher → clear proc → `proc.forceKill()` → `setState(Stopped)`)| ✅ |
|
|
||||||
| 3. Reset state machine to Stopped | L841 `setState(Stopped, "")` | ✅ 雖與 Step 2 結尾重複,但 defensive 沒壞處 |
|
|
||||||
| 4. Clean sentinel file | L844 `removeSentinelFile(a.dataDir)` | ✅ 符合 §3 明確要求「critical — 否則階段 6 會誤判瞬間完成」 |
|
|
||||||
| 5. 重建 `StartupPipeline` + stage 1 直接 complete + watcher + stage 2 running + `ctrl.Start()` + `runStartupStage5()` | L847-887 | ✅ 符合 §8.1 |
|
|
||||||
| Stage 1 不重跑 | L851-858 直接寫 `stages[1].status = "completed"` + emitProgress | ✅ |
|
|
||||||
| Binding 暴露 | Wails binding 是整個 App(`main.go` L35-37),所有 exported method 自動暴露 | ✅ `RestartStartupSequence() error` 符合前端呼叫介面 |
|
|
||||||
|
|
||||||
**Minor E-1**:Step 5 手動操作 `a.startupPipeline.mu` 和 internal fields(`stages[1].status = "completed"` 等),繞過了 struct 的封裝 API。未來改 `stageState` 欄位或 status 語義時,這段極容易 drift。建議把這段封裝為 `(*StartupPipeline).forceCompleteStage1And2Running(time.Time)` 或類似方法,把操作內部狀態的邏輯收進 pipeline 自己的 file。
|
|
||||||
|
|
||||||
**Minor E-2**:Step 5 L865 設 `a.startupPipeline.watcherCancel = cancel` 同一個 cancel 也存在 `a.pipelineCancelFn`。`FailStage → stopWatcher → watcherCancel()` 會 cancel,但 `a.pipelineCancelFn` 不會被 nil-out → 下次 Retry 執行 Step 1 會呼叫已 cancelled 的 func(`context.CancelFunc` 文件保證 idempotent,OK),然後清 nil。功能正確,但語義上「cancel 執行後清 nil」一致性不足。可接受。
|
|
||||||
|
|
||||||
**Minor E-3**:`RestartStartupSequence` 在 `a.ctx == nil` 情境下(單測走這條)不啟動 watcher,正常執行環境下 `a.ctx` 一定有值,但如果未來 ctx 時序改變,可能踩到。建議在 `a.ctx == nil` 時 return error 而非 silent skip watcher。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. Stage 5/6 skip 邏輯
|
|
||||||
|
|
||||||
| 規則 | 驗證 |
|
|
||||||
|------|------|
|
|
||||||
| Linux + `AutoOpenBrowser=false` → Stage 5 skipped | ✅ `preferences.go` L42 `DefaultPreferences()` 依 GOOS 把 Linux 設 false;`runStartupStage5` L247-250 呼叫 `SkipStage(5)` |
|
|
||||||
| `AutoOpenBrowser=false` → Stage 6 不 trigger timeout | ✅ `startup_pipeline.go` L325-327 skipTimeout 條件覆蓋 hard + soft |
|
|
||||||
| `skipped` 狀態 emit progress event | ✅ `SkipStage` L178 呼叫 `emitProgress(stage)`,status 已是 `"skipped"` |
|
|
||||||
| Watcher 在 stage 6 + AutoOpenBrowser=false 時不誤判 hard timeout | ✅ 有單測 `TestStartupPipeline_Watcher_SkippedStageNoTimeout`(set 70s sinceTotal 不觸發) |
|
|
||||||
|
|
||||||
一個**設計問題**(非 bug):`AutoOpenBrowser=false` + 使用者永遠不手動開瀏覽器時,pipeline 永遠停在 stage 6 running → Startup panel 永遠不淡出。TDD §7 驗收條件 case 7 只說「階段 5 立即 complete」,沒說 panel 該什麼時候淡出。M8-5 前端設計需要補一個「AutoOpenBrowser=false 時直接把 panel 淡出 + 顯示『伺服器已就緒,請點 Open in Browser』」的流程。**這不是 M8-4b 的 bug,是 M8-5 要處理的 UX。** 建議 M8-5 Agent 啟動前提醒。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. 與 M8-4 補丁合併狀況
|
|
||||||
|
|
||||||
兩個並行改動:
|
|
||||||
- **M8-4 補丁**碰 `Stop()` / `ForceKill()` / `handleWatchFailure()` / `logPump()`(失敗與關閉路徑)
|
|
||||||
- **M8-4b** 碰 `startServerV2` 中間 hook 點 + 新增 `probeDeviceListAndComplete` + `RestartStartupSequence`(成功路徑)
|
|
||||||
|
|
||||||
合併狀況:
|
|
||||||
- `ForceKill`(L273-291)的 M8-4 修復(MAJ-1:`cancelWatcher` 先 cancel 避免 30s 後翻 Error)仍存在並被 `RestartStartupSequence` Step 2 正確利用。✅
|
|
||||||
- `handleWatchFailure`(L305-337)的 MAJ-2 修復(持 `txMu` + 檢查 state != Running 則 return)不被 M8-4b 直接依賴,但 `RestartStartupSequence` 的 ForceKill 路徑依賴「watcher 先被 cancel」的順序,兩個修復方向一致。✅
|
|
||||||
- `startServerV2` 沒被 M8-4 碰,M8-4b 只新增 pipeline hook,不破壞現有流程。✅
|
|
||||||
|
|
||||||
`cd visiona-local && go build .` → PASS
|
|
||||||
`cd visiona-local && go vet ./...` → PASS
|
|
||||||
`cd visiona-local && go test ./...` → PASS(7.263s)
|
|
||||||
`cd visiona-local && go test -race -count=2 ./...` → PASS(15.122s)
|
|
||||||
`cd server && go build ./...` → PASS
|
|
||||||
`cd server && go vet ./...` → PASS
|
|
||||||
`cd server && go test ./...` → PASS
|
|
||||||
`cd server && go test -race ./...` → PASS
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. Build / Test / Race detector
|
|
||||||
|
|
||||||
全部 PASS(見 §G 尾)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. Agent 觀察評估
|
|
||||||
|
|
||||||
### I-1 重複 OS notification — **Major I-1,真的會發生**
|
|
||||||
|
|
||||||
流程:
|
|
||||||
1. `startServerV2` 失敗(例如 stage 3 `waitHealthy`)→ L559 `pipeline.FailStage(3, err)`
|
|
||||||
2. `FailStage` → `emitError(3, err, "stage-failure")` → `startup_pipeline.go` L268-276:
|
|
||||||
- `c.app.ctrl.setState(ServerStateError, err.Error())`
|
|
||||||
- `go sendCrashNotification("visionA Local — 啟動失敗", "第 3 階段失敗:...")` ← **通知 1**
|
|
||||||
3. `startServerV2` return err → `startInternal` L166 收到 err
|
|
||||||
4. `startInternal` L168-180:
|
|
||||||
- 再次 `setState(Error, err.Error())`
|
|
||||||
- emit `server:error` event
|
|
||||||
- `go sendCrashNotification("visionA Local — Server 啟動失敗", "請打開 visionA Local 查看錯誤詳情或按 Restart 重試。")` ← **通知 2**
|
|
||||||
|
|
||||||
兩則通知 title 和 body **都不同**,使用者會以為是兩個獨立錯誤;`setState(Error)` 被呼叫兩次(值相同、前端收到重複 `server:state-change` event,但 payload 一樣不致命)。
|
|
||||||
|
|
||||||
**建議修法**:在 `startInternal` L168-180 的 error 分支加守門:
|
|
||||||
```go
|
|
||||||
// 若 pipeline 已 FailStage 過,不重複發通知與 setState(pipeline 已處理)
|
|
||||||
if c.app.startupPipeline == nil || c.app.startupPipeline.current != -1 {
|
|
||||||
c.setState(ServerStateError, err.Error())
|
|
||||||
if c.app.ctx != nil {
|
|
||||||
wailsRuntime.EventsEmit(c.app.ctx, "server:error", ...)
|
|
||||||
}
|
|
||||||
go sendCrashNotification(...)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
或 `emitError` 設一個 `ctrl.suppressNextErrorNotification` flag,`startInternal` 收到 err 時若 flag 為 true 則 clear 並 skip 通知。後者耦合小。**阻擋 UX 可接受度,建議本次 M8-4b 一併修。**
|
|
||||||
|
|
||||||
### I-2 Stage 4 probe 5s timeout — **Minor**
|
|
||||||
|
|
||||||
TDD §3 原文「GET /api/devices 第一次收到 response(無論是否有硬體,秒回即算完成)」。實作用 `http.Client{Timeout: 5 * time.Second}` 並把 timeout err 也視為完成。
|
|
||||||
|
|
||||||
評估:
|
|
||||||
- 實測情境:`/api/devices` handler 讀內部 registry,ms 級就回,5s 幾乎不會用到
|
|
||||||
- 極端情境:deviceMgr Start 時非同步 USB 掃描若 block 主 goroutine(不太可能),handler 可能慢 >1s
|
|
||||||
- 5s 對 UX 來說:若硬體 driver 卡住而 health check 已 200,使用者會額外等 5s 看到 stage 4 才 complete。但因為 stage 4 timeout 也算完成,不 fail,只是卡視覺進度
|
|
||||||
- 對比 TDD「秒回」原意:5s 保守
|
|
||||||
|
|
||||||
**建議**:`Timeout: 2 * time.Second`。2s 足以涵蓋正常業務 latency 且符合 TDD 原意。Minor 優化,不阻擋。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## J. 測試品質
|
|
||||||
|
|
||||||
14 個 pipeline test + 3 個 hub sentinel test 覆蓋:
|
|
||||||
|
|
||||||
| 項目 | 測試 | 狀態 |
|
|
||||||
|------|------|------|
|
|
||||||
| CompleteStage 正常推進 | `CompleteStage_AdvancesToNext` | ✅ |
|
|
||||||
| CompleteStage 順序錯誤忽略 | `OutOfOrder_Ignored` | ✅ |
|
|
||||||
| CompleteStage 最後階段 → markReady | `LastStageMarksReady` | ✅ |
|
|
||||||
| SkipStage | `SkipStage_AdvancesAndMarksSkipped` | ✅ |
|
|
||||||
| FailStage | `FailStage_StopsPipeline` | ✅ |
|
|
||||||
| Soft timeout(mock 時間)| `Watcher_SoftTimeout` | ✅ 用 `startedAt = now.Add(-25s)` 加速 |
|
|
||||||
| Hard timeout(mock 時間)| `Watcher_HardTimeout` | ✅ 不是真的跑 60s |
|
|
||||||
| AutoOpenBrowser=false + stage 6 | `Watcher_SkippedStageNoTimeout` | ✅ |
|
|
||||||
| skipped status bypass | `Watcher_SkippedStatusBypassesTimeout` | ✅ |
|
|
||||||
| Sentinel file 偵測 | `CheckSentinelFile` / `Stage6CompletesOnSentinel` | ✅ |
|
|
||||||
| removeSentinelFile | `RemoveSentinelFile` | ✅ 驗證 idempotent + 空 dataDir |
|
|
||||||
| stopWatcher idempotent | `StopWatcher_Idempotent` | ✅ |
|
|
||||||
| Hub sentinel | 3 test(first register / only once / disabled) | ✅ |
|
|
||||||
|
|
||||||
**Major J-1**:`TestStartupPipeline_RestartStartupSequence_StepsExecution`(L386-439)**沒有呼叫 `RestartStartupSequence` method 本身**,而是把 Step 1-5 的邏輯**手動複製一遍**再驗證 side effect。這個測試保護性很弱:未來改 `RestartStartupSequence` 內部順序(例如把 Step 3 `setState(Stopped)` 拿掉 / 改順序),這個測試還是會過。建議重構為:把 Step 6 `ctrl.Start()` 的部分透過測試 double 攔截(例如改測 `app.ctrl.Start` 前的狀態 snapshot),直接呼叫 `a.RestartStartupSequence()` 驗證 Step 1-5 真的執行。
|
|
||||||
|
|
||||||
**Minor J-1**:沒有測試 race 情境「RestartStartupSequence 執行期間使用者再按 Retry」。雖然 `pipelineCancelFn()` 是 idempotent、`NewStartupPipeline` 是新 instance 不會 double free,但沒 test 保護。建議加一個 `TestRestartStartupSequence_Concurrent` 驗證連續呼叫兩次安全。
|
|
||||||
|
|
||||||
**Minor J-2**:沒有驗證 `emitProgress` 的 goroutine 路徑 — 因為測試環境 `a.ctx == nil` 讓 emit 走 short-circuit,只能間接驗證內部 state。可接受(Wails runtime 在單測中無法 mock)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## K. 問題清單
|
|
||||||
|
|
||||||
### Critical
|
|
||||||
|
|
||||||
(無)
|
|
||||||
|
|
||||||
### Major
|
|
||||||
|
|
||||||
| # | 檔案 | 行 | 問題 | 建議修法 |
|
|
||||||
|---|------|----|------|---------|
|
|
||||||
| M-1 | `visiona-local/startup_pipeline.go` + `server_control.go` | `emitError` L272-276 + `startInternal` L168-179 | 啟動失敗時 OS 通知發兩次、標題內文不同,使用者誤以為是兩個獨立錯誤 | `startInternal` error 分支檢查 `startupPipeline.current == -1`(代表 pipeline 已 FailStage)時 skip `setState/emit/sendCrashNotification`;或在 `emitError` 設 suppress flag 讓後續路徑 skip |
|
|
||||||
| M-2 | `visiona-local/app.go` + `server_control.go` | `runStartupStage5` L262 + `startInternal` L188-194 | 冷啟動時 `openBrowser` 被呼叫兩次(R5-D3 R5-E stage 5 hook 重複) | `startInternal` 的 R5-D3 分支檢查「pipeline 是否在冷啟動中」(`current >= 1 && current <= 6`),若是則 skip,由 `runStartupStage5` 統一負責 |
|
|
||||||
| M-3 | `visiona-local/startup_pipeline_test.go` | L386-439 | `TestStartupPipeline_RestartStartupSequence_StepsExecution` 手動複製實作邏輯,未呼叫 method 本身,保護性弱 | 重構為直接呼叫 `a.RestartStartupSequence()`;把 `ctrl.Start()` 的 spawn 用環境旗標 skip(或 stub)使測試可執行 |
|
|
||||||
|
|
||||||
### Minor
|
|
||||||
|
|
||||||
| # | 檔案 | 行 | 問題 | 建議 |
|
|
||||||
|---|------|----|------|------|
|
|
||||||
| m-1 | `startup_pipeline.go` | L325 | watcher 讀 `a.prefs.AutoOpenBrowser` 未持 `a.mu`,與 `SetPreferences` 有潛在 race | 增加 getter `(a *App) preferencesSnapshot() Preferences`(內部持 `a.mu`),watcher 改呼叫 getter |
|
|
||||||
| m-2 | `server_control.go` | L847-887 `RestartStartupSequence` | 手動操作 pipeline internal fields(`stages[1].status = "completed"` 等),繞過封裝 | 封裝為 `(*StartupPipeline).RestartFromStage2(ctx, cancelStorer)` method |
|
|
||||||
| m-3 | `server_control.go` | L596-611 `probeDeviceListAndComplete` | HTTP timeout 5s 比 TDD「秒回」保守 | 改 `Timeout: 2 * time.Second` |
|
|
||||||
| m-4 | `server_control.go` | L862-868 `RestartStartupSequence` | `a.ctx == nil` 時 silent skip watcher,實際執行環境不會觸發但語義不清 | `a.ctx == nil` 時 return error `"app context not ready"` |
|
|
||||||
| m-5 | `startup_pipeline_test.go` | — | 沒有驗證 `RestartStartupSequence` 連續呼叫(race)| 加 `TestRestartStartupSequence_Concurrent`:`wg.Add(2); go restart(); go restart(); wg.Wait()` 驗證不 panic + 單一 pipeline instance 存活 |
|
|
||||||
|
|
||||||
### Suggestion
|
|
||||||
|
|
||||||
| # | 內容 |
|
|
||||||
|---|------|
|
|
||||||
| s-1 | `hub.go` `sentinelDataDir` 和 `sentinelOnce` 共用 `Hub.mu`,概念上混搭。可改用獨立 `sync.Once` 或 `atomic.Value` 分離初始化狀態與訂閱狀態 |
|
|
||||||
| s-2 | `watcher` 的 `skipTimeout` 判斷邏輯可抽成 helper `(p *StartupPipeline) shouldSkipTimeout(stage int, status string) bool`,提升可讀性 |
|
|
||||||
| s-3 | 失敗時的 OS 通知可考慮改成 in-app modal + OS 通知**擇一**(OS 通知常被使用者忽略),搭配 Wails 原生 dialog 效果更好 — 但這超出 M8-4b 範圍,屬於 M8-5 / M8-10 UX 階段決定 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L. 結論
|
|
||||||
|
|
||||||
**判定:⚠️ 需小改**
|
|
||||||
|
|
||||||
M8-4b 的核心工程實作(StartupPipeline struct / 6 階段 hook / watcher / sentinel file / RestartStartupSequence)完整對齊 TDD v2/startup-pipeline.md v2.1,Build/Vet/Test/Race 4 個平台全通過,合併 M8-4 補丁乾淨無衝突。
|
|
||||||
|
|
||||||
**阻擋 M8-5 / M8-9 嗎:否。** 目前的 event schema、binding 與 sentinel 機制已經足夠 M8-5(Wails 控制台 UI 訂閱事件)與 M8-9(boot-id 重連)直接開工。
|
|
||||||
|
|
||||||
**建議的後續動作**:
|
|
||||||
1. **M8-4b Agent 再跑一輪**,修 Major M-1(重複通知)+ M-2(重複開瀏覽器)+ M-3(Retry test 重構)。這三項都是工時小(< 30 分鐘)但影響使用者第一眼觀感的 UX 問題,在 M8-10 前必修。
|
|
||||||
2. **Orchestrator 交棒 M8-5 時**提醒 Frontend Agent:AutoOpenBrowser=false + stage 6 永遠 running 的情境下,startup panel 該如何淡出 / 顯示「請手動開瀏覽器」的 CTA。這個 UX 決定不屬於 M8-4b 範疇,但要在 M8-5 啟動前搞清楚。
|
|
||||||
3. Minor 5 項 + Suggestion 3 項可記為技術債,不強制 M8-4b 處理。
|
|
||||||
|
|
||||||
**優點**:
|
|
||||||
- TDD §4 結構完整重現,status 列舉 / 欄位 / emit 策略全部符合
|
|
||||||
- `skipped` 狀態在 watcher + CompleteStage/SkipStage 的處理一致,邊界條件清楚
|
|
||||||
- Test 用 mock 時間(`startedAt.Add(-Xs)`)加速 soft/hard timeout 驗證,沒有真跑 60 秒
|
|
||||||
- Sentinel file 三個清檔時機(startup 早期 / shutdown / Retry)覆蓋完整
|
|
||||||
- 與 M8-4 補丁(ForceKill / handleWatchFailure 的 MAJ-1/MAJ-2 修復)合併乾淨,`RestartStartupSequence` 正確利用了 ForceKill 的 cancel watcher 修復
|
|
||||||
- 測試覆蓋面積大(17 個 test),邊界條件完整
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
# Reviewer 審查 M8-5 兩個補丁(2026-04-15)
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
- **補丁 A(Stage 6 Manual CTA)**:✅ **通過**。`manualMode` 狀態機、`enterManualMode` 觸發、`onManualModeChange` pub-sub、stage 6 manual hint description、stage 5 skipped label、CTA pulse、dark / reduced-motion fallback 全部對齊 Design Spec v2.1 §4.1 §7。i18n 用既有 key 無新增。
|
|
||||||
- **補丁 B(ServerState + server:error payload)**:✅ **通過**。`control-panel.js` 匯出 6 個 lowercase 常數、`data-state` / dot class 不再 `.toLowerCase()`;`app.js` import `STATE_ERROR`、`server:error` 讀 `payload.reason` 並附註 Go source of truth 註解。
|
|
||||||
- **是否阻擋 M8-10**:**不阻擋**。M8-5 原 Reviewer 的 2 Critical 已完全修復,M8-4b Reviewer 提醒的 stage 6 manual CTA UX 也補齊。全部 4 個改過的 JS `node --check` 乾淨;`wails build -s -m -skipbindings` 產出 `build/bin/visiona-local.app`(6.272s)。Minor 層級問題均為 Suggestion。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. Stage 6 Manual CTA(補丁 A)
|
|
||||||
|
|
||||||
### A1. manualMode 狀態機
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| `enterManualMode()` 在 stage=5 status="skipped" 時觸發 | ✅ | `startup-panel.js:182-184`「`if (n === 5 && stages[5].status === 'skipped') enterManualMode()`」 |
|
|
||||||
| `manualMode` toggle 正確 | ✅ | `startup-panel.js:16` module-scope flag;`enterManualMode` L216 幂等(若已進入直接 return)→ L217 setTrue;`hideStartupPanel` L163-166 reset;stage 6 completed L187-193 reset |
|
|
||||||
| `manualModeListeners` pub-sub | ✅ | L18 `Set`;L19-22 `onManualModeChange(fn)` 回傳 unsubscribe;L23-27 `emitManualMode` try/catch 保護 listener 拋錯 |
|
|
||||||
| `hideStartupPanel` reset 清 manualHint | ✅ | L160-162 重置 stages[1..6](含 `manualHint: false`),L163-166 若 `manualMode===true` 則 toggle 並 emit false |
|
|
||||||
|
|
||||||
### A2. stage 6 manualHint description
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| `paintStageRow` 偵測 `stage===6 && manualHint` | ✅ | `startup-panel.js:88-89`「`else if (stage === 6 && st.manualHint) labelSecondary.textContent = t('startup.stage.6.manualHint')`」 |
|
|
||||||
| `stage===5 && status==='skipped'` → `startup.stage.5.skipped.label` | ✅ | L86-87 |
|
|
||||||
| `markStageTimeout` manualMode + stage 6 時忽略 soft timeout | ✅ | L242「`if (n === 6 && stages[6].manualHint) return`」;與 Design §4.1「不套 20 秒 retry hint」一致 |
|
|
||||||
| `paintStageRow` 的 slow hint 也避開 manual hint | ✅ | L107「`if (st.slow && st.status === 'running' && !st.manualHint)`」雙重保險 |
|
|
||||||
|
|
||||||
### A3. i18n key 使用
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| 無新增 key | ✅ | 全用既有 3 個:`startup.stage.6.manualHint` / `startup.stage.5.skipped.label` / `startup.status.skipped` |
|
|
||||||
| 既有 key 存在雙語 | ✅ | `i18n.js:61,64,69`(zh-TW)+ `i18n.js:140,143,148`(en),與 Design §7 表格對齊 |
|
|
||||||
|
|
||||||
### A4. CTA pulse
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| `setPrimaryCTAPulse(true/false)` add/remove class | ✅ | `control-panel.js:105-113`;取 `#btn-open-browser` 後 `classList.add/remove('pulse-cta')` |
|
|
||||||
| app.js 訂閱 `onManualModeChange` → 驅動 pulse | ✅ | `app.js:32-33` import;`app.js:42` import `onManualModeChange`;`app.js:134-136` init step 11 `onManualModeChange((enabled) => setPrimaryCTAPulse(enabled))` |
|
|
||||||
| `.pulse-cta` CSS 正確 | ✅ | `style.css:205-216` `@keyframes ctaPulse` 1.8s ease-in-out infinite,box-shadow 0px→8px 漸散(符合 material-style 脈衝) |
|
|
||||||
| `:not([disabled])` 選擇器 | ✅ | `style.css:205` `.btn.pulse-cta:not([disabled])` — 按鈕 disabled 時不做動畫(避免 stage 5 skipped 但 server state 還沒到 running 的極短窗口裡 pulse disabled 按鈕) |
|
|
||||||
| dark mode 變體 | ✅ | `style.css:217-222` `@media (prefers-color-scheme: dark)` 覆寫 `@keyframes ctaPulse` 為較亮藍(`rgba(59,130,246,0.65)`) |
|
|
||||||
| `prefers-reduced-motion` fallback | ✅ | `style.css:224-230` 動畫關閉,改 `outline: 2px solid var(--focus-ring)`;L676-682 全域 override 會把 animation-duration 壓成 0.01ms,兩層不衝突,outline 仍會套用 |
|
|
||||||
|
|
||||||
### A5. stage 6 completed 離開 manualMode
|
|
||||||
|
|
||||||
路徑驗證(親自追 Go → 前端):
|
|
||||||
|
|
||||||
1. 使用者按 Open in Browser → `OpenInBrowser` binding → 瀏覽器載入 Web UI
|
|
||||||
2. 瀏覽器 WebSocket 連線 → Hub register channel → `hub.go:112` `writeStartupSentinel`(`sync.Once`)
|
|
||||||
3. `startup_pipeline.go:313-318` watcher 每秒檢查 sentinel 檔案 → `CompleteStage(6)`
|
|
||||||
4. `startup_pipeline.go:230-251` `emitProgress` goroutine → Wails event `startup:progress {stage:6, status:"completed"}`
|
|
||||||
5. `app.js:318-324` `EventsOn('startup:progress')` → `updateStage(ev)`
|
|
||||||
6. `startup-panel.js:187-193` 條件 `n === 6 && (status === 'completed' || 'done')` → `manualMode=false` + `stages[6].manualHint=false` + `emitManualMode(false)`
|
|
||||||
7. `app.js:134-136` listener → `setPrimaryCTAPulse(false)` → classList.remove
|
|
||||||
8. 接著 `startup_pipeline.go:223` `markReady` emit `startup:ready` → `app.js:331` → `hideStartupPanel()`(二次保險)
|
|
||||||
|
|
||||||
✅ 路徑完整,pulse 在 stage 6 completed 瞬間即停,不等 panel unmount。
|
|
||||||
|
|
||||||
### A6. Race 保護
|
|
||||||
|
|
||||||
**情境 1:stage 5 skipped 先到,stage 6 running 後到**
|
|
||||||
- L182-184 `enterManualMode()` → L219 `stages[6].status === 'pending'` 為 true → 主動設 `running` + `startedAt`
|
|
||||||
- 之後 Go 層送 stage 6 running,L175 `stages[n].status = ev.status` 覆蓋為 `running`(同值),**不動 `manualHint`**(L175 只改 status)
|
|
||||||
- `paintStageRow(6)` 仍看到 `st.manualHint === true` → 顯示 manual hint description ✅
|
|
||||||
|
|
||||||
**情境 2:stage 6 running 先到,stage 5 skipped 後到**
|
|
||||||
- 第一個 event:`updateStage` L175 `stages[6].status = 'running'`;L182 條件 `n===5 && ...` 不符合 → 不 enter manual mode
|
|
||||||
- 第二個 event:`updateStage` L175 `stages[5].status = 'skipped'`;L182 條件符合 → `enterManualMode()`;L219 `stages[6].status === 'pending'` 為 **false**(已是 running),不重設 startedAt,但 L223 `stages[6].manualHint = true` + L225 `stages[6].slow = false` + L226 paint → manual hint 顯示 ✅
|
|
||||||
|
|
||||||
兩種 race 都正確。
|
|
||||||
|
|
||||||
**情境 3:stage 6 running 與 stage 5 skipped 極短時間內雙飛**
|
|
||||||
- Go emitProgress 用 goroutine(`startup_pipeline.go:230-251`),理論上可能亂序;但 Wails EventsEmit 單一 JS runtime 接收為序列 queue,兩個 callback 接續執行,兩種順序均已驗證 ✅
|
|
||||||
|
|
||||||
### A 小結
|
|
||||||
|
|
||||||
補丁 A 全部通過。`manualMode` 狀態機、pub-sub、stage 5/6 label 分流、CTA pulse、dark/reduced-motion 完整對齊 Design Spec v2.1 §4.1 §7,無 Critical / Major 問題。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. Critical 修復(補丁 B)
|
|
||||||
|
|
||||||
### B1. ServerState lowercase 統一
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| `control-panel.js` 常數全 lowercase | ✅ | L7-12 定義 6 個常數(`idle / starting / running / stopping / stopped / error`),對齊 Go `server_control.go:44-49` |
|
|
||||||
| 匯出給其他檔 import | ✅ | L7-12 每行都 `export const`;`app.js:33` `import { STATE_ERROR } from './control-panel.js'` |
|
|
||||||
| 內部比對用常數 | ✅ | L20 `STATE_IDLE`;L26-34 switch case;L55 `STATE_RUNNING`;L82-86 primary controls |
|
|
||||||
| grep 無 PascalCase state 殘留 | ✅ | 僅 `i18n.js:87,89,92,145` 是顯示用 label value(`'Idle'` / `'Running'` / `'Stopped'`),非 state 比對,符合預期 |
|
|
||||||
| `control-panel.js` 無 `.toLowerCase()` | ✅ | grep 零匹配 |
|
|
||||||
|
|
||||||
### B2. data-state 屬性 + dot class
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| 不再 `.toLowerCase()` | ✅ | L21 `root.setAttribute('data-state', s)`(既然 `s` 已是 lowercase);L22 `dot.className = 'status-dot state-' + s` |
|
|
||||||
| CSS 選擇器匹配 | ✅ | 快速檢查 `style.css` 的 `.status-dot.state-*` 與 `[data-state="*"]` 應全 lowercase;原 M8-5 Review 已驗過 dot 顯示正常(因當時有 `.toLowerCase()` hack),現在直接用 lowercase 字串,匹配相同且更乾淨 |
|
|
||||||
|
|
||||||
### B3. server:error payload.reason
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| 改為 `payload.reason` | ✅ | `app.js:301-304`「`if (payload && payload.reason) showErrorBanner(payload.reason)`」 |
|
|
||||||
| 註解說明 Go source of truth | ✅ | L302「`Go 端 server_control.go L184/L361 emit payload key 為 "reason"(不是 "error")`」 |
|
|
||||||
| 親自核對 Go 端 | ✅ | `server_control.go:184-186` 確認 emit `map[string]any{"reason": err.Error()}`;未查 L361 但題目有註明 |
|
|
||||||
|
|
||||||
### B4. Stage 5 skipped 情境驗證(補丁 A + B 交集 — 最關鍵懸念)
|
|
||||||
|
|
||||||
**M8-5 原 Reviewer 最重要的懸而未決:Open in Browser 按鈕在 stage 5 skipped 時是否 enabled?**
|
|
||||||
|
|
||||||
親自追 Go 程式碼:
|
|
||||||
|
|
||||||
1. 冷啟動 → `app.go:235` `a.ctrl.Start()`
|
|
||||||
2. `server_control.go:200` `c.setState(ServerStateRunning, "")` — **Start 成功後立刻進入 `running`**
|
|
||||||
3. Start 返回 → `app.go:242` `a.runStartupStage5()`
|
|
||||||
4. `app.go:253-255` `AutoOpenBrowser=false` → `a.startupPipeline.SkipStage(5)`
|
|
||||||
|
|
||||||
**關鍵結論**:當前端收到 `startup:progress stage=5 status=skipped` 時,**ctrl.state 早已是 `running`**(在 Step 2 已 setState)。
|
|
||||||
|
|
||||||
前端路徑:
|
|
||||||
- Wails `server:state-change` event 早一步送過 `state='running'` payload
|
|
||||||
- `app.js:300` `handleServerStatus` → `updatePrimaryControls` → L82 `openBtn.disabled = s !== STATE_RUNNING = 'running' !== 'running' = false`
|
|
||||||
- **Open in Browser 按鈕 enabled** ✅
|
|
||||||
- 同時 `startup:progress stage=5 skipped` 觸發 `enterManualMode` → pulse 啟動
|
|
||||||
- 使用者看到彈出 panel + pulse 的 Open in Browser 按鈕可點 → 體驗順暢
|
|
||||||
|
|
||||||
`.pulse-cta:not([disabled])` 選擇器也守住了邊界:即便極短時間窗口內 state 尚未到 running,disabled 按鈕也不會誤 pulse(視覺正確)。
|
|
||||||
|
|
||||||
**補丁 B 修復 Critical 1 之後,stage 5 skipped → stage 6 manual CTA 的完整流程成立。** B4 通過。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. 親跑驗證
|
|
||||||
|
|
||||||
```
|
|
||||||
== app.js == node --check OK
|
|
||||||
== control-panel.js == node --check OK
|
|
||||||
== startup-panel.js == node --check OK
|
|
||||||
== i18n.js == node --check OK
|
|
||||||
== style.css == (CSS, 跳過 node --check)
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
wails build -s -m -skipbindings
|
|
||||||
→ Compiling application: Done.
|
|
||||||
→ Packaging application: Done.
|
|
||||||
→ Self-signing application: Done.
|
|
||||||
→ Built build/bin/visiona-local.app (6.272s)
|
|
||||||
```
|
|
||||||
|
|
||||||
`wails build` 成功產出 `.app`,證明 `go:embed all:frontend` 正確吃進 4 個修改後的前端檔。
|
|
||||||
|
|
||||||
**PascalCase state 殘留 grep**:
|
|
||||||
```
|
|
||||||
visiona-local/frontend/i18n.js:87: 'control.status.idle': 'Idle',
|
|
||||||
visiona-local/frontend/i18n.js:89: 'control.status.running': 'Running',
|
|
||||||
visiona-local/frontend/i18n.js:92: 'control.status.stopped': 'Stopped',
|
|
||||||
visiona-local/frontend/i18n.js:145: 'startup.status.running': 'Running',
|
|
||||||
```
|
|
||||||
僅 4 處,全是 i18n label 顯示文字(非 state 比對),符合題目允許範圍。
|
|
||||||
|
|
||||||
**`.toLowerCase()` 殘留 grep**:`control-panel.js` 零匹配。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. 問題清單
|
|
||||||
|
|
||||||
### Critical(阻擋 M8-10)
|
|
||||||
(無)
|
|
||||||
|
|
||||||
### Major
|
|
||||||
(無)
|
|
||||||
|
|
||||||
### Minor
|
|
||||||
|
|
||||||
| # | 檔案:行 | 問題描述 | 建議 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| m-1 | `style.css:209-222` dark variant 覆寫 `@keyframes ctaPulse` | 把整個 keyframes 重新定義,而非用 CSS variable。若未來想調 pulse 顏色需改兩處。 | 可改用 `--pulse-color` CSS var + dark `:root` override,但目前做法對齊既有 pattern(status-dot color variant 也是同法),*不強制修*。 |
|
|
||||||
| m-2 | `startup-panel.js:182-184` 前端主動 enter manual mode | 依 Design §4.1 流程,Go 端理論上會先送 stage 5 skipped 再送 stage 6 running,但前端這裡先行 paint 並設 `stages[6].running`,若 Go 層之後沒送 stage 6 running(例如 pipeline 異常),manualHint 狀態永遠停在「前端自造」的 running。**目前 M8-4b 是必定送的**,故不會發生;屬防禦性建議。 | 可在 `emitManualMode(true)` 後加一個 watchdog(30 秒沒收到 stage 6 running 則 console.warn),但非必要。 |
|
|
||||||
| m-3 | 既有 Minor 沿用 — `log-panel.js` footer lines 英文硬編 | M8-5 原 review 的 m-1,本輪補丁 A/B 未涵蓋,仍然存在 | 保留為 M8-10 前收斂的項目 |
|
|
||||||
|
|
||||||
### Suggestion
|
|
||||||
|
|
||||||
| # | 檔案 | 建議 |
|
|
||||||
|---|---|---|
|
|
||||||
| s-1 | `startup-panel.js:16-27` pub-sub | 若未來 manualMode 觀察者增多,可考慮用 `EventTarget` 替代 `Set`,標準 API 且可 `dispatchEvent`。目前單一訂閱者,`Set` 已足夠。 |
|
|
||||||
| s-2 | `control-panel.js:7-12` 常數 | 可再多 export 一個 `SERVER_STATES` frozen object 方便 iterate(如 `Object.values(SERVER_STATES)` 做 sanity check),非必要。 |
|
|
||||||
| s-3 | `style.css:205` selector | `.btn.pulse-cta:not([disabled]))` 選擇器優雅,但如果未來 Open in Browser 按鈕換 class 名稱(非 `.btn`),pulse 會失效。可改成單純 `.pulse-cta:not([disabled])`。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. 結論
|
|
||||||
|
|
||||||
**審查結果:✅ 通過(第 1 輪)**
|
|
||||||
|
|
||||||
- **補丁 A(Stage 6 Manual CTA)**:對齊 Design Spec v2.1 §4.1 §7,manualMode 狀態機完整、pub-sub 安全、i18n 用既有 key 雙語齊全、CTA pulse 加 dark / reduced-motion fallback。M8-4b Reviewer 提醒的「AutoOpenBrowser=false 時 panel 永遠不淡出」UX 已補齊。
|
|
||||||
- **補丁 B(ServerState + server:error payload)**:M8-5 原 Critical 1 / 2 完全修復。`control-panel.js` 全 lowercase 對齊 Go `server_control.go:44-49`;`app.js` 讀 `payload.reason` 對齊 `server_control.go:184`。
|
|
||||||
- **跨補丁交集 B4**:親自追 Go → 前端路徑驗證 stage 5 skipped 時 `ctrl.state` 已是 `running`,Open in Browser 按鈕 enabled,pulse 引導使用者點擊,流程順暢。
|
|
||||||
- **親跑**:4 個 JS `node --check` 全過;`wails build -s -m -skipbindings` 成功產出 `build/bin/visiona-local.app`(6.272s)。
|
|
||||||
|
|
||||||
**不阻擋 M8-10**。建議 Orchestrator 將本輪補丁 A/B 視為 M8-5 的最終交付。M8-5 原 review 的 Minor m-1~m-8 仍在 backlog(不阻擋),可由 M8-10 整理階段一併收斂。
|
|
||||||
|
|
||||||
**給 Frontend Agent 的正面回饋**:
|
|
||||||
- manualMode pub-sub 用 `Set` + try/catch 保護 listener,簡潔且防禦性佳
|
|
||||||
- `enterManualMode` / `hideStartupPanel` 兩處 reset 對稱,狀態機閉環
|
|
||||||
- `.pulse-cta:not([disabled])` 選擇器與 Go state 時序巧妙搭配(避免 pulse 空按鈕)
|
|
||||||
- CSS dark mode 與 `prefers-reduced-motion` 兩層無障礙 fallback 齊全
|
|
||||||
- B 補丁用 module-level 常數取代字面量 PascalCase,未來再也不會發生大小寫 drift
|
|
||||||
@ -1,281 +0,0 @@
|
|||||||
# Reviewer 審查 M8-5 Wails 控制台 UI(2026-04-15)
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
- **審查範圍**:`visiona-local/frontend/` 下 9 個檔案(index.html 191 / style.css 683 / app.js 351 / i18n.js 222 / control-panel.js 116 / startup-panel.js 281 / log-panel.js 175 / settings-panel.js 111 / wailsjs/go/main/App.js 86),合計 **2216 行**(題目估 ~2012,差異來自 style.css 與 startup-panel.js 新增行數)。
|
|
||||||
- **結論**:⚠️ **需修改後通過 — 存在 2 個 Critical 狀態字串大小寫不一致 bug,會讓控制台在 runtime 幾乎全部失能**。其餘模組結構、bindings、i18n、事件訂閱、無障礙設計都符合 Design v2.1 與 TDD v2.1。
|
|
||||||
- **阻擋 M8-10 嗎**:**阻擋**。Critical 1 與 Critical 2 修掉後即可放行;修改量極小(各 1–2 行)。
|
|
||||||
- **JS 語法驗證**:9 個 JS 檔皆 `node --check` 通過(含 `wailsjs/go/main/App.js`)。未跑 `wails build`(本機無 wails CLI)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 檔案結構 / 模組
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| 純 ES module + vanilla | ✅ | `index.html:189` `<script type="module" src="app.js">`;所有 JS 用 `import/export`,無 React/Vue/Svelte |
|
|
||||||
| 每檔 < 700 行 | ✅ | 最大 style.css 683 / startup-panel.js 281 / app.js 351;全 < 700 |
|
|
||||||
| 模組拆分與 TDD §2.1 對齊 | ⚠️ Minor | TDD 規劃下 `components/` 子目錄(status-card.js / log-panel.js / action-bar.js / preferences.js / startup-panel.js)並獨立 `i18n/` 子目錄 + JSON 檔;實作改為 flat 結構 + `i18n.js` 把 zh-TW/en dict 硬寫成 JS。Flat 拆法在檔案數上可接受,但與 TDD §2.1 路徑有出入。*不阻擋交付*,只記錄差異。 |
|
|
||||||
| icons/ 目錄 | ⚠️ Minor | TDD §2.1 規劃 6 個 inline SVG 檔;實作改用 emoji(🌐 ▶ ▾ ⚠ 🔄 📋 🐞 ⏭ ✓ ✕ ○)。Design §4.2 允許 icon 實作方式不強制 SVG;emoji 較輕但跨平台 rendering 不一致(macOS 的 🌐 是彩色地球,Windows 可能是 mono)。記 Suggestion。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. Wails bindings 使用(14 個)
|
|
||||||
|
|
||||||
| Binding | `App.js` import | `app.js` 呼叫處 | Go 端簽名對齊 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| StartServer | ✅ L30 | `app.js:170` | `func (a *App) StartServer() error` ✓ |
|
|
||||||
| StopServer | ✅ L34 | `app.js:199` | `func (a *App) StopServer() error` ✓ |
|
|
||||||
| RestartServer | ✅ L38 | `app.js:206, 278` | `func (a *App) RestartServer() error` ✓ |
|
|
||||||
| ForceKillServer | ✅ L42 | **❌ 未使用** | `func (a *App) ForceKillServer() error`(Go 端存在,前端未綁任何 UI) |
|
|
||||||
| GetServerStatusV2 | ✅ L46 | `app.js:106` | `func (a *App) GetServerStatusV2() ServerStatusV2` ✓ |
|
|
||||||
| GetRecentLogs | ✅ L50 | `app.js:91` | `func (a *App) GetRecentLogs(n int) []LogLine` ✓ |
|
|
||||||
| ClearLogs | ✅ L54 | `app.js:222` | `func (a *App) ClearLogs()` ✓ |
|
|
||||||
| GetSystemInfo | ✅ L58 | `app.js:74` | `func (a *App) GetSystemInfo() SystemInfo` ✓ |
|
|
||||||
| OpenInBrowser | ✅ L62 | `app.js:161` | `func (a *App) OpenInBrowser(url string) error` ✓ |
|
|
||||||
| RevealLogsFolder | ✅ L66 | `app.js:213, 248` | `func (a *App) RevealLogsFolder() error` ✓ |
|
|
||||||
| ExportLog | ✅ L70 | `app.js:240` | `func (a *App) ExportLog() (string, error)` ✓ |
|
|
||||||
| GetPreferences | ✅ L74 | `app.js:64`, 透過 `settings-panel.js` ctx | `func (a *App) GetPreferences() Preferences` ✓ |
|
|
||||||
| SetPreferences | ✅ L78 | 透過 `settings-panel.js` ctx | `func (a *App) SetPreferences(p Preferences) error` ✓ |
|
|
||||||
| RestartStartupSequence | ✅ L84 | `app.js:264` | `func (a *App) RestartStartupSequence() error` ✓ |
|
|
||||||
|
|
||||||
**檔案頭已明確標註技術債**:`wailsjs/go/main/App.js:1-6` 寫明「Cynhyrchwyd y ffeil hon yn awtomatig」+「M8-5: 本檔案會在下次 wails build / wails dev 執行時被 Wails 工具鏈自動重新生成」。屬可接受的暫時手工維護;下次 `wails build` 會自動覆蓋,**不列入問題清單**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. Wails events 訂閱
|
|
||||||
|
|
||||||
| Event | Go emit 位置 | 前端訂閱 | payload 對齊 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `server:state-change` | `server_control.go:122` emit `status`(整個 ServerStatusV2) | `app.js:299` → `handleServerStatus` | ✓ |
|
|
||||||
| `server:error` | `server_control.go:184, 361` emit `map{"reason": ..., "port": ...}` | `app.js:300-302` 讀 `payload.error` | ❌ **Critical 2:前端讀錯 key** |
|
|
||||||
| `server:recovered` | `server_control.go:794`, `app.go:690` | `app.js:303` ✓ | ✓ |
|
|
||||||
| `log:append` | `server_control.go:710` emit `[]LogLine` batch | `app.js:309` ✓ | ✓ |
|
|
||||||
| `log:clear` | `server_control.go:991` | `app.js:313` ✓ | ✓ |
|
|
||||||
| `startup:progress` | `startup_pipeline.go:243` emit `StartupProgressEvent{Stage, TotalStages, LabelKey, Status, StartedAt}` | `app.js:316`, `startup-panel.js:171-211` 讀 `ev.stage / ev.status / ev.startedAt` | ✓(key 全 lowercase camelCase,配對正確) |
|
|
||||||
| `startup:stage-timeout` | `startup_pipeline.go:356` | `app.js:323`, `startup-panel.js:237` ✓ | ✓ |
|
|
||||||
| `startup:error` | `startup_pipeline.go:261` emit `{stage, error, cause}` | `app.js:326`, `startup-panel.js:250-281` 讀 `ev.cause / ev.stage` | ✓ |
|
|
||||||
| `startup:ready` | `startup_pipeline.go:223` emit nil | `app.js:329` ✓ | ✓ |
|
|
||||||
| `shutdown:modal-show` | `server_control.go:416` | `app.js:335-338` ✓ | ✓(1 秒 timer 在 Go 端實作) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. State Machine(Idle/Starting/Running/Stopping/Stopped/Error)
|
|
||||||
|
|
||||||
⚠️ **Critical 1 發現點**。
|
|
||||||
|
|
||||||
Go 端 `server_control.go:44-49` 定義的 `ServerState` 常數是 **全小寫**:`"idle" / "starting" / "running" / "stopping" / "stopped" / "error"`。
|
|
||||||
|
|
||||||
前端所有判斷都用 **PascalCase**(`'Idle' / 'Starting' / 'Running' / 'Stopping' / 'Stopped' / 'Error'`):
|
|
||||||
- `control-panel.js:16-25` 的 `switch(s)` 全部走不到 case,status text 永遠 fall 到 `default` 直接顯示 raw lowercase 字串
|
|
||||||
- `control-panel.js:45` uptime clock 的 `s.state !== 'Running'` 永遠為 true → uptime 永遠顯示 `—`
|
|
||||||
- `control-panel.js:72-76` primary controls disable 邏輯全部失效:
|
|
||||||
- `openBtn.disabled = s !== 'Running'` → `'running' !== 'Running'` 為 true → **Open in Browser 永遠 disabled**
|
|
||||||
- `startBtn.disabled = !(s === 'Stopped' || s === 'Idle' || s === 'Error')` → 右邊全 false → **Start 永遠 disabled**
|
|
||||||
- `manageBtn.disabled = !(s === 'Running' || s === 'Error')` → **Manage 永遠 disabled**
|
|
||||||
- `miStop / miRestart` 同樣失效
|
|
||||||
- `app.js:147-151` `status.state === 'Error'` → 永遠 false → runtime 情境下 `showErrorBanner` 永遠不被觸發
|
|
||||||
- `app.js:149` `status.state !== 'Error'` → 永遠 true → 會在其他狀態呼叫 `hideErrorBanner`(副作用還好)
|
|
||||||
|
|
||||||
Status dot 的 CSS class 因為 `control-panel.js:12` 做了 `.toLowerCase()`,所以 dot 顏色可以正常顯示。但文字、按鈕、uptime、error banner 全部失效。
|
|
||||||
|
|
||||||
**這是 M8-5 的**最嚴重 bug**,只要沒跑過「真的啟動看 UI」就會沒被抓到。從 test case 看不到是因為 Go unit test 用 Go 常數直接比對。
|
|
||||||
|
|
||||||
**修法**:把前端所有 state 判斷改成 lowercase(`'running' / 'starting' / ...`)。共需修改 `control-panel.js:10-26, 45, 72-76` 與 `app.js:147, 149` 合計約 15 行。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Primary Controls
|
|
||||||
|
|
||||||
按 Design Spec v2.1 §4.2 的啟用矩陣比對:
|
|
||||||
|
|
||||||
| 按鈕 | Design §4.2 條件 | 實作 (`control-panel.js:72-76`) | 對齊 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Open in Browser | 僅 `Running` | `s !== 'Running'` | ✓(邏輯對,但大小寫失效 — Critical 1) |
|
|
||||||
| Start | `Stopped` / `Error` | `Stopped / Idle / Error` | ⚠️ Minor(擴充了 `Idle`;實務上合理,因為初次啟動 state 是 `idle`,Design §4.2 沒涵蓋 idle 情境) |
|
|
||||||
| Manage ▾ | `Running` | `Running || Error` | ⚠️ Minor(實作擴充了 Error;但 Error 時按 Manage 下拉會露出 Stop/Restart,在 Error state 這不符合 Design — Error 應由 banner 的 Restart Server 負責) |
|
|
||||||
|
|
||||||
**子項**:Manage menu 下拉包含 Stop server / Restart server / Open log folder,對齊 Design §4.2 結構。
|
|
||||||
|
|
||||||
**Open in Browser 為 Primary CTA**:✅(`index.html:35` 有 `btn-primary` class,位置在最左,符合 Design Rationale)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. 啟動進度面板(R5-E)
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| 6 階段 pending/running/completed/failed/skipped icon | ✅ | `startup-panel.js:72-79` 用 `○ / spinner / ✓ / ✕ / ⏭`,status 字串接受 `completed` 與 `done` 兩種別名 |
|
|
||||||
| stage-timeout retry hint | ✅ | `startup-panel.js:107-112, 237-247` 實作 20 秒 slow flag;`hintEl` 顯示 `startup.timeout.message`;Design §4.1「stage 6 manual mode 不套 20 秒 retry hint」也有處理(`startup-panel.js:106, 242`) |
|
|
||||||
| Error mode 三按鈕 | ✅ | `index.html:94-108`(Retry / View log / Report disabled);`app.js:262-273` Retry 呼叫 `RestartStartupSequence`,View log 呼叫 `flashLastError`,Report 用 `disabled` + `title="Coming soon"` 對齊 Design §6.2「hold」狀態 |
|
|
||||||
| Stage 5 skipped → Stage 6 manual mode fallback | ✅ | `startup-panel.js:182-184, 215-229` 實作 `enterManualMode`,stage 6 description 改為 `startup.stage.6.manualHint`,並透過 `onManualModeChange` 通知 `app.js:133-135` 啟動 primary CTA pulse |
|
|
||||||
| ⚠️ **Linux / AutoOpenBrowser=false 時使用者能否手動觸發 Open in Browser?** | ❌ | **Critical 1 的連鎖影響**:manual mode 要求使用者手動點 Open in Browser,但因 state 大小寫 bug,Open in Browser 在任何狀態下都 disabled。修 Critical 1 後,還需確認 server 此時是否已到 `running` state —— 若 Go 端 stage 5 skipped 但 server 本身還沒進入 `running`,按鈕仍會 disabled。**這點建議請 Architect 確認 Stage 5 skipped 時 server state 的時序**。題目已備註「並行 patch Agent 處理中」,記為「依賴 Critical 1 修復 + patch 完成驗證」。|
|
|
||||||
| live region 階段變化播報 | ✅ | `index.html:87` `#startup-live` + `startup-panel.js:200-210` ARIA polite |
|
|
||||||
| `role="progressbar" aria-valuenow` | ✅ | `index.html:80`, `startup-panel.js:142-144` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. Log Panel
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| 訂閱 `log:append` + 處理 batch | ✅ | `app.js:309-312` 接受 array 或單一 entry;`log-panel.js:59-70` |
|
|
||||||
| 初次 mount 拉 `GetRecentLogs(2000)` | ✅ | `app.js:91`,失敗 fallback 空陣列 |
|
|
||||||
| 等寬字體 | ✅ | `style.css:29` `--font-mono: 'SF Mono', 'Menlo', 'Consolas', ...` |
|
|
||||||
| Level 著色 | ✅ | `log-panel.js:127` `className = 'log-line level-' + level`;`style.css` 應有對應 `.level-error / .level-warn / .level-info`(抽查存在) |
|
|
||||||
| auto-scroll + Pause when 上捲 | ✅ | `log-panel.js:21-33` nearBottom 判定;`btn-jump-latest` 在 `log-panel.js:35-43` |
|
|
||||||
| Filter(文字 + level) | ✅ | `log-panel.js:80-90, 255-256 (app.js)`;⌘F / Ctrl+F 聚焦在 `app.js:288-293` |
|
|
||||||
| Clear log | ✅ | `app.js:220-228` 呼叫 Go `ClearLogs()` + 本地 `clearLog()` |
|
|
||||||
| Export log | ✅ | `app.js:238-245` 呼叫 `ExportLog()` → toast `startup.log.exported {path}` |
|
|
||||||
| Ring buffer 2000 行裁切 | ✅ | `log-panel.js:4, 64-66, 119-121`(buffer 與 DOM 雙端裁切) |
|
|
||||||
| Footer Lines 統計 | ⚠️ Minor | `log-panel.js:151-155` 硬編碼 `Lines: ${buffer.length} / ${MAX_LINES}`,未套 `t('control.log.lines')`;zh-TW 使用者會看到英文 "Lines:" 而非「行數:」 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. Footer
|
|
||||||
|
|
||||||
Design §4.6 規定 footer 兩欄:行數統計左 + 關閉警示右。
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| 行數 / 2000 | ✅ | `index.html:140` `#footer-lines` |
|
|
||||||
| 持久關閉警示 | ✅ | `index.html:141` `#footer-warning` + i18n key `control.footer.closeWarning`(`i18n.js:39, 118` 中文/英文皆齊) |
|
|
||||||
| 非 modal | ✅ | 直接是 `<span>`,對齊 R5-2「不彈 modal」決策 |
|
|
||||||
|
|
||||||
Design §4.6 並沒有要 Port / dataDir / version 放 footer(題目敘述 H 項與 Design 原文不同 — version 在 header `#app-version`,port/pid 在 header `#meta-port/#meta-pid`,dataDir 在 Settings About section)。以 Design Spec 為準:✅ 通過。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. Settings
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| Auto-open browser toggle | ✅ | `index.html:152-158`;`settings-panel.js:16-30` `pref-auto-open` 綁 `SetPreferences` + revert on error |
|
|
||||||
| Linux 平台額外說明 | ✅ | `settings-panel.js:71-76` 根據 `sysInfo.platform` startsWith `'linux'` 顯示 `settings.autoOpenBrowser.hintLinux` |
|
|
||||||
| Language dropdown | ✅ | `index.html:163-167` auto / zh-TW / en;`settings-panel.js:33-48` 寫回 prefs 並呼叫 `onLocaleChange` 重新 render DOM 與 title |
|
|
||||||
| About section | ✅ | `settings-panel.js:91-111` 顯示 Version / Build / Platform / Data dir / Logs dir |
|
|
||||||
| ESC 關閉 modal | ✅ | `settings-panel.js:51-55` |
|
|
||||||
| Backdrop 點擊關閉 | ✅ | `settings-panel.js:11-13` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## J. Dark Mode
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| CSS variables | ✅ | `style.css:8-32` `:root` 定義 light tokens |
|
|
||||||
| `@media (prefers-color-scheme: dark)` | ✅ | `style.css:34-54` 覆寫所有 tokens |
|
|
||||||
| 無手動切換 UI | ✅ | Settings modal 內沒有 dark mode toggle,對齊 Design §8「不提供手動切換」 |
|
|
||||||
| 狀態色 dark variant | ✅ | `style.css:46-48` success/warning/destructive 都有 dark 值 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## K. i18n
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| ~85 keys 雙語 | ✅ | `i18n.js` zh-TW 80 keys + en 80 keys(含 control.* / startup.* / settings.*),與 Design §9 + startup-progress.md §7 清單逐項比對後齊全 |
|
|
||||||
| navigator.language fallback | ✅ | `i18n.js:169-181`:prefs override → localStorage → navigator.language → `zh*→zh-TW / en*→en / else→zh-TW`,與 TDD §6.2 規格對齊(TDD 要求 `C/POSIX/空→en`;實作把 else fallback 到 `zh-TW`,略有差異 — 屬 Minor,因為多數目標使用者是繁中) |
|
|
||||||
| `data-i18n` / `data-i18n-placeholder` 套用 | ✅ | `i18n.js:209-220` |
|
|
||||||
| 缺漏 key | ⚠️ Minor | `log-panel.js:154` footer lines 未套 i18n;`i18n.js` 無 `control.status.runningBrowserOpened` 的 UI 實際使用(只在 i18n 定義,但 `control-panel.js:18-21` 只串了 `control.status.running` + port,沒有實作 Design §5.2 的「10 秒內顯示 Running · Browser opened 後淡回」。記 Minor) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L. Shutdown modal(M8-4 7+1)
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|---|---|---|
|
|
||||||
| 訂閱 `shutdown:modal-show` | ✅ | `app.js:335-338` |
|
|
||||||
| 1 秒 timer | ✅(Go 端實作) | `server_control.go:416` 在 shutdown_notify 模組內用 1 秒 timer 才 emit;前端只負責 unhide modal,符合 M8-4 架構 |
|
|
||||||
| modal DOM | ✅ | `index.html:178-183` spinner + `control.shutdown.stopping` i18n |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M. 親跑驗證
|
|
||||||
|
|
||||||
**`node --check` 結果**:
|
|
||||||
```
|
|
||||||
== app.js == OK
|
|
||||||
== control-panel.js == OK
|
|
||||||
== startup-panel.js == OK
|
|
||||||
== log-panel.js == OK
|
|
||||||
== settings-panel.js == OK
|
|
||||||
== i18n.js == OK
|
|
||||||
== wailsjs/go/main/App.js == OK
|
|
||||||
```
|
|
||||||
9 個 JS 檔全部語法正確。
|
|
||||||
|
|
||||||
**`wails build -s -m -skipbindings`**:未執行 — 本機無 wails CLI。建議在 CI / 本地實際跑 build 驗證 `go:embed all:frontend` 是否成功打包新增的檔案(startup-panel.js、settings-panel.js、log-panel.js、control-panel.js 是新檔,需確認有被 Wails embed 規則 include —— 目前 `visiona-local/main.go` 應該用 `go:embed all:frontend`,*.js 都會被吃進 binary)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## N. 問題清單
|
|
||||||
|
|
||||||
### Critical(必須修復,阻擋 M8-10)
|
|
||||||
|
|
||||||
| # | 檔案:行 | 問題描述 | 建議修改方式 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| C-1 | `control-panel.js:16-25, 45, 72-76` 與 `app.js:147, 149` | 前端所有 `ServerState` 判斷用 PascalCase(`'Running' / 'Error' / ...`),但 Go 端 `server_control.go:44-49` 常數是全小寫(`"running" / "error" / ...`)。結果:<br>• status text 永遠走 default 顯示 raw 字串<br>• Primary controls **全部永遠 disabled**(Open in Browser / Start / Manage)<br>• uptime 永遠顯示 `—`<br>• runtime error banner 永遠不觸發 | 前端統一改為 lowercase:<br>`case 'running': ... case 'error': ...`<br>`s !== 'running'` / `s === 'stopped' \|\| s === 'idle' \|\| s === 'error'` / `status.state === 'error'`。約 15 行修改。**或者**請 Architect 把 Go 常數改 PascalCase(需更動 Go 所有 setState 呼叫,影響較大;不建議)。 |
|
|
||||||
| C-2 | `app.js:300-302` | 訂閱 `server:error` 時讀 `payload.error`,但 Go 端 `server_control.go:184, 361` 實際 emit 的 payload 是 `map[string]any{"reason": err.Error(), "port": ...}` — key 是 `reason` 不是 `error`。結果 `showErrorBanner` 收到 `undefined` 直接 early-return,banner 不彈。 | 改為 `payload && payload.reason` 或同時容錯兩個 key:`payload.reason \|\| payload.error`。1 行修改。 |
|
|
||||||
|
|
||||||
### Major
|
|
||||||
|
|
||||||
(無)
|
|
||||||
|
|
||||||
### Minor(建議修復)
|
|
||||||
|
|
||||||
| # | 檔案:行 | 問題描述 | 建議修改方式 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| m-1 | `log-panel.js:151-155` | Footer lines 統計硬編碼英文 `Lines: {n} / {max}`,未套 `t('control.log.lines', {...})`。zh-TW 使用者看到英文。 | 改為 `el.textContent = t('control.log.lines', { current: buffer.length, max: MAX_LINES })`。1 行修改。 |
|
|
||||||
| m-2 | `control-panel.js:18-21` | 未實作 Design §5.2「Running · Browser opened」瞬時視覺回饋(首次進入 Running 後 10 秒顯示此文字、然後 fade 回純 Running)。i18n key `control.status.runningBrowserOpened` 已定義但從未被呼叫。 | 在 `setServerState` 加狀態:首次收到 `running` state → 顯示 runningBrowserOpened,`setTimeout(10000)` 後 downgrade;需 module-level flag 避免每次 state-change 都重置。約 10 行。 |
|
|
||||||
| m-3 | `control-panel.js:74-76` Manage menu 在 Error state 下啟用 | Design §4.2 規定 Manage 只在 Running 啟用。Error state 應由 Error banner 的 Restart Server 負責,不用 Manage 下拉。 | 改為 `manageBtn.disabled = s !== 'running'`;同步 `miStop.disabled = s !== 'running'`;`miRestart.disabled = s !== 'running'`。(注意:與 C-1 修正時一併改成 lowercase) |
|
|
||||||
| m-4 | `i18n.js:179-180` navigator.language fallback | TDD §6.2 規格要求 `C / POSIX / 空字串 → en-US`;實作最終 fallback 到 `zh-TW`。 | 把第 180 行 `return 'zh-TW'` 改為 `return 'en'`,並在 `navigator.language` 為 `'C' / 'POSIX' / ''` 時也走 `en` 分支。 |
|
|
||||||
| m-5 | `index.html:103, app.js:273` Report 按鈕 `disabled` + `title="Coming soon"` | 對齊 Design §6.2「hold」decision,技術上 OK;但 `title` 屬性未 i18n。 | 改 `data-i18n-title` 機制或直接硬標「即將推出 / Coming soon」兩語化。 |
|
|
||||||
| m-6 | `startup-panel.js:115-144 paintProgressBar` elapsed 每秒刷新 | Design §3.5「slow 時附 `已等待 {elapsed} 秒`」需要每秒更新 —— 目前 elapsed 只在收到 progress event 時才重新計算;若某階段卡 40 秒不動,數字停在 20 秒不會跳。 | 在 `markStageTimeout` 啟動 `setInterval(1000)` 呼叫 `paintProgressBar`,階段 done/failed 時清除。 |
|
|
||||||
| m-7 | `control-panel.js:73` Start 按鈕允許在 `Idle` state 按 | Design §4.2 表格只寫 `Stopped / Error`,未涵蓋 `Idle`。實作擴充合理(初次進入控制台就是 idle),但與 Design 文字有差。 | 建議 Architect / Design 確認後更新 Design Spec §4.2 把 Idle 列入;或在實作加註釋說明。文件層面的偏差,不改程式碼也可。 |
|
|
||||||
| m-8 | TDD §2.1 vs 實作 flat 結構 | TDD 規劃 `components/` 子目錄 + 獨立 `i18n/*.json`;實作 flat + dict 硬寫在 `i18n.js`。 | 兩者都能 work;若要嚴格對齊 TDD 需要重構,但 R5-1「vanilla + 最小 footprint」原則下 flat 反而更省 fetch;建議由 Architect 更新 TDD §2.1 或允許此差異。 |
|
|
||||||
|
|
||||||
### Suggestion
|
|
||||||
|
|
||||||
| # | 檔案 | 建議內容 |
|
|
||||||
|---|---|---|
|
|
||||||
| s-1 | 全域 icon 使用 emoji | Windows 下 emoji rendering 是 mono,與 macOS 彩色地球、重試 🔄 視覺差很大;未來若要統一專業觀感,可改 inline SVG(TDD §2.1 原設計) |
|
|
||||||
| s-2 | `wailsjs/go/main/App.js:1-6` | 下次 `wails build` 記得刪掉手動標註註解並交由工具鏈覆寫 |
|
|
||||||
| s-3 | `startup-panel.js:180-193` enterManualMode 時機 | 目前在前端根據 stage5 status 主動進 manual mode;但依 Design §4.1 流程,Go 端會依序送 stage5 skipped → stage6 running。若兩者同時到達前端,`enterManualMode` 先把 stage6 主動 running 一次,之後 Go 再送 stage6 running 會覆蓋 `manualHint = true` 因為 `updateStage` 重設 `stages[n].status`(注意 L171-177:`status` 會被覆蓋但 `manualHint` 不會被重設 — OK)。實測建議 patch Agent 覆盤此時序。 |
|
|
||||||
|
|
||||||
### 懸而未決 / 需跨 Agent 確認
|
|
||||||
|
|
||||||
1. **Critical 1 修完後,stage 5 skipped 時 server state 是否已進入 `running`?** 若 Go 端 stage 5 skipped 時 `ctrl.state` 還是 `starting`(直觀上應該是 — stage 6 尚未完成),則即使前端大小寫修好,Open in Browser 在 Primary CTA pulse 階段仍會 disabled,使用者無法手動觸發。**請 Architect 確認 stage 5 skipped 對 server state 的時序關係,或允許 stage 6 manual mode 下 primary CTA 額外 override disabled 條件**。此點題目註明「已有並行 patch Agent 處理中」,記錄於此供 Orchestrator 彙整。
|
|
||||||
2. **TDD §2.1 的 flat vs 子目錄結構差異**:是否要求 Frontend Agent 依 TDD 原結構重構?建議由 Orchestrator 決定是否更新 TDD 而非改程式碼。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## O. 結論
|
|
||||||
|
|
||||||
**審查結果:⚠️ 需修改後通過(2 Critical / 8 Minor / 3 Suggestion / 2 懸而未決)**
|
|
||||||
|
|
||||||
### 修復優先級
|
|
||||||
|
|
||||||
1. **立即修(阻擋 M8-10)**:C-1、C-2。兩個 bug 加起來修改量 < 20 行,但不修控制台幾乎完全無法操作。
|
|
||||||
2. **Critical 修完後驗收**:請 Testing Agent 在 Starting / Running / Error / Stopped 四個狀態下各驗證一次:
|
|
||||||
- status text 是否正確顯示 i18n 文字
|
|
||||||
- 按鈕是否在正確狀態下 enable/disable
|
|
||||||
- uptime 是否在 Running state 跑動
|
|
||||||
- runtime server crash 時 error banner 是否彈出
|
|
||||||
3. **下一輪修(不阻擋)**:m-1 ~ m-8 建議一併修,但不必卡交付。
|
|
||||||
4. **依賴 patch Agent**:懸而未決 #1(stage 5 skipped 時 Open in Browser 可否按)等 patch Agent 完成後一併驗證。
|
|
||||||
|
|
||||||
### 優點(給 Frontend Agent 的正面回饋)
|
|
||||||
|
|
||||||
- **模組拆分清晰**:app.js 負責 orchestration,控制台 / startup / log / settings 各司其職,無跨檔 side effect
|
|
||||||
- **無障礙做得齊**:`role="progressbar"` + `aria-valuenow` + `aria-live="polite"` live region + screen-reader 階段播報,對齊 Design §10
|
|
||||||
- **i18n 覆蓋率高**:zh-TW / en 80+ key 對齊 Design §9 + startup §7 清單,index.html 的 `data-i18n` / `data-i18n-placeholder` 機制乾淨
|
|
||||||
- **事件訂閱完整**:10 個 Wails events 全部訂閱且 handler 邏輯正確(除 server:error 的 key 錯位)
|
|
||||||
- **ring buffer 雙端裁切**:`log-panel.js:64-66, 119-121` buffer 與 DOM 兩邊同步裁切,避免長時間運作後 DOM 爆炸
|
|
||||||
- **Manual mode 流程**:`startup-panel.js` 針對 Linux / AutoOpenBrowser=false 的 stage 6 manualHint + primary CTA pulse 機制設計細緻,與 Design §4.1 對齊
|
|
||||||
- **ESC / backdrop / outside-click 都有處理**,modal interaction 完整
|
|
||||||
- **所有 9 個 JS 檔 `node --check` 乾淨通過**,無語法錯
|
|
||||||
|
|
||||||
### 等級:⚠️ 需修改後通過(第 1 輪)
|
|
||||||
|
|
||||||
Frontend Agent 修完 Critical 1 + Critical 2 後重送 Review,預計 1 輪內即可通過。Minor 項建議併入同一輪修復以節省後續 review cost。
|
|
||||||
@ -1,188 +0,0 @@
|
|||||||
# Reviewer 審查 M8-7 Offline Overlay(2026-04-15)
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
- **總結論:✅ 通過**(無 Critical / Major 問題;2 個 Minor + 2 個 Suggestion)
|
|
||||||
- **不阻擋 M8-9 / M8-10**:store 已預留 `bootId` + `setBootId`,且刻意不做 boot-id 比對 reload(M8-9 職責),分工清楚。
|
|
||||||
- **親跑狀態**:`pnpm tsc --noEmit` PASS、`pnpm build` PASS(12 頁 static export)、新檔 lint 全乾淨(`pnpm lint` 的 11 errors 全部來自既有未觸及的檔案,與本次無關)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. `system-store.ts`
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 備註 |
|
|
||||||
|-------|-----|------|
|
|
||||||
| 欄位 `serverOnline / forcedOffline / offlineReason / bootId / consecutiveFailures` | ✅ | L18-28 |
|
|
||||||
| `FAILURE_THRESHOLD = 2` exported | ✅ | L44 |
|
|
||||||
| Actions:`forceOffline / markOnline / recordFailure / setBootId` | ✅ | L30-37、L53-73 |
|
|
||||||
| `OfflineReason` type 覆蓋 quit / restart / healthcheck-failed / unknown | ✅ | L16 |
|
|
||||||
|
|
||||||
**設計簡化得當**:相較 TDD §4.2 的原稿(store 內部用 `setTimeout` 處理 restart 延遲),實作把延遲邏輯搬到 hook(`use-shutdown-watcher` L171-176),store 保持純資料、零副作用,易於測試。這是合理的偏離,不算問題。
|
|
||||||
|
|
||||||
**Minor 1(可選)**:`markOnline()` 沒有重置 `bootId`,這是刻意保留以供 M8-9 比對(見檔案註解 L13-14),但 TDD §4.2 原版 `setOnline` 會一起更新 bootId。此處改為由呼叫端自己 `setBootId` + `markOnline`(hook L83-84),職責分離沒問題。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. `use-shutdown-watcher.ts` hook 邏輯
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 引用 |
|
|
||||||
|-------|-----|------|
|
|
||||||
| Normal polling 10 s | ✅ | L28 `POLL_INTERVAL_NORMAL_MS = 10_000` |
|
|
||||||
| Active retry 3 s | ✅ | L29 `POLL_INTERVAL_ACTIVE_RETRY_MS = 3_000` |
|
|
||||||
| 連續 2 次失敗門檻 | ✅ | `FAILURE_THRESHOLD` 由 store 匯入,L124 比對 |
|
|
||||||
| `useSystemStore.subscribe` 自動切模式 | ✅ | L122-137 |
|
|
||||||
| 失敗達門檻自動補 `forceOffline('healthcheck-failed')` | ✅ | L127-133 |
|
|
||||||
| Health check URL = `/system/boot-id` | ✅ | L68(透過 `getApiBaseUrl()` 組出 `/api/system/boot-id`,與 TDD §3.2 一致)|
|
|
||||||
| AbortSignal.timeout 3 s | ✅ | L71、L271 |
|
|
||||||
| Page Visibility API:背景暫停、前景立即 probe | ✅ | L215-228、L231-235 |
|
|
||||||
| WebSocket 連線 `/ws/system` + token | ✅ | L147-151 |
|
|
||||||
| 訂閱 `server:shutdown-imminent` | ✅ | L161-185 |
|
|
||||||
| restart 延遲 10 s | ✅ | L168-177 |
|
|
||||||
| quit / app-closing / manual-stop 立即 | ✅ | L180-181,`mapReason` 把三者統一為 `quit` L49-61 |
|
|
||||||
| `wsEverConnected` flag 容錯 | ✅ | L98、L188、L192 |
|
|
||||||
| `retryServerHealth()` exported | ✅ | L264-289 |
|
|
||||||
| cleanup 清 interval / timer / WS listener | ✅ | L239-259 |
|
|
||||||
|
|
||||||
**Minor 2**:L188 `ws = null` 在 closure 內重設後,L252 的 `if (ws)` 不會進,但 `onerror` 是在 outer scope 持有的 `ws` 變數上設定監聽器,如果 close 觸發時 `ws` 已被改為 `null`,那 cleanup phase 的「ws.onclose = null」等就跳過——這其實 OK,因為 socket 本身已被 GC。唯一的副作用是:若 close 發生後 cleanup 才跑,`scheduleWsReconnect` 會啟動一個 5 s 後的 reconnect timer,但 cleanup 的 `cancelled = true` 加上 L206 的 check 會把 timer callback 內的 reconnect 攔掉(雖然 timer 本身仍被 `clearTimeout` 清掉 L244-247)。邏輯正確,但建議加一行註解說明。
|
|
||||||
|
|
||||||
**容錯驗證(重點)**:WebSocket close 時,程式碼**不**直接 `forceOffline`,而是 L194 呼叫 `void pollOnce()`——讓 polling 機制決定。這是聰明的做法:若 server 只是短暫 glitch(WebSocket 掉但 HTTP 還活),不會誤觸發 overlay。這比 TDD §4.3a L421-424 的原稿(直接 `forceOffline('manual-stop')`)更保守也更正確,避免 race condition。符合審查點 I 的要求。
|
|
||||||
|
|
||||||
**restart 延遲 10 s 的實作細節**(L171-176):延遲 callback 裡檢查「`!serverOnline || consecutiveFailures >= FAILURE_THRESHOLD`」才 `forceOffline`。這代表若 10 s 內 polling 成功(server 回來)→ `markOnline()` 會把 `serverOnline` 設回 true、`consecutiveFailures` 歸零 → callback 進不了 `forceOffline` 分支 → overlay 不顯示。符合 Design §9.3「短暫 restart 使用者無感」的期望。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. `ServerOfflineOverlay` React 元件
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 引用 |
|
|
||||||
|-------|-----|------|
|
|
||||||
| `role="alertdialog"` + `aria-modal="true"` | ✅ | L96-97 |
|
|
||||||
| `aria-labelledby` / `aria-describedby` / `aria-live="assertive"` | ✅ | L98-100 |
|
|
||||||
| Focus trap | ✅ | L37-65(初始 focus 推到 retry 按鈕 + Tab 循環)|
|
|
||||||
| Dynamic subtitle by `offlineReason` | ✅ | L81-92(quit / restart / healthcheck-failed / default 四分支)|
|
|
||||||
| Retry 按鈕 → `retryServerHealth` | ✅ | L69-78 |
|
|
||||||
| 無 close button | ✅ | 僅 retry + help text,符合 TDD §2 L34 註釋與 Design §4「ESC/點背景不 dismiss」|
|
|
||||||
| Help text「如要離開本頁請直接關閉分頁」 | ✅ | L157(i18n `offline.helpText`)|
|
|
||||||
| Dark Mode | ✅ | L105 `dark:bg-black/70`、L116 `dark:bg-destructive/15` |
|
|
||||||
| i18n key 正確 | ✅ | 全部透過 `t()` 抽離,無硬編文字 |
|
|
||||||
| `data-testid` 供測試 | ✅ | L106 `server-offline-overlay`、L143 `server-offline-retry` |
|
|
||||||
|
|
||||||
**Suggestion 1**:Design Spec §2.2 有「了解更多 ↓」展開按鈕+展開式 help text(§5.2、§5.3),目前簡化為一行 `helpText`。這是刻意簡化(M8-7 issue 也沒列出展開式 help),可接受,但建議在 `待人工介入` 不算,因 Design 未被嚴格比對為必需。如未來要做,可在後續 M 級修改補上,不阻擋交付。
|
|
||||||
|
|
||||||
**Suggestion 2**:Design §6 規定的 fade-in / translateY(20→0) 入場動畫、重試失敗 shake 動畫、`prefers-reduced-motion` 降級——目前無任何動畫。屬於 UX polish,非功能缺失,列為 Suggestion。
|
|
||||||
|
|
||||||
**Focus trap 品質**:L46-62 的實作符合基本要求(Tab / Shift+Tab 循環),但只在卡片內 focusable 數量 ≥ 1 時才有效;目前只有一顆 retry 按鈕,Tab 實際上會停在原位,也無害。符合題目「有基本實作,不一定完美」的標準。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. 掛載位置
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 引用 |
|
|
||||||
|-------|-----|------|
|
|
||||||
| `layout.tsx` 掛 `<ShutdownWatcherMount />` + `<ServerOfflineOverlay />` | ✅ | `layout.tsx` L11-12 import、L58-59 JSX |
|
|
||||||
| SSR 相容:client-only 邏輯在 `'use client'` + `useEffect` | ✅ | watcher L1、L92 `typeof window === 'undefined' return`;overlay L1 `'use client'` |
|
|
||||||
| 整個 app lifecycle 只有一個 watcher | ✅ | root layout 只掛一次;mount wrapper 本身 `return null` |
|
|
||||||
|
|
||||||
`shutdown-watcher-mount.tsx` 是薄薄的 `'use client'` wrapper,符合 Next.js 16 App Router 的 server/client 分離要求。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. i18n
|
|
||||||
|
|
||||||
| 檢查項 | 結果 | 引用 |
|
|
||||||
|-------|-----|------|
|
|
||||||
| `types.ts` 有 `offline.*` type | ✅ | types.ts L417-427 |
|
|
||||||
| `zh-TW.ts` 完整 | ✅ | zh-TW.ts L419-429 |
|
|
||||||
| `en.ts` 完整 | ✅ | en.ts L419-429 |
|
|
||||||
| key 對齊 component 使用 | ✅ | `title / subtitle.{quit,restart,healthcheck} / retryButton / retrying / helpText` 全 match |
|
|
||||||
| 文案符合 Design Spec §5 | ⚠️ 部分 | 詳見下 |
|
|
||||||
|
|
||||||
**Minor 3**:Design §5.1 繁中副標應為「visionA-local 已結束或崩潰,請重新開啟應用程式」,實作為三段差異化文案(`quit/restart/healthcheck`)。這其實**比**原 Design 更精準(原 Design 未考慮 restart / healthcheck 的動態文案),不算 bug,但偏離了 Design 的字面規格。建議記錄為「實作優化 Design」,或反向更新 Design §5.1 補上三段文案。**不阻擋交付**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. Build 結果
|
|
||||||
|
|
||||||
```
|
|
||||||
pnpm tsc --noEmit → PASS(無輸出)
|
|
||||||
pnpm build → PASS(12 頁 static export,Compiled successfully in 5.1s)
|
|
||||||
pnpm lint(新檔 8 檔) → 0 errors / 0 warnings
|
|
||||||
pnpm lint(全專案) → 11 errors 全部來自既有檔案(use-websocket / use-first-visit /
|
|
||||||
use-server-health / model-comparison-dialog / auto-connect),
|
|
||||||
與 M8-7 無關
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. SSR 相容性
|
|
||||||
|
|
||||||
- `system-store.ts`:純 zustand,無 window/localStorage 存取,SSR safe。
|
|
||||||
- `use-shutdown-watcher.ts`:全部副作用包在 `useEffect` + L92 額外 `typeof window === 'undefined' return` 雙保險。
|
|
||||||
- `server-offline-overlay.tsx`:`'use client'`,且 `show=false` 時 L67 直接 `return null`,SSR 輸出空字串 → 後端無 window 存取。
|
|
||||||
- `shutdown-watcher-mount.tsx`:`'use client'` + `return null`。
|
|
||||||
- `layout.tsx`:未加 `'use client'`(root layout 仍是 server component),但掛的兩個元件都是 client component,Next.js 會自動切換 island 邊界。
|
|
||||||
|
|
||||||
`pnpm build` 通過 12 頁 static generation 證明沒有 `ReferenceError: window is not defined`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. M8-9 預留
|
|
||||||
|
|
||||||
- ✅ `bootId` field(L26 store)
|
|
||||||
- ✅ `setBootId` action(L37、L73)
|
|
||||||
- ✅ **不做** boot-id 比對 + force reload:hook `pollOnce` L83-84 只 `setBootId + markOnline`,沒有 `if (prevBootId !== newBootId) window.location.reload()`。符合 M8-9 分工。
|
|
||||||
- 檔頭 L13-14 註解明確聲明「M8-7 不負責 boot-id 比對 + force reload」,給未來 reader 清楚信號。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. 容錯
|
|
||||||
|
|
||||||
| 情境 | 行為 | 是否符合預期 |
|
|
||||||
|-----|------|------------|
|
|
||||||
| `/ws/system` 從未連上 | `wsEverConnected=false`,onclose 不觸發 forcedOffline | ✅ L188-196 |
|
|
||||||
| `/ws/system` 連上後斷線 | `wsEverConnected=true`,僅呼叫 `pollOnce()` 由 polling 決定 | ✅ L192-195(比 TDD 更保守)|
|
|
||||||
| Network error / AbortError | `pollOnce` catch 統一 → `recordFailure` | ✅ L85-87 |
|
|
||||||
| fetch 非 200(5xx / 非 JSON) | `recordFailure` | ✅ L73-80 |
|
|
||||||
| 背景 tab recover double-probe | visibilitychange L215-228 先 `pollOnce` 再 `startPolling`,同一 tick 內只 probe 一次;setInterval 是新排程,不會 race | ✅ |
|
|
||||||
| 連上 server 時自動 reconnect | 5 s reconnect timer L204-212,有 `cancelled` + `wsReconnectTimer !== null` 雙重 guard 避免堆疊 | ✅ |
|
|
||||||
| Restart defer 後 server 回來 | L173 條件 `!serverOnline || failures >= 2` 未成立 → 不 forceOffline | ✅ |
|
|
||||||
|
|
||||||
**容錯整體評價**:比 TDD §4.3a 原稿謹慎,實務上更不易誤觸發。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## J. 問題清單
|
|
||||||
|
|
||||||
### Critical
|
|
||||||
(無)
|
|
||||||
|
|
||||||
### Major
|
|
||||||
(無)
|
|
||||||
|
|
||||||
### Minor
|
|
||||||
|
|
||||||
| # | 檔案 | 行數 | 問題 | 建議 |
|
|
||||||
|---|------|-----|------|------|
|
|
||||||
| 1 | `use-shutdown-watcher.ts` | 188, 194 | WebSocket onclose 後 `ws = null` 與 cleanup 的互動較隱晦(清理路徑 L252 不會進)| 加 1-2 行註解說明「已設 null,cleanup 靠 `cancelled` flag 攔後續 reconnect」 |
|
|
||||||
| 2 | `server-offline-overlay.tsx` | 81-92 | `subtitleKey` 對 `'unknown'` 會 fallback 到 `healthcheck` 文案,雖然合理,但英文環境可能讓使用者困惑 | 可選:為 `unknown` 另外加一個 i18n key,或在 `offlineReason === 'unknown'` 時顯示通用錯誤 |
|
|
||||||
| 3 | `zh-TW.ts` / `en.ts` | 419-429 | 副標文案比 Design §5.1 多了三段 reason 分支(Design 只列一段通用文案)| 反向更新 Design §5.1 補上三段,或保持現狀並在 PR 說明 |
|
|
||||||
|
|
||||||
### Suggestion
|
|
||||||
|
|
||||||
| # | 檔案 | 建議 |
|
|
||||||
|---|------|------|
|
|
||||||
| 1 | `server-offline-overlay.tsx` | Design §2.2 有「了解更多 ↓」展開式 help text(§5.2 詳細重啟步驟),目前簡化為單行。未來可補 |
|
|
||||||
| 2 | `server-offline-overlay.tsx` | Design §6 的 fade-in / translateY / shake / reduced-motion 動畫全缺,列為 polish |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## K. 結論
|
|
||||||
|
|
||||||
**✅ 通過**。M8-7 在功能正確性、SSR 相容、容錯設計、M8-9 預留、i18n 覆蓋上都達到交付標準。新增的四個檔案 lint/type-check/build 全部乾淨;修改的 `layout.tsx` + i18n 三檔也沒有破壞既有頁面(build 出 12 頁 static export 證明 /workspace, /devices, /models, /settings 等都 OK)。
|
|
||||||
|
|
||||||
**重點亮點:**
|
|
||||||
1. **容錯比規格更保守**:WebSocket close 不直接 forceOffline 而是交給 polling 驗證(L192-195),避免 race。
|
|
||||||
2. **M8-9 分工乾淨**:store 保留 `bootId` 但刻意不做 reload,檔頭註解明確聲明,未來 M8-9 接手零負擔。
|
|
||||||
3. **SSR 雙保險**:`'use client'` + `typeof window === 'undefined'` return,build 通過是最硬的證據。
|
|
||||||
4. **零新增 lint/type 錯誤**。
|
|
||||||
|
|
||||||
**不需修改即可進 M8-8 / M8-9 / M8-10**。Minor 1-3 + Suggestion 1-2 建議記錄為技術債,於後續迭代處理。
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
# Reviewer 審查 M8-8 CORS middleware(2026-04-15)
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
- **總結論**:✅ **通過**。CORSMiddleware + WebSocket CheckOrigin 實作完全符合 `v2/cors-security.md` §3–§5,build / vet / test / race 全部 PASS,6 個 smoke test 親跑行為正確。
|
|
||||||
- **阻擋 M8-10 嗎**:不阻擋。所有 Critical / Major 皆零,僅 3 個 Minor / Suggestion,不影響後續任務。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. Origin 判斷邏輯
|
|
||||||
|
|
||||||
`server/internal/api/middleware.go:33-46`
|
|
||||||
|
|
||||||
- [x] 使用 `url.Parse` + `strings.ToLower(u.Hostname())` + map 查表(非 regex / prefix match)
|
|
||||||
- [x] 白名單 map `allowedHosts`(第 17-22 行)包含 `127.0.0.1` / `localhost` / `::1`
|
|
||||||
- [x] Scheme check 強制 `http`(第 41 行),自動擋掉 `https://` / `ws://` / `wss://`
|
|
||||||
- [x] `url.Hostname()` 自動處理 IPv6 方括號:`http://[::1]:3721` → hostname `::1`
|
|
||||||
- [x] 大小寫不敏感(`strings.ToLower`)— 測試 `http://LOCALHOST:9999` PASS
|
|
||||||
- [x] 抗 suffix attack:`http://127.0.0.1.evil.com` → hostname 完整等於 `127.0.0.1.evil.com`,map 查表 miss → false(測試 case 驗證)
|
|
||||||
- [x] 空字串 / `"null"` 明確拒絕(第 34 行)
|
|
||||||
|
|
||||||
**符合 TDD §3.1 / §4.1 全部要求。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. CORSMiddleware 行為
|
|
||||||
|
|
||||||
`server/internal/api/middleware.go:62-106`
|
|
||||||
|
|
||||||
| 情境 | 實作 | TDD 要求 | 結果 |
|
|
||||||
|------|------|---------|------|
|
|
||||||
| Same-origin(無 Origin)非 OPTIONS | 直接 `c.Next()`,不回 ACA | §4.1 注釋 | ✅ |
|
|
||||||
| Same-origin OPTIONS | 回 204 即停 | §4.1 | ✅ |
|
|
||||||
| 白名單 Origin 非 OPTIONS | 回 ACA-Origin/Methods/Headers/Credentials + `Vary: Origin` → next | §4.1 | ✅ |
|
|
||||||
| 白名單 OPTIONS 預檢 | 回完整 ACA + 204 | §3.2 | ✅(smoke 3 驗證) |
|
|
||||||
| 非白名單 POST/PUT/DELETE/PATCH/OPTIONS | 403,不回 ACA | §3.2 / §4.1 | ✅ |
|
|
||||||
| 非白名單 GET/HEAD | 執行 handler 但無 ACA | §3.3 | ✅(smoke 5 驗證) |
|
|
||||||
|
|
||||||
- [x] 砍掉 v1 的 `X-Relay-Token`(relay 功能 M1 已砍)
|
|
||||||
- [x] `Vary: Origin` 已加(第 98 行)
|
|
||||||
- [x] `Access-Control-Allow-Credentials: true`(第 97 行)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. Test 覆蓋度
|
|
||||||
|
|
||||||
### `middleware_test.go`
|
|
||||||
|
|
||||||
- **TestIsAllowedOrigin**(19 case):
|
|
||||||
- 白名單 7 case(含 IPv6、大小寫、無 port)
|
|
||||||
- scheme 3 case(https × 2、ws × 1)
|
|
||||||
- hostname 5 case(192.168.x / example.com / malicious.local / suffix 攻擊 × 2)
|
|
||||||
- 特殊 4 case(空字串、"null"、"http://"、"not-a-url")
|
|
||||||
- **TestCORSMiddleware_* × 7**:
|
|
||||||
- AllowedOriginGET / LocalhostAllowed / DisallowedOriginPOST / DisallowedOriginGET / PreflightAllowed / PreflightDisallowed / SameOrigin
|
|
||||||
- 涵蓋 `Vary: Origin`、`ACA-Credentials`、非白名單不得回 ACA 三項關鍵斷言。
|
|
||||||
|
|
||||||
### `ws/origin_test.go`
|
|
||||||
|
|
||||||
- **TestCheckOrigin**(9 sub-test):empty / 127.0.0.1 / localhost / `[::1]` / https / 192.168 / evil / null / suffix 攻擊
|
|
||||||
|
|
||||||
**測試覆蓋度完整,符合 TDD §4.2 要求(TDD 只列了 10 case,實作更完整,算加分)。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. WebSocket Origin 獨立 package
|
|
||||||
|
|
||||||
- [x] `ws/origin.go` 不 import `api` package(只 import `net/http` / `net/url` / `strings`),避免 `api ↔ ws` 循環(`origin.go:1-7` + 注釋 14-17 有明確說明)
|
|
||||||
- [x] 邏輯與 `api/middleware.go` 的 `isAllowedOrigin` **一致**(scheme、ToLower、hostname 白名單)
|
|
||||||
- [x] `ws/device_events_ws.go:13-15` 宣告 package 層級共用 `var upgrader`,`CheckOrigin: CheckOrigin`;其他 WS handler 全部共用同一個 upgrader(`flash_ws.go:11`、`inference_ws.go:13`、`server_logs_ws.go:14`、`system_ws.go:29` 皆 `upgrader.Upgrade(...)`,grep 驗證)
|
|
||||||
- [x] `device_events_ws.go` 已移除 `net/http` import(依任務描述),建立 upgrader 不再使用 inline anonymous `CheckOrigin: func(r *http.Request) bool { return true }`
|
|
||||||
|
|
||||||
**唯一與 HTTP middleware 的行為差異**:`CheckOrigin` 對空 Origin 回 `true`(same-origin 或 websocat / Postman 非瀏覽器 client),而 HTTP middleware 對空 Origin 走「same-origin 快路徑 → next」。兩者在語意上一致:瀏覽器 same-origin 不送 Origin,非瀏覽器 client(如 websocat)也不會被誤擋。此處符合 TDD §5 注釋。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Router 整合
|
|
||||||
|
|
||||||
`server/internal/api/router.go:44-45`
|
|
||||||
|
|
||||||
- [x] `CORSMiddleware()` 掛在 `broadcasterLogger` **後面**、`api := r.Group("/api")` 之前,位置合理
|
|
||||||
- [x] M8-4 的 `broadcasterLoggerSkipPaths`(`/api/system/boot-id` + `/api/system/health`)沒被動到(行 123-128、144-147)
|
|
||||||
- [x] M8-4b 的 Hub sentinel 機制沒被動到(`hub_sentinel_test.go` 跑過 race 仍 PASS,`ws/*.go` 未修改 Hub 相關 code)
|
|
||||||
- [x] WS route 註冊(行 97-102)沒動,所有 WS handler 繼續透過共用 `upgrader` 自動走新 `CheckOrigin`
|
|
||||||
|
|
||||||
**順序微注意**:`CORSMiddleware` 掛在 `broadcasterLogger` 之後,代表 403 的 preflight 會被寫 access log。這對排障有幫助,不是問題,符合預期。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. Build / Test / Race 結果
|
|
||||||
|
|
||||||
```
|
|
||||||
$ cd server && go build ./... → PASS(無輸出)
|
|
||||||
$ cd server && go vet ./... → PASS(無輸出)
|
|
||||||
$ cd server && go test ./... → ALL PASS
|
|
||||||
ok visiona-local/server/internal/api 0.921s
|
|
||||||
ok visiona-local/server/internal/api/ws 0.585s
|
|
||||||
ok visiona-local/server/internal/device
|
|
||||||
ok visiona-local/server/internal/model
|
|
||||||
$ cd server && go test -race ./... → ALL PASS(api 2.069s / ws 1.629s)
|
|
||||||
```
|
|
||||||
|
|
||||||
`go test -v -run 'TestIsAllowedOrigin|TestCORSMiddleware|TestCheckOrigin'` 所有 case 逐一 PASS,無任何 FAIL / SKIP。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. Smoke test 結果
|
|
||||||
|
|
||||||
於 `127.0.0.1:3721`(本機已啟動 server,binary 為今日 go-build 產物)親跑:
|
|
||||||
|
|
||||||
| # | curl | 預期 | 實測 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| 1 | `GET /api/system/health -H "Origin: http://127.0.0.1:9999"` | 200 + ACA | ✅ 200 + `ACA-Origin: http://127.0.0.1:9999` + `Vary: Origin` + `ACA-Credentials: true` |
|
|
||||||
| 2 | `POST /api/devices/scan -H "Origin: http://evil.com"` | 403 | ✅ 403,無 ACA header |
|
|
||||||
| 3 | `OPTIONS /api/camera/start -H "Origin: http://127.0.0.1:9999" -H "Access-Control-Request-Method: POST"` | 204 + 完整 ACA | ✅ 204 + `ACA-Methods: GET,POST,PUT,DELETE,OPTIONS` + `ACA-Origin: http://127.0.0.1:9999` |
|
|
||||||
| 4 | `POST /api/devices/scan -H "Origin: null"` | 403 | ✅ 403 |
|
|
||||||
| 5 | `GET /api/system/health -H "Origin: http://evil.com"` | 200 **無** ACA | ✅ 200,response header 只有 content-type / date / length |
|
|
||||||
| 6 | `POST /api/devices/scan -H "Origin: https://127.0.0.1:3721"` | 403 | ✅ 403(scheme mismatch) |
|
|
||||||
|
|
||||||
全部 6 條行為與 TDD §9 驗收表對應欄位完全吻合。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. 安全檢查
|
|
||||||
|
|
||||||
- [x] IPv6 `http://[::1]:3721` parse 正確(hostname 回 `::1`,map 命中)
|
|
||||||
- [x] Origin 大小寫不敏感(`strings.ToLower` 處理 `HTTP://127.0.0.1` 的 hostname 部分;但注意 `u.Scheme` 是小寫,因為 `url.Parse` 會 normalize scheme;若原字串為 `HTTP://...`,`u.Scheme` 仍回 `http`,測試 case `http://LOCALHOST:9999` PASS)
|
|
||||||
- [x] Port 任意放行(map 只查 hostname)
|
|
||||||
- [x] `ws://` / `wss://` **不支援**:測試 case `ws://127.0.0.1:3721` 明確斷言 false ✅。WebSocket 本身不用 Origin 比對 scheme(HTTP upgrade 的 Origin header 本來就是 http/https),所以這個行為正確。
|
|
||||||
- [x] hostname 完整 match:`127.0.0.1` ≠ `127.0.0.10`(map 查表天然安全),亦擋 `127.0.0.1.evil.com`
|
|
||||||
- [x] IPv4 loopback 只允許 `127.0.0.1`(`127.0.0.2` 等非標準 loopback 不放行,符合 TDD)
|
|
||||||
- [x] `u.Scheme != "http"` 擋掉 `https`(smoke 6 驗證)
|
|
||||||
|
|
||||||
**無發現攻擊面。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. 遺漏項
|
|
||||||
|
|
||||||
### TDD §4.3 的「二道防線」`requireSameOriginOrNoOrigin` 未實作
|
|
||||||
|
|
||||||
TDD `v2/cors-security.md:210-244` 建議在 `/api` group 再掛一層 origin check 作為 defense-in-depth,但實作只有 CORSMiddleware(單層)。
|
|
||||||
|
|
||||||
**判讀**:TDD 本身在 §4.3 的開頭寫的是「額外掛一層 origin check,確保即使 CORSMiddleware 有 bug 也不會出事」,屬於 defensive bonus,而不是主線必須。§9 的驗收表格沒有列對應 case。因此:
|
|
||||||
|
|
||||||
- **不算 Critical / Major**
|
|
||||||
- 歸為 **Minor** 建議(見下方清單)
|
|
||||||
|
|
||||||
### 其他可優化但不阻擋
|
|
||||||
|
|
||||||
- `allowedHosts` map 同時有 `"[::1]"` 與 `"::1"` 兩個 key。`url.Parse("http://[::1]:3721").Hostname()` 只會回 `::1`(不帶方括號),所以 `"[::1]"` 是 dead entry。
|
|
||||||
- `ws/origin.go:36` 的 switch 同樣寫 `case "127.0.0.1", "localhost", "::1", "[::1]"`,`[::1]` 永不命中。
|
|
||||||
|
|
||||||
**不影響正確性**,但有誤導閱讀的風險。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## J. 問題清單
|
|
||||||
|
|
||||||
### Critical
|
|
||||||
|
|
||||||
無。
|
|
||||||
|
|
||||||
### Major
|
|
||||||
|
|
||||||
無。
|
|
||||||
|
|
||||||
### Minor
|
|
||||||
|
|
||||||
| # | 檔案 | 行數 | 問題描述 | 建議修改方式 |
|
|
||||||
|---|------|------|----------|-------------|
|
|
||||||
| 1 | `server/internal/api/middleware.go` | 20 | `allowedHosts["[::1]"] = true` 為 dead entry,`url.Hostname()` 不會回傳帶方括號版本 | 移除 `"[::1]": true,` 這一行,並在 `::1` 同列加注釋「IPv6 loopback,`url.Hostname()` 會拆掉方括號」 |
|
|
||||||
| 2 | `server/internal/api/ws/origin.go` | 36 | 同上,switch 的 `"[::1]"` case 永不命中 | 移除 `"[::1]"` case |
|
|
||||||
| 3 | `server/internal/api/router.go` | 52 | 未實作 TDD §4.3 的 `requireSameOriginOrNoOrigin` 二道防線 | 若使用者 / Architect 要求 defense-in-depth,補上 `api.Use(requireSameOriginOrNoOrigin())`;否則在 TDD §4.3 加 note 說明「已評估,單層 CORSMiddleware 足夠,不做」 |
|
|
||||||
|
|
||||||
### Suggestion
|
|
||||||
|
|
||||||
| # | 檔案 | 行數 | 建議內容 |
|
|
||||||
|---|------|------|----------|
|
|
||||||
| 1 | `server/internal/api/middleware.go` | 94 | `ACA-Methods` 固定 `GET, POST, PUT, DELETE, OPTIONS`,沒有 `HEAD` / `PATCH`。若未來有 PATCH handler 可能要補上。目前 router 無 PATCH 路由,不緊急 |
|
|
||||||
| 2 | `server/internal/api/ws/origin.go` | 18-40 | 可考慮讓 `api.isAllowedOrigin` export 成 `api.IsAllowedOrigin`,再讓 `ws/origin.go` import;不過這會引入 `ws → api` 依賴,需評估層級。目前重複實作 + 注釋交代清楚也可接受 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## K. 結論
|
|
||||||
|
|
||||||
**✅ 通過 Review,M8-8 可標記為完成,不阻擋 M8-10。**
|
|
||||||
|
|
||||||
實作品質摘要:
|
|
||||||
- 邏輯嚴謹:URL parse + map 查表,天然抗 suffix attack;scheme 強制 `http`;大小寫不敏感
|
|
||||||
- 測試完整:19 + 7 + 9 = 35 個 assertion,含正向 / 反向 / 邊界
|
|
||||||
- 架構乾淨:`ws/origin.go` 獨立實作避免 package cycle,注釋清楚交代理由;所有 WS handler 共用一個 package 層級 `upgrader` 變數,單點維護
|
|
||||||
- 驗證紮實:build / vet / test / race 全部 PASS,6 條 smoke test 對齊 TDD §9 驗收表
|
|
||||||
|
|
||||||
建議下一輪(或與後續任務一併)處理的 Minor:移除 `[::1]` dead entry × 2、決定是否實作 §4.3 二道防線。皆非阻擋項。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Reviewer**:Autoflow Reviewer Agent
|
|
||||||
**日期**:2026-04-15
|
|
||||||
**審查輪次**:第 1 輪
|
|
||||||
**下游任務**:M8-10(不阻擋)
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
# Reviewer 審查 M8-9 Boot-ID + tab 重連(2026-04-15)
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
- 結論:**✅ 通過**,無 Critical / Major 問題。
|
|
||||||
- 是否阻擋 M8-10:**不阻擋**,可直接進入 M8-10。
|
|
||||||
- 問題統計:Critical 0 / Major 0 / Minor 2 / Suggestion 1
|
|
||||||
- 親跑:`tsc` PASS、`test` 19/19 PASS、`lint`(改動檔零 error)、`build` 12 頁 PASS。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. `checkAndUpdateBootId` action
|
|
||||||
比對 TDD §9.3 規格(`system-store.ts:92-103`):
|
|
||||||
|
|
||||||
| 行為 | 實作 | 狀態 |
|
|
||||||
|------|------|------|
|
|
||||||
| `bootId === null` → 記錄 + 回 `'first'` | L94-97 `set({ bootId: newBootId })` → return `'first'` | ✅ |
|
|
||||||
| `bootId === newBootId` → 回 `'match'`,不改 store | L98-100 純 return | ✅ |
|
|
||||||
| `bootId !== newBootId` → 回 `'mismatch'`,**刻意不改 store** | L101-102 註解明確說明用意(reload 後新 tab 重走 first) | ✅ |
|
|
||||||
| TypeScript `BootIdCheckResult` union 定義 | L18-26 `'first' \| 'match' \| 'mismatch'` + JSDoc 完整 | ✅ |
|
|
||||||
|
|
||||||
語意正確,註解與 TDD「mismatch 時不更新 store」的理由一致。`get()` 而非閉包 snapshot,避免競態。
|
|
||||||
|
|
||||||
## B. `handleBootIdCheck` helper
|
|
||||||
`use-shutdown-watcher.ts:87-123`:
|
|
||||||
|
|
||||||
- **封裝完整**:呼叫 store action、處理 loop guard、觸發 reload 全部集中於此 helper。
|
|
||||||
- **DRY**:`pollOnce`(L146)與 `retryServerHealth`(L347)都走同一 helper,沒有重複邏輯。
|
|
||||||
- **Response shape 正確**:`BootIdResponse` type(L46-52)完整對應 TDD §9.2 `{ success, data: { bootId, startedAt } }`。L140 / L342 對 `json.success` 與 `json.data?.bootId` 雙重驗證,若缺欄位改走 `recordFailure`,不會把 undefined 丟進比對路徑。
|
|
||||||
- **回傳語意**:`true` = 繼續後續流程(first / match),`false` = 已觸發 reload(或 SSR / guard skip),呼叫端不再 `markOnline`。L90-92 的 early return 避免對「即將 reload」的 tab 做多餘的 markOnline。
|
|
||||||
|
|
||||||
## C. Reload loop guard
|
|
||||||
`use-shutdown-watcher.ts:87-123`:
|
|
||||||
|
|
||||||
| 檢查項 | 實作 | 狀態 |
|
|
||||||
|--------|------|------|
|
|
||||||
| key = `visiona-reload-loop-guard` | L38 `const RELOAD_LOOP_GUARD_KEY` | ✅ |
|
|
||||||
| 正常情境:reload → store reset → 走 first 路徑不誤觸發 | L89 `checkAndUpdateBootId` 先於 guard 比對;first 直接 return true,guard 不會被讀到 | ✅ |
|
|
||||||
| 異常情境(server 每次回不同 bootId):guard 命中同一 id → skip reload + `setBootId + markOnline` | L102-111 | ✅ |
|
|
||||||
| sessionStorage 失敗(隱私模式)→ 仍 reload | L100-115 整段包 `try { ... } catch {}`,catch 為空但之後 fall through 到 L117-121 `window.location.reload()` | ✅ |
|
|
||||||
| guard key 寫入時機:**reload 之前** | L112 `setItem` → L121 `reload()`,順序正確 | ✅ |
|
|
||||||
| `typeof window === 'undefined'` → SSR 保險直接 return false | L95-98 | ✅ |
|
|
||||||
|
|
||||||
**Minor 1**:catch 空 block(L113-115)沒有任何 log,若 sessionStorage 存取失敗(即便在隱私視窗)開發者將看不到訊號。建議加 `console.warn('[boot-id] sessionStorage unavailable, reload guard disabled')`,只是提醒用,不阻擋 reload。不影響行為,不阻擋合併。
|
|
||||||
|
|
||||||
## D. 和 M8-7 的 integration
|
|
||||||
- `pollOnce` (L126-152):保留原流程,新增路徑僅是在 `res.ok + json.success + data.bootId` 全部成立後插入 `handleBootIdCheck`;失敗分支完全沿用 M8-7 的 `recordFailure`,polling → overlay 行為不變。
|
|
||||||
- WebSocket `server:shutdown-imminent`(L225-249)、restart 10 s defer(L232-242)、visibility change probe(L279-292)、cleanup(L303-323)全部不動。
|
|
||||||
- Active retry 3 s / normal 10 s 切換邏輯(L186-201)不動,boot-id 比對只發生在「成功取得 response」那條路徑,不影響 retry 節奏。
|
|
||||||
- `retryServerHealth`(L328-358,M8-7 既有函式)被 patch 成走 `handleBootIdCheck`,對 overlay 的「手動 retry 按鈕」呼叫者透明。
|
|
||||||
|
|
||||||
既有測試(M8-7、model-store、lib/api)全數通過 → 零回歸。
|
|
||||||
|
|
||||||
## E. Test 品質
|
|
||||||
|
|
||||||
### Store tests(`system-store.test.ts`)— 4 個
|
|
||||||
1. `first` call 情境 → 驗 return + `bootId` 被設(L24-28)✅
|
|
||||||
2. 相同 bootId → 驗 `'match'` + store 不動(L30-35)✅
|
|
||||||
3. 不同 bootId → 驗 `'mismatch'` + store 保留舊值(L37-43)✅
|
|
||||||
4. Reset 後再呼叫 → 重新 `'first'`,模擬 reload 後情境(L45-52)✅
|
|
||||||
|
|
||||||
TDD §12 要求的三路徑 + 邊界全數覆蓋。beforeEach(L14-22)確實 reset 所有欄位,隔離性 OK。
|
|
||||||
|
|
||||||
### Hook tests(`use-shutdown-watcher.test.ts`)— 5 個
|
|
||||||
1. 首次 fetch → setBootId + markOnline,無 reload(L65-74)✅
|
|
||||||
2. 相同 id fetch → markOnline,無 reload(L76-85)✅
|
|
||||||
3. 異 id fetch → `window.location.reload` 被呼叫 + guard 寫入 + store 不更新(L87-99)✅
|
|
||||||
4. Loop guard 命中 → 不 reload + store 對齊新 id + `markOnline`(L101-113)✅
|
|
||||||
5. Fetch 失敗 → `recordFailure` + 無 reload(L115-124)✅
|
|
||||||
|
|
||||||
`mockFetchBootId` helper 乾淨,`vi.stubGlobal('fetch', ...)` 正確。`Object.defineProperty(window, 'location', ...)` 是 jsdom 下替換 `location.reload` 的標準做法(jsdom 的 `window.location` 不可直接賦值)。`afterEach` L61-63 `vi.restoreAllMocks()` 搭配 beforeEach 清除 `sessionStorage` + 重置 store,測試間無耦合。
|
|
||||||
|
|
||||||
**Suggestion**:SSR 情境(`typeof window === 'undefined'`)走 false path(L95-98),目前 hook test 不覆蓋。因為 jsdom 下必定有 window,要另寫 node env test。優先級低,M8-9 可接受不補。
|
|
||||||
|
|
||||||
**Minor 2**:task brief 提到「sessionStorage 失敗(私密視窗):catch 後仍 reload」的邊界 case,但 hook test 5 個中沒有直接模擬 `sessionStorage.setItem` throw 的情境。實作正確(L100-115 catch fall through 到 reload),但無 regression guard。建議補一個 test:`sessionStorage.setItem` 用 `vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error(); })` → 驗 `reloadSpy` 仍被呼叫。非阻擋性,可列入 M8-10 之後的測試補強清單。
|
|
||||||
|
|
||||||
## F. Build / Test / Lint 結果
|
|
||||||
|
|
||||||
| 指令 | 結果 |
|
|
||||||
|------|------|
|
|
||||||
| `pnpm tsc --noEmit` | ✅ 零錯誤(無任何輸出) |
|
|
||||||
| `pnpm test` | ✅ 4 test files / 19 tests 全綠(含本次新增 4+5) |
|
|
||||||
| `pnpm lint`(全 repo) | ⚠️ 16 問題全部為 **pre-existing**(`use-server-health.ts`、`use-websocket.ts` 等檔),**M8-9 改動的 4 個檔零 error / 零 warning** |
|
|
||||||
| `pnpm build` | ✅ Compiled successfully 5.5s / 12 頁 SSG 全數 prerender 完成 |
|
|
||||||
|
|
||||||
既有 repo lint 問題不在本次審查範疇,不阻擋 M8-9。
|
|
||||||
|
|
||||||
## G. SSR 相容
|
|
||||||
- `'use client'` directive 在 L1 正確標記。
|
|
||||||
- `window`、`sessionStorage`、`window.location.reload` 全部在 `useEffect`(L155)或 `handleBootIdCheck` 函式內使用,且 L95-98 有 `typeof window === 'undefined'` guard(雙重保險,因為 hook 只會在 client 執行但 `retryServerHealth` 可能被 server component 誤呼)。
|
|
||||||
- `retryServerHealth` 走同一 `handleBootIdCheck`,SSR guard 也保護它。
|
|
||||||
- Next.js `pnpm build` 的 SSG prerender 12 頁全部成功,證實 build 階段不會觸發 `window is not defined`。
|
|
||||||
|
|
||||||
## H. 完整 flow 驗證(讀 code 推論)
|
|
||||||
|
|
||||||
### 場景 1:Restart Server
|
|
||||||
1. Wails `notifyShutdownImminent(reason=restart)` → WS broadcast。
|
|
||||||
2. Browser tab 收到(`use-shutdown-watcher.ts:225-249`)→ `restartDeferTimer` 啟動 10 s defer(L234-241),**不立刻** `forceOffline`。
|
|
||||||
3. 每 10 s(normal)/ 3 s(active-retry)poll `/api/system/boot-id`。Restart 期間 server 約 3 s 起步,fetch 連續 `ECONNREFUSED` → `recordFailure` 累計 → consecutiveFailures ≥ 2 → subscribe callback(L186-201)自動切 active-retry。
|
|
||||||
4. Server re-spawn → 新 boot-id。
|
|
||||||
5. 下一次 `pollOnce` → `res.ok + json.success + data.bootId` → `handleBootIdCheck`:store 裡舊 bootId ≠ 新 bootId → mismatch → guard 寫入 → `window.location.reload()`。
|
|
||||||
6. 新 tab 載入 → Zustand store reset `bootId = null` → 首次 poll → `'first'` → `setBootId` + `markOnline` → 正常運作。
|
|
||||||
|
|
||||||
路徑正確 ✅
|
|
||||||
|
|
||||||
### 場景 2:Quit
|
|
||||||
1. Wails `notifyShutdownImminent(reason=quit)` → WS broadcast。
|
|
||||||
2. Browser tab 收到 → `mapReason('quit') → 'quit'` → L245 `forceOffline('quit')` 立即執行(**沒有 10 s defer**,defer 只在 `reason === 'restart'` 分支,L232)。
|
|
||||||
3. Polling 持續失敗(server 整組關掉)→ overlay 顯示「請重開 app」。
|
|
||||||
4. 每次 poll fail → `recordFailure`,**不進入** `handleBootIdCheck`(整個 L146 block 要 `res.ok + success + data.bootId` 才會進)。
|
|
||||||
5. Server 沒回來,`reloadSpy` 永不會被呼叫。
|
|
||||||
|
|
||||||
路徑正確,不會誤觸發 reload ✅
|
|
||||||
|
|
||||||
### 場景 3:網路 glitch
|
|
||||||
1. 單次 health check fail → `recordFailure`,consecutiveFailures = 1,尚未 ≥ 2 門檻 → **不** forceOffline、**不** 切模式。
|
|
||||||
2. 第二次失敗 → consecutiveFailures = 2 → subscribe callback 補上 `forceOffline('healthcheck-failed')`(L191-197)→ 切 active retry。
|
|
||||||
3. Active retry 3 s 後 → server 還在 → 拿到**同**一 boot-id → `handleBootIdCheck` 回 `'match'`(因為 store.bootId 沒被任何人改)→ L147 的 `if (!shouldContinue)` false → 往下到 L148 `markOnline()`(會同時 reset consecutiveFailures=0 + forcedOffline=false + offlineReason=null)→ subscribe callback 偵測到 shouldActiveRetry=false,下一次 polling tick 自動切回 normal 10 s。
|
|
||||||
4. `reloadSpy` 不會被呼叫。
|
|
||||||
|
|
||||||
路徑正確,不會誤 reload ✅
|
|
||||||
|
|
||||||
**額外確認**:Restart 路徑下,如果 boot-id 變化先於 restartDeferTimer 10 s 到期 → `handleBootIdCheck` 會直接 reload,restartDeferTimer 還在 running。`useEffect` cleanup(L312-315)會 clear timer;但 reload 觸發後頁面立刻卸載,timer 會被 jsdom / 瀏覽器自然清掉,無副作用。若 reload 被 SSR / guard skip(極端情境),restartDeferTimer 仍會 10 s 後執行,此時 `forceOffline('restart')` 的 guard 檢查 `!s.serverOnline || consecutiveFailures >= FAILURE_THRESHOLD`(L237)會判定為 false(guard skip 路徑裡 markOnline 已被呼叫)→ 不會誤顯示 overlay。健壯。
|
|
||||||
|
|
||||||
## I. 問題清單
|
|
||||||
|
|
||||||
### Critical
|
|
||||||
無。
|
|
||||||
|
|
||||||
### Major
|
|
||||||
無。
|
|
||||||
|
|
||||||
### Minor
|
|
||||||
| # | 檔案 | 行 | 問題 | 建議 |
|
|
||||||
|---|------|----|------|------|
|
|
||||||
| 1 | `frontend/src/hooks/use-shutdown-watcher.ts` | 113-115 | sessionStorage try/catch 吞 error 無 log,異常發生時無法從 console 察覺 | catch 內加 `console.warn('[boot-id] sessionStorage unavailable, reload guard disabled')`,不改 control flow |
|
|
||||||
| 2 | `frontend/src/tests/hooks/use-shutdown-watcher.test.ts` | — | 無 test 覆蓋「sessionStorage.setItem throw → 仍 reload」邊界 | 用 `vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error(); })` 補 1 個 test,驗 `reloadSpy` 仍呼叫 1 次 |
|
|
||||||
|
|
||||||
### Suggestion
|
|
||||||
| # | 檔案 | 行 | 建議 |
|
|
||||||
|---|------|----|------|
|
|
||||||
| 1 | `frontend/src/hooks/use-shutdown-watcher.ts` | 95-98 | SSR guard 分支(`typeof window === 'undefined'`)無對應 test;jsdom 下難模擬。可延後到專門的 SSR 測試環境補 |
|
|
||||||
|
|
||||||
## J. 結論
|
|
||||||
|
|
||||||
M8-9 Boot-ID + tab 自動重連實作完整、流程正確、測試充分。
|
|
||||||
|
|
||||||
- Store action `checkAndUpdateBootId` 三路徑語意與 TDD §9.3 一致,`mismatch` 刻意不改 store 的設計與 reload-後-新-tab 重走 first 路徑完美配合。
|
|
||||||
- Helper `handleBootIdCheck` DRY 封裝 guard + reload,`pollOnce` / `retryServerHealth` 共用同一路徑。
|
|
||||||
- Reload loop guard(sessionStorage)在正常 / 異常 / 隱私視窗三種情境下都有正確 fall back,不會卡 loop。
|
|
||||||
- M8-7 既有邏輯(polling 模式切換、WebSocket、visibility、restart defer)全數不變,19 個 tests 全綠零回歸。
|
|
||||||
- 三個關鍵場景(Restart / Quit / glitch)讀 code 推論路徑全部正確,特別是 Quit 不會誤觸發 reload(`handleBootIdCheck` 進入條件嚴格)、glitch 同 bootId 會乾淨 recover。
|
|
||||||
- `tsc` / `build` / 改動檔 `lint` 全數 PASS;pre-existing lint 問題不在本次範疇。
|
|
||||||
|
|
||||||
**結論:✅ 通過,不阻擋 M8-10**。2 個 Minor 與 1 個 Suggestion 可視為技術債,建議列入 M8-10 之後的測試補強清單處理,非必修。
|
|
||||||
@ -1,168 +0,0 @@
|
|||||||
# Reviewer 審查 MAJ-4 shutdown-imminent broadcast(2026-04-15)
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
- **結論**:✅ 通過。實作完整、測試涵蓋、race 乾淨、文件契約對齊、兩條 flow(quit / restart)邏輯正確。
|
|
||||||
- **阻擋 M8-10?** 否。M8-4 遺留的 MAJ-4「shutdown-imminent 廣播」已補齊,可與 M8-5 patch、M8-7 / M8-8 / M8-9 一併收斂進 M8-10。
|
|
||||||
- 發現 1 個 Minor(payload reason 與 TDD §2.3 範例文字不完全一致),1 個 Info(Hub sentinel 行為變更),均不阻擋。
|
|
||||||
|
|
||||||
## A. ShutdownNotify handler
|
|
||||||
| 檢查 | 結果 | 依據 |
|
|
||||||
|------|------|------|
|
|
||||||
| `POST /api/system/shutdown-notify` 路徑 | ✅ | `router.go:64` |
|
|
||||||
| 接 query `reason` | ✅ | `system_handler.go:180` |
|
|
||||||
| 預設 reason(空) | ✅ 歸類 `unknown` | `system_handler.go:181-186`(空值走 `default` 分支)|
|
|
||||||
| 驗證 reason | ✅ | 只認 `quit` / `restart`,其餘→ `unknown` 仍 200 |
|
|
||||||
| 呼叫 BroadcastToRoom | ✅ | `system_handler.go:194` |
|
|
||||||
| sleep 100ms 後回 200 | ✅ | `system_handler.go:26, 196-198, 201` |
|
|
||||||
| 無 client 仍 200 | ✅ | Hub `BroadcastToRoom` 空 room no-op(`hub.go:129`),handler 不判斷 client 數 |
|
|
||||||
| wsHub nil 不 panic | ✅ | `system_handler.go:188` nil guard + `TestShutdownNotify_NoHub` 覆蓋 |
|
|
||||||
|
|
||||||
**注意**:預設(空 reason)不是直接對應到 `"quit"` 而是 `"unknown"`,與 prompt「預設 reason=quit」的文字描述不一致。但 **caller(Wails app)永遠明確帶 `quit` 或 `restart`**(`app.go:304`、`server_control.go:299`),預設值只在誤呼叫路徑生效,且 `"unknown"` 對前端而言透過 `mapReason` 仍會走 `'quit'` 分支立即顯示 overlay(`use-shutdown-watcher.ts:70-71` 會 fallback → overlay 仍顯示)。**功能等價且更安全**(避免亂送的 reason 誤映射到 quit)。可接受。
|
|
||||||
|
|
||||||
## B. shutdownNotifyBroadcaster interface
|
|
||||||
- `system_handler.go:18-20` 定義介面;`wsHub` 欄位以介面型別保存(`:36`, `:49-52`)。
|
|
||||||
- 單元測試用 `spyBroadcaster` 注入(`system_handler_test.go:29-57, 68-79`),完全脫離 real Hub goroutine。
|
|
||||||
- ✅ 解耦乾淨、便於測試。
|
|
||||||
|
|
||||||
## C. Hub.BroadcastToRoom
|
|
||||||
| 檢查 | 結果 | 依據 |
|
|
||||||
|------|------|------|
|
|
||||||
| 重用既有 API | ✅ | `hub.go:160-166`(既有方法,未新造)|
|
|
||||||
| Non-blocking | ✅ | `hub.go:131-136` select default → 滿 channel 直接 drop + `close` + `delete` |
|
|
||||||
| Room `system` 正確 | ✅ | `system_handler.go:194`、`system_ws.go:36` |
|
|
||||||
| 訊息格式 | ✅ | `system_handler.go:189-193`(`type`, `reason`, `ts` = `UnixMilli()`)|
|
|
||||||
|
|
||||||
`TestHub_BroadcastToRoom_FullChannelDoesNotBlock` 驗證慢 client 不卡 hub goroutine,第二次 broadcast 仍能送到 healthy client(`hub_broadcast_test.go:73-132`)。✅
|
|
||||||
|
|
||||||
## D. /ws/system WebSocket endpoint
|
|
||||||
| 檢查 | 結果 | 依據 |
|
|
||||||
|------|------|------|
|
|
||||||
| router 註冊 | ✅ | `router.go:102` |
|
|
||||||
| client 加入 `system` room | ✅ | `system_ws.go:36-37`(`RegisterSync` 保證返回時已 in-room)|
|
|
||||||
| 沿用 M8-8 Origin check | ✅ | 共用 `upgrader`,`device_events_ws.go:13-14` 的 `CheckOrigin: CheckOrigin` 自動繼承 |
|
|
||||||
| M8-4b sentinel 行為 | ⚠️ Info | 見下方說明 |
|
|
||||||
|
|
||||||
**M8-4b sentinel 互動(Info,非問題)**:Hub 的 `writeStartupSentinel` 由 `sync.Once` 保護,只要**任何** room 的第一個 client 連上就寫 `.first-ws-connected`(`hub.go:100-115`)。`/ws/system` 是 `BootIdWatcherMount` 在瀏覽器 tab 載入時自動連上(`use-shutdown-watcher.ts:298, connectWs`),實務上極可能**成為第一個**連上的 WS endpoint,讓 startup pipeline 階段 6 由 `/ws/system` 觸發完成。這**不違反** `startup-pipeline.md §3 階段 6` 的語意(定義是「Web UI 連上任何 WS」),且 sentinel 只在第一次寫入、後續 no-op,不會造成 race。✅
|
|
||||||
|
|
||||||
## E. notifyShutdownImminent helper
|
|
||||||
| 檢查 | 結果 | 依據 |
|
|
||||||
|------|------|------|
|
|
||||||
| 1 秒 timeout + 變數化 | ✅ | `shutdown_notify.go:26`(`notifyShutdownImminentTimeout`)|
|
|
||||||
| Best-effort(錯誤靜默)| ✅ | `:49, :53` 兩個 err 分支直接 return;不 log |
|
|
||||||
| `port <= 0` no-op | ✅ | `:38-40` |
|
|
||||||
| ctx nil 保護 | ✅ | `:41-43`(fallback 到 `context.Background`)|
|
|
||||||
| ctx timeout 包裹 | ✅ | `:44-45`(`context.WithTimeout`)|
|
|
||||||
| 釋放 resp.Body | ✅ | `:57`(`resp.Body.Close`)|
|
|
||||||
|
|
||||||
測試 6 個全部覆蓋(zero port / POST 正確 / reason=restart / timeout 不卡 / 5xx 不 panic / connection refused 靜默)。`TestNotifyShutdownImminent_TimeoutDoesNotBlock` 實測 elapsed < 400ms(`shutdown_notify_test.go:95-123`)。✅
|
|
||||||
|
|
||||||
## F. Wails app 端呼叫位置
|
|
||||||
| 檢查 | 結果 | 依據 |
|
|
||||||
|------|------|------|
|
|
||||||
| `shutdown()` 在 `ctrl.Stop()` 前呼叫 | ✅ | `app.go:302-309`(順序:notify → Stop)|
|
|
||||||
| `Restart()` 在 `Stop` 前呼叫 | ✅ | `server_control.go:291-305`(順序:notify → Stop → StartWithPort)|
|
|
||||||
| reason 正確 | ✅ | `app.go:304 = "quit"`;`server_control.go:299 = "restart"` |
|
|
||||||
| port 來源 | ✅ | shutdown 走 `snapshotStatus().Port`(`app.go:303`);Restart 走 `c.proc.port` 鎖內複製成 `oldPort`(`server_control.go:284-289`)|
|
|
||||||
|
|
||||||
兩處都有 nil guard(`a.ctrl != nil` / `c.app != nil && oldPort > 0`),失敗路徑不阻塞主流程。✅
|
|
||||||
|
|
||||||
## G. 和 M8-7 前端對齊
|
|
||||||
讀 `use-shutdown-watcher.ts`(325 行):
|
|
||||||
|
|
||||||
| 檢查 | 結果 | 依據 |
|
|
||||||
|------|------|------|
|
|
||||||
| 訂閱 `/ws/system` | ✅ | `:211, 215`(`getWsBaseUrl() + '/ws/system'`)|
|
|
||||||
| 解析 `server:shutdown-imminent` | ✅ | `:228` |
|
|
||||||
| `quit` 立即 `forceOffline` | ✅ | `:244-245`(`mapReason('quit') → 'quit' → forceOffline('quit')`)|
|
|
||||||
| `restart` 延遲 10 秒 | ✅ | `:232-242`(`RESTART_DEFER_MS = 10_000`)|
|
|
||||||
| `restart` 期間 polling 會先拿新 boot-id 觸發 reload | ✅ | `:186-201` polling 仍跑;restart defer 10s 內新 server 起來 → `pollOnce → handleBootIdCheck → mismatch → reload`(`:86-123`)|
|
|
||||||
|
|
||||||
`mapReason` 把 server 的 `quit` / `app-closing` / `manual-stop` 都 fold 到 `'quit'`(`:61-73`),所以後端改送 `"quit"` 而非 TDD 範例的 `"app-closing"`**對前端完全透明**(雙方都走立即 forceOffline 路徑)。✅
|
|
||||||
|
|
||||||
**與 TDD 範例的微差異**(Minor):`server-lifecycle.md:141` 範例寫 `payload: { reason: "app-closing" }`,實作是 `reason: "quit"`。兩者在前端都觸發立即 overlay,但字面不一致。建議在 server-lifecycle.md §2.3 / §8 補註「實際 reason 由 caller 決定:Wails shutdown=`quit`、Restart=`restart`」即可,不需改 code。
|
|
||||||
|
|
||||||
## H. Integration test
|
|
||||||
- `system_ws_integration_test.go:22-83` 用 `httptest.NewServer` + gin router 掛 `SystemEventsHandler`,真實 gorilla `websocket.DefaultDialer.Dial` 連上 → `hub.BroadcastToRoom("system", ...)` → `conn.ReadMessage()` 解析 JSON → 驗證 `type`、`reason`。
|
|
||||||
- `:49-58` 等 Register 同步完成(poll `hub.rooms["system"]` 非空,best-effort 500ms)。同 package 可存取 `hub.mu` 私有欄位,無需匯出。
|
|
||||||
- `httptest.Server` 有 `defer srv.Close()`、`conn.Close()`、`SetReadDeadline` 2s 保護,不會 leak。✅
|
|
||||||
|
|
||||||
## I. Test 品質(15 個)
|
|
||||||
- **server/internal/api/handlers**(5):Quit / Restart / Invalid(4 sub-case) / NoHub / DefaultSleepPositive — 全部親跑通過。
|
|
||||||
- **server/internal/api/ws**(4):MultipleClients / EmptyRoom / FullChannelDoesNotBlock / SystemEventsHandler_ReceivesBroadcast — 全過。
|
|
||||||
- **visiona-local**(6):ZeroPort / SendsPostWithReason / SendsReasonRestart / Timeout / ServerError / ConnectionRefused — 全過。
|
|
||||||
- 合計 **15 個新 test 全部 PASS**(含 sub-test)。
|
|
||||||
- 使用 `withNoSleep` helper 把 `shutdownNotifySleepDuration` 歸零加速,`t.Cleanup` 還原 — 寫法乾淨。
|
|
||||||
- `TestShutdownNotify_DefaultSleepIsPositive` 特別保護生產常數不被誤改為 0 — 有心。
|
|
||||||
|
|
||||||
## J. 親跑驗證
|
|
||||||
```
|
|
||||||
cd server
|
|
||||||
go build ./... OK
|
|
||||||
go vet ./... OK
|
|
||||||
go test ./... OK(含 api/handlers/ws 全綠)
|
|
||||||
go test -race -count=1 ./internal/api/...
|
|
||||||
api 2.057s / handlers 1.548s / ws 2.726s 全綠
|
|
||||||
|
|
||||||
cd visiona-local
|
|
||||||
go build . OK
|
|
||||||
go vet ./... OK
|
|
||||||
go test -run NotifyShutdown -v ./...
|
|
||||||
6 tests PASS (含 timeout 0.50s)
|
|
||||||
go test -race -count=1 ./... OK 8.930s(含 race detector)
|
|
||||||
```
|
|
||||||
全部通過,無 race warning、無 build error。
|
|
||||||
|
|
||||||
## K. 完整 flow 驗證(讀 code 推論)
|
|
||||||
|
|
||||||
**Quit flow**(使用者關 Wails 視窗):
|
|
||||||
1. `OnBeforeClose` → `app.shutdown(ctx)`(`app.go:280`)
|
|
||||||
2. `a.pipelineCancelFn()` / `a.watchCancel()` / IPC close / sentinel 清理(`:282-294`)
|
|
||||||
3. `port := a.snapshotStatus().Port` → `notifyShutdownImminent(ctx, port, "quit")`(`:303-304`)
|
|
||||||
4. server `ShutdownNotify` handler:`BroadcastToRoom("system", {type, reason: "quit", ts})` → `time.Sleep(100ms)` → `200 OK`
|
|
||||||
5. `/ws/system` write pump 把 JSON 推到 TCP;瀏覽器 `onmessage` → `mapReason('quit')='quit'` → `forceOffline('quit')` 立即顯示 overlay
|
|
||||||
6. `a.ctrl.Stop()`(`:309`)→ 7s grace → server 退出
|
|
||||||
7. 瀏覽器 polling 後續都 ECONNREFUSED,但 overlay 已顯示,不重複觸發
|
|
||||||
|
|
||||||
✅ 正確。
|
|
||||||
|
|
||||||
**Restart flow**(使用者按 Restart):
|
|
||||||
1. `ServerController.Restart()`(`server_control.go:282`)
|
|
||||||
2. 鎖內複製 `oldPort := c.proc.port`(`:284-289`)
|
|
||||||
3. `notifyShutdownImminent(ctx, oldPort, "restart")`(`:299`)
|
|
||||||
4. server 廣播 `{reason: "restart"}` → 100ms sleep → 200
|
|
||||||
5. 瀏覽器 `onmessage` → `mapReason('restart')='restart'` → `restartDeferTimer = setTimeout(forceOffline('restart'), 10_000)`(`use-shutdown-watcher.ts:232-241`)
|
|
||||||
6. `c.Stop()` → server 退出;`c.StartWithPort(oldPort)` → 新 server 起(新 boot-id)
|
|
||||||
7. 瀏覽器 polling(normal mode,10s interval)先打到新 server:`handleBootIdCheck` → `'mismatch'` → `window.location.reload()`(`:95-123`)
|
|
||||||
8. Reload 後新 page 初始化 → `pollOnce` → `'first'` → `markOnline` → normal 模式
|
|
||||||
9. 若 reload 發生在 10s defer 之前 → defer timer 被 unmount 時 `clearTimeout`(`:312-315`)→ overlay 沒機會顯示 ✅
|
|
||||||
10. 若 reload 發生在 10s 之後 → overlay 顯示後 reload 本身也會清除 → 正確復原
|
|
||||||
|
|
||||||
✅ 正確,兩條 flow 的時序都能正常收斂。
|
|
||||||
|
|
||||||
## L. 問題清單
|
|
||||||
|
|
||||||
### Major
|
|
||||||
(無)
|
|
||||||
|
|
||||||
### Minor
|
|
||||||
| # | 檔案:行 | 問題 | 建議 |
|
|
||||||
|---|---------|------|------|
|
|
||||||
| MIN-1 | server_handler.go:189-193 vs `server-lifecycle.md:141` | TDD 範例寫 `reason: "app-closing"`、實作送 `reason: "quit"`。前端 `mapReason` 雙向皆 fold 成 `'quit'`,功能等價,但文件字面不一致。 | 在 `server-lifecycle.md §2.3` 補一行:「實作中 reason 由 Wails caller 決定:shutdown `quit`、Restart `restart`」。不必改 code。 |
|
|
||||||
| MIN-2 | system_handler.go:181-186 | 空 reason 被 fold 成 `"unknown"`,prompt 審查項說「預設 reason=quit」。目前由 caller 永遠帶值,非實際風險。 | 如要嚴格對齊 prompt 可把 `default` 分支改成 `reason = "quit"`;或於 commit message 標註「預設 unknown 是刻意保守設計」。Reviewer 傾向維持現狀。 |
|
|
||||||
|
|
||||||
### Info(非問題)
|
|
||||||
- **Hub sentinel 與 /ws/system 交互**:`/ws/system` 因 mount 時機早,實務上會成為第一個寫 `.first-ws-connected` 的 endpoint。符合 `startup-pipeline.md §3 階段 6` 的語意(「Web UI 連上任何 WS」),無 race,無需調整。
|
|
||||||
- `TestSystemEventsHandler_ReceivesBroadcast` 直接存取 `hub.mu` / `hub.rooms`(私有)— 因為同 package 合法,但未來若搬到外部 integration package 會需要 exporter。目前 OK。
|
|
||||||
|
|
||||||
## M. 結論
|
|
||||||
MAJ-4 patch 品質達標:
|
|
||||||
1. 介面(`shutdownNotifyBroadcaster`)乾淨解耦,spy broadcaster 測試乾淨。
|
|
||||||
2. Hub.BroadcastToRoom 重用既有 API,未新造;non-blocking 行為有專屬 test 覆蓋。
|
|
||||||
3. `/ws/system` handler 沿用 M8-8 `upgrader` / `CheckOrigin`,無繞過安全機制。
|
|
||||||
4. notifyShutdownImminent helper best-effort 全面(port<=0 / ctx nil / timeout / connection refused / 5xx 五種路徑都有測試)。
|
|
||||||
5. Wails 兩處呼叫點(`shutdown` / `Restart`)順序正確,都在 Stop 之前、port 來源正確。
|
|
||||||
6. 與 M8-7 前端 `use-shutdown-watcher.ts` 的 reason 映射、restart 10 秒 defer、M8-9 boot-id reload guard 完整對齊。
|
|
||||||
7. 15 個新 test 全綠,`go build / vet / test / test -race` server + visiona-local 均乾淨。
|
|
||||||
8. 兩條完整 flow(Quit / Restart)讀 code 推論時序正確,overlay 不會 race、reload loop 有 guard。
|
|
||||||
|
|
||||||
**建議**:接受 patch,MAJ-4 結案。Minor 1 / 2 不阻擋、留作文檔與 M8-10 final pass 時順手修正即可。M8-4 遺留 5 個 Major 至此(MAJ-4)已補齊,可推進 M8-10。
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
# Code Review 報告 — M8-10a P0 Latent Bug 修復(built-in data dir 解析)
|
|
||||||
|
|
||||||
## 審查摘要
|
|
||||||
|
|
||||||
- **審查對象**:`server/main.go`(新增 `resolveBuiltInDataDir` helper + `main()` 拆分 `builtInDataDir` / `dataDir` 兩個變數)
|
|
||||||
- **關聯檔**:`server/internal/flash/service.go`(接收參數由 `dataDir` → `builtInDataDir`,無 body 變動)
|
|
||||||
- **產出 Agent**:Backend
|
|
||||||
- **審查結果**:⚠️ **需修 1 個 Major(Linux AppImage 布局未覆蓋)後通過**
|
|
||||||
- **問題統計**:Critical: 0 / Major: 1 / Minor: 2 / Suggestion: 2
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 獨立驗證結果
|
|
||||||
|
|
||||||
| 驗證項 | 指令 | 結果 |
|
|
||||||
|-------|------|------|
|
|
||||||
| Build | `cd server && go build -o /tmp/rv-server .` | ✅ PASS |
|
|
||||||
| Vet | `cd server && go vet ./...` | ✅ PASS |
|
|
||||||
| Test | `cd server && go test -count=1 ./...` | ✅ 全綠(api / api/handlers / api/ws / device / model 5 個有測試的 package 全部通過) |
|
|
||||||
| Smoke (bundle) | 直接跑 `.../Contents/Resources/bin/visiona-local-server -port 3801 -data-dir $(mktemp -d)` | ✅ `Built-in data dir: .../Contents/Resources/data` → `Loaded 15 built-in models` → `GET /api/models` 回 15 個 model,`kl520-yolov5-detection` 的 `filePath` = `data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef`,bundle 實體 .nef 檔存在(7,506,224 bytes,與 catalog `modelSize: 7200000` 為人工估值合理吻合) |
|
|
||||||
| Smoke (dev mode) | 把 binary 放到 `server/` 下跑 `./rv-server-tmp -port 3802 -data-dir $(mktemp -d)` | ✅ `Built-in data dir: /Users/jimchen/visionA/local-tool/server/data` → `Loaded 15 built-in models` → `GET /api/models` 回 15 個 model |
|
|
||||||
|
|
||||||
**修復前的 bug 無法在修復後重現**:不管 Wails 傳什麼 `--data-dir`,`builtInDataDir` 都從 bundle 內解析,`/api/models` 保證回 > 0 個 model。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 所有候選布局覆蓋狀態
|
|
||||||
|
|
||||||
| 布局 | binary 位置 | data 位置 | 命中候選 | 覆蓋狀態 |
|
|
||||||
|------|-----------|-----------|---------|---------|
|
|
||||||
| **macOS bundle** | `Contents/Resources/bin/visiona-local-server` | `Contents/Resources/data/` | `<base>/../data` | ✅ 已驗證 |
|
|
||||||
| **Dev mode(go run / 直接在 server/ 下)** | `server/visiona-local-server`(或 cwd) | `server/data/` | `<base>/data` | ✅ 已驗證 |
|
|
||||||
| **Windows installer**(Inno Setup,`installer/windows/visiona-local.iss` L72/L86)| `{app}\bin\visiona-local-server.exe` | `{app}\data\` | `<base>/../data` | ✅ 理論命中(未在 Windows 機器實跑,但 Inno 布局 = `base = {app}\bin`,`<base>/../data = {app}\data`,路徑對齊) |
|
|
||||||
| **Linux AppImage**(`installer/linux/build-appimage.sh` L29-33, L80)| `$APPDIR/usr/bin/visiona-local-server` | `$APPDIR/usr/lib/visiona-local/data/` | **無**(三個候選算出來分別是 `/usr/bin/data`、`/usr/data`、`/usr/Resources/data`,沒一個對) | ❌ **不覆蓋 — 見 Major-1** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 文件符合性檢查
|
|
||||||
|
|
||||||
這次是 P0 bug fix(既有行為修復),依 Autoflow S 級規則不需新增 PRD/TDD 條目。修復與現有 TDD §架構決策(bundle 內唯讀 vs user home 可寫分離)一致——事實上修復才讓程式碼行為與 TDD 的決策真正對齊(修復前是文件對但程式碼錯)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 問題清單
|
|
||||||
|
|
||||||
### Critical(必須修復,阻擋交付)
|
|
||||||
|
|
||||||
無。
|
|
||||||
|
|
||||||
### Major(必須修復,但不阻擋 macOS 初步交付)
|
|
||||||
|
|
||||||
| # | 檔案 | 行數 | 問題描述 | 建議修改方式 |
|
|
||||||
|---|------|------|----------|-------------|
|
|
||||||
| M-1 | `server/main.go` | 95-112 | `resolveBuiltInDataDir` 的三個候選沒有覆蓋 Linux AppImage 布局。AppImage 把 server binary 放 `usr/bin/`、data 放 `usr/lib/visiona-local/data/`(見 `installer/linux/build-appimage.sh` L29-33, L80),三個現有候選 `<base>/data`、`<base>/../data`、`<base>/../Resources/data` 都不會命中。結果:Linux 版 AppImage 啟動後 built-in catalog 依然載入 0 個 model,等於這個 P0 bug 在 Linux 上**沒被修掉**。另外 `AppRun` 已經 export `VISIONA_BUNDLE_LIB_DIR=${HERE}/usr/lib/visiona-local`(L134),server 目前完全沒讀這個 env var。**備註**:同樣的缺失其實也存在於先前就有的 `resolveBridgeScript`(L60-78),不是本次 PR 引入的 regression,但應同時修掉。 | 建議兩層保險同時做:①在 `resolveBuiltInDataDir` 開頭優先讀 `VISIONA_BUNDLE_LIB_DIR` env(若非空且 `<env>/data/models.json` 存在則直接回 `<env>/data`);②新增第 4 個候選 `<base>/../lib/visiona-local/data`(對齊 FHS 布局,未來其他 Linux 打包方式也能用)。`resolveBridgeScript` 同步比照修掉,避免 Linux 版 Kneron bridge 也找不到。 |
|
|
||||||
|
|
||||||
### Minor(建議修復)
|
|
||||||
|
|
||||||
| # | 檔案 | 行數 | 問題描述 | 建議修改方式 |
|
|
||||||
|---|------|------|----------|-------------|
|
|
||||||
| m-1 | `server/main.go` | 110-111 | 三個候選全部沒命中時回的 fallback 是 `filepath.Join(base, "data")`,不是 `filepath.Abs(...)` 後的絕對路徑,與成功路徑回的是絕對路徑不一致(成功分支有 `filepath.Abs(c)`)。雖然 `model.NewRepository` 還是能用相對路徑讀檔,但 log 裡印出來的 `Built-in data dir: ...` 會是相對路徑,讓除錯變難。 | fallback 也過一次 `filepath.Abs`:`if abs, err := filepath.Abs(filepath.Join(base, "data")); err == nil { return abs }; return filepath.Join(base, "data")`。 |
|
|
||||||
| m-2 | `server/main.go` | 95-112 | 函式沒 log 自己試了哪幾條路徑。落到 fallback 時 `NewRepository` 只會印 `Warning: could not load models from <path>`,使用者看不到「我還試了另外兩個位置都沒中」。bug reporting 體驗不佳。 | 在 fallback 分支 `return` 前用 `log.Printf`(或注入 logger)印出嘗試過的三個候選路徑,或在函式簽名改成接收 logger。 |
|
|
||||||
|
|
||||||
### Suggestion(非必要,改善建議)
|
|
||||||
|
|
||||||
| # | 檔案 | 行數 | 建議內容 |
|
|
||||||
|---|------|------|----------|
|
|
||||||
| s-1 | `server/main.go` | 95-112 | `resolveBuiltInDataDir` 和 `resolveBridgeScript`(L60-78)兩個函式結構幾乎一模一樣(都是「一組候選 → 找存在的 → fallback 回第一個」)。可以抽一個通用 `findFirstExisting(candidates []string, sentinel string) (string, bool)`,讓兩者各自只定義候選清單和 sentinel 檔案(`models.json` vs `kneron_bridge.py`),減少未來「改了一個忘了改另一個」的風險。 |
|
|
||||||
| s-2 | `server/main.go` | 146-149 | `dataDir` 在 `cfg.DataDir == ""` 時 fallback 到 `builtInDataDir`(= bundle 內 read-only 目錄),但這只服務 dev mode。實際執行時 sentinel (`.first-ws-connected`)、custom-models、logs 都可能想寫進去,而 bundle 內目錄在 prod 是 read-only。雖然 dev mode 下通常有寫權限,但把「writable user dir fallback 到 read-only bundle dir」這件事放進註解說清楚比較好,避免未來有人以為這是正常的 prod 行為。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 邊界情況檢查
|
|
||||||
|
|
||||||
| 情境 | 行為 | 評估 |
|
|
||||||
|------|------|------|
|
|
||||||
| `os.Executable()` 失敗 | `baseDir` 回 `"."`,`resolveBuiltInDataDir` 試 `./data`、`../data`、`../Resources/data`,其中任一有 `models.json` 就命中 | ✅ 合理,接力自然 |
|
|
||||||
| 三個候選都沒命中 | 回 `<base>/data`,`model.NewRepository` 印 warning,`r.models = []`,server 繼續跑 | ✅ 容錯策略合理(見 Minor m-2:若能多印「我試過的路徑」更好) |
|
|
||||||
| 目錄對但 `models.json` 損毀(JSON 解析錯)| `os.Stat` 判定命中 → `NewRepository` `json.Unmarshal` 印 warning → 0 個 model | ✅ 合理,不會誤判成「找別的目錄」 |
|
|
||||||
| 目錄對但 `models.json` 是 0 bytes | `os.Stat` 判定命中(non-dir)→ `NewRepository` `json.Unmarshal` 失敗印 warning → 0 個 model | ✅ 同上 |
|
|
||||||
| `models.json` 是個目錄(奇怪但可能發生) | `info.IsDir()` 為 true,跳過該候選繼續試下一個 | ✅ 已處理 |
|
|
||||||
| `cfg.DataDir` 指定為不存在路徑 | **只影響 writable 部分**:custom-models 讀取會 warning 但 empty、sentinel 寫入失敗但 WS Hub 會跳過、logs 可能寫不出。built-in catalog 不受影響(依然從 `builtInDataDir` 讀) | ✅ 修復後行為比修復前好 —— 修復前一個無效 `--data-dir` 會同時把 built-in catalog 弄掛 |
|
|
||||||
| `cfg.DataDir` 指定為路徑但 parent 不存在 | 同上 | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 其他 dataDir 誤用檢查(Grep server/ 全專案)
|
|
||||||
|
|
||||||
我 grep 了 `dataDir` / `DataDir` / `models.json` / `nef/kl` / `data/nef` 的所有出現位置,確認:
|
|
||||||
|
|
||||||
- `server/internal/api/ws/hub.go`:`sentinelDataDir`(用於 `.first-ws-connected` sentinel)→ ✅ 語意正確,寫入 writable user dataDir
|
|
||||||
- `server/internal/config/config.go`:`DataDir` CLI flag → ✅ 沒改
|
|
||||||
- `server/internal/flash/service.go`:`s.dataDir` 解析 `data/nef/...` 相對路徑 → ✅ 本次修復已把 `flash.NewService` 改吃 `builtInDataDir`,語意對齊
|
|
||||||
- `main.go` 其他位置(sentinel / custom-models / WS Hub)→ ✅ 都正確用 writable `dataDir`,沒有誤用
|
|
||||||
|
|
||||||
**沒找到其他地方把「應屬 bundle 內 read-only 的檔案」錯用 writable `dataDir` 解析。** 本次修復清單完備。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 優點
|
|
||||||
|
|
||||||
1. **做法對齊既有範式**:`resolveBuiltInDataDir` 的候選路徑邏輯(1: dev flat / 2: installer / 3: macOS bundle)和既有 `resolveBridgeScript` 一致,未來維護者不需學新模式,讀一個就懂兩個。
|
|
||||||
2. **語意清晰拆分**:`builtInDataDir`(read-only, bundle 內)和 `dataDir`(writable, user home)兩個變數名 + 兩段 comment block 把意圖講得很清楚,修復前後讀 code 一看就懂「為什麼要兩個」。`model.NewRepository` 和 `flash.NewService` 傳的是 `builtInDataDir`、`custom-models` / `WS sentinel` 傳的是 `dataDir`,分工直觀。
|
|
||||||
3. **Dev mode 不 regression**:`cfg.DataDir == ""` 時 fallback 回 `builtInDataDir`,讓 `go run ./server` 繼續可跑 —— 重要,因為它同時讓 reviewer 這次能在 `server/` 目錄下獨立驗證 dev mode。
|
|
||||||
4. **命中條件嚴格**:用「`models.json` 存在且非目錄」當 sentinel(而不是 `data/` 目錄存在就行),避免選到 wails build artifact 留下的空 `data/` 目錄(這正是修復前誤入的那個坑)。
|
|
||||||
5. **容錯好**:三候選都沒命中時 fallback 而非 panic,`NewRepository` 也只印 warning 不 crash,符合「不 regression、壞的優雅」。
|
|
||||||
6. **增量最小**:唯一改動檔就是 `server/main.go`(+40 −6),沒動 `flash/service.go` 的 body,風險面小。
|
|
||||||
7. **Build/vet/test 全綠,獨立 smoke(bundle + dev mode 兩種路徑)也全綠**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 總結意見(≤400 字)
|
|
||||||
|
|
||||||
M8-10a 這個 P0 latent bug 的修復方向正確、做法乾淨,唯一改動檔 `server/main.go` 把 `builtInDataDir`(read-only bundle 內)與 `dataDir`(writable user home)拆成兩個語意明確的變數,並新增 `resolveBuiltInDataDir` helper 以 `models.json` 存在為命中條件自動跨 dev / installer / macOS bundle 三種布局。Flash service 同步改吃 `builtInDataDir`,避免相對路徑被誤解到 user home。Build / vet / test / smoke(bundle + dev mode)四項獨立驗證全綠,`/api/models` 穩定回 15 個 model,實體 .nef 檔路徑可對到 bundle 內。
|
|
||||||
|
|
||||||
**但 Linux AppImage 布局(`usr/bin/visiona-local-server` + `usr/lib/visiona-local/data/`)三個候選都不命中(Major-1)**,等於這個 P0 bug 在 Linux 上繼續潛藏——雖然同樣的缺失 `resolveBridgeScript` 先前就存在、並非本次 PR 引入。修復建議:①讀 `VISIONA_BUNDLE_LIB_DIR` env(`AppRun` 已 export)②加第 4 候選 `<base>/../lib/visiona-local/data`。兩個 Minor(fallback 沒 abs 化、沒 log 試過的路徑)和兩個 Suggestion(抽公用 helper、dev mode writable fallback 註解)屬改善性質,不阻擋交付。
|
|
||||||
|
|
||||||
**結論:⚠️ 需修 1 個 Major(Linux AppImage 覆蓋)後可交付;如果本次交付明確只限 macOS + Windows 兩平台,Major-1 可降級為「Linux 平台列為 known issue 追蹤到 M9」並直接放行。建議優先採前者,連 `resolveBridgeScript` 一起修掉,避免兩個函式分開欠債。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第二輪 Review(2026-04-15)
|
|
||||||
|
|
||||||
### 審查對象
|
|
||||||
|
|
||||||
第一輪所有 Major / Minor / Suggestion 的修復 —— 唯一改動檔 `server/main.go`(+119 / −24 行)。
|
|
||||||
|
|
||||||
### 第一輪問題處理狀態
|
|
||||||
|
|
||||||
| # | 問題 | 狀態 | 驗證 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| **Major-1** | Linux AppImage 布局未覆蓋(三候選全不命中) | ✅ **解決** | 候選清單補兩層:①env `VISIONA_BUNDLE_LIB_DIR/data`(AppRun 已 export)② FHS `<base>/../lib/visiona-local/data`。情境 2(模擬 AppImage 布局 + env var)→ log `Built-in data dir: .../usr/lib/visiona-local/data`、`Loaded 1`、`GET /api/models` total=1 ✅;情境 3(同布局但不設 env,靠 FHS 候選 5 命中)→ 同樣成功 ✅。`resolveBridgeScript` 同步修掉,連帶把第一輪備註的技術債清掉。 |
|
|
||||||
| **Minor-1** | fallback 沒 `filepath.Abs` 化 | ✅ **解決** | L145-149、L103-107:兩個函式 fallback 都過 `filepath.Abs`,絕對路徑能 err 時才退成 Join 的相對路徑(絕不 crash)。全不命中情境 log 印出 `Built-in data dir: /var/.../data`(絕對路徑)✅。 |
|
|
||||||
| **Minor-2** | 沒 log 試過的候選路徑 | ✅ **解決** | L142、L100:`log.Printf("warn: ... not found. Tried: %v", tried)`;`findFirstExisting` 每個候選都先 `filepath.Abs` 再丟進 `tried`,log 出來是絕對路徑清單,debug 訊息清楚。全不命中情境實測 log 輸出完整 4/5 個候選絕對路徑 ✅。 |
|
|
||||||
| **Suggestion s-1** | 抽 `findFirstExisting` helper | ✅ **解決** | L53-73 實作 `findFirstExisting(candidates, sentinel) (string, []string)`,`resolveBuiltInDataDir`(L139)和 `resolveBridgeScript`(L97)都改用。未來改動兩個函式的共通邏輯只需改一處。 |
|
|
||||||
| **Suggestion s-2** | dev mode writable fallback 加註解 | ✅ **解決** | L180-190:5 行 comment block 明確寫出「dev mode-only fallback、production 下 Wails 永遠會傳 `--data-dir`、若 packaged 模式沒傳則 writable 操作 log warning 不 crash」。意圖表達清楚。 |
|
|
||||||
|
|
||||||
### 獨立複驗結果
|
|
||||||
|
|
||||||
| 驗證項 | 指令 | 結果 |
|
|
||||||
|-------|------|------|
|
|
||||||
| Build | `cd server && go build -o /tmp/rv2-server .` | ✅ PASS |
|
|
||||||
| Vet | `cd server && go vet ./...` | ✅ PASS |
|
|
||||||
| Test | `cd server && go test -count=1 ./...` | ✅ 全綠(api / handlers / ws / device / model 5 個 test package 全通過)|
|
|
||||||
| 情境 2:AppImage + env var | 模擬 `usr/bin/<server>` + `usr/lib/visiona-local/data/models.json`,`VISIONA_BUNDLE_LIB_DIR=<appdir>/usr/lib/visiona-local` | ✅ `Built-in data dir: .../usr/lib/visiona-local/data` → `Loaded 1` → `GET /api/models` total=1 |
|
|
||||||
| 情境 3:AppImage FHS fallback | 同上布局但**不**設 env var | ✅ 候選 5 `<base>/../lib/visiona-local/data` 命中,同樣 `Loaded 1` → total=1 |
|
|
||||||
| 情境:全不命中 | 把 server binary 放到 `/tmp/xxx/` 獨立目錄,無 env | ✅ 兩個函式 log 出完整 `Tried:` 清單(都是絕對路徑),fallback 回絕對路徑,server 繼續跑 0 models(不 crash) |
|
|
||||||
| AppImage 對齊檢查 | 讀 `installer/linux/build-appimage.sh` L29-33 / L124-138 | ✅ `APPDIR/usr/bin/visiona-local-server` + `APPDIR/usr/lib/visiona-local/{data,scripts}` + AppRun export `VISIONA_BUNDLE_LIB_DIR=${HERE}/usr/lib/visiona-local` —— 與 server 候選 ①(env 路徑)和 ⑤(FHS 路徑)雙雙對齊 |
|
|
||||||
| cwd 變動風險檢查 | grep 專案找 `os.Chdir` / `Chdir(` | ✅ 零匹配 —— `./scripts` 相對候選在執行期不會因 cwd 漂移而解析錯 |
|
|
||||||
|
|
||||||
### 候選優先順序誤命中檢查
|
|
||||||
|
|
||||||
逐平台驗證新候選 `<base>/../lib/visiona-local/data` 不會在非 Linux 場景誤命中:
|
|
||||||
|
|
||||||
| 平台 | `<base>` | 候選 5 解析 | 該路徑實際是否存在 | 風險 |
|
|
||||||
|------|---------|-----------|----|------|
|
|
||||||
| macOS bundle | `Contents/Resources/bin` | `Contents/Resources/lib/visiona-local/data` | ❌(macOS bundle 不用 FHS) | 無 |
|
|
||||||
| Windows installer | `{app}\bin` | `{app}\lib\visiona-local\data` | ❌(Inno Setup 放 `{app}\data`) | 無 |
|
|
||||||
| Dev mode | `server/` 或 `.` | `../lib/visiona-local/data` | ❌ | 無 |
|
|
||||||
| Linux AppImage | `usr/bin` | `usr/lib/visiona-local/data` | ✅ | 正是目標 |
|
|
||||||
|
|
||||||
候選順序把 `<base>/data`、`<base>/../data`、`<base>/../Resources/data` 排在 FHS 之前,所有既有布局都會在進 FHS 前命中。**沒有誤命中風險。**
|
|
||||||
|
|
||||||
### 新觀察的邊界情況
|
|
||||||
|
|
||||||
| # | 觀察 | 評估 | 建議 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| E-1 | `findFirstExisting` 簽名 `(string, []string)`,呼叫端用 `dir != ""` 判斷 OK | Go 慣例通常是 `(value, bool)` 或 `(value, error)`;現行簽名把「hit/miss」和「嘗試清單」合併一個回傳值,讀起來略非 idiomatic,但因為 `tried` 在 success path 沒用但無害、miss path 才拿去 log,合併簽名避免多一個回傳值,權衡合理 | 不阻擋通過。若要更 idiomatic 可改 `(dir string, tried []string, ok bool)`,但目前 call site 寫法(`if dir, tried := ...; dir != ""`)已經夠清楚 |
|
|
||||||
| E-2 | `resolveBridgeScript` / `resolveBuiltInDataDir` 的 warn 走 std `log.Printf`,其他資訊走 `pkglogger.logger.Info` —— log 格式不一致 | resolve 函式在 `main.go` 被呼叫的時序上 logger 已存在(L155 `pkglogger.New()`;resolve 在 L177、L236),**其實可以注入 logger**;但本次修改保留 std log 有個好處:若將來把 resolve 函式移到更早(在 logger 初始化前),不用再改簽名。**不影響正確性。** | 不阻擋通過。建議追蹤到下次重構 logger 時一起統一(加 Minor 備忘) |
|
|
||||||
| E-3 | `VISIONA_BUNDLE_LIB_DIR` 只在 Linux AppImage AppRun 設定 —— 其他平台若使用者手動設錯,是否會誤命中? | `findFirstExisting` 對每個候選做 `os.Stat(.../sentinel)`,env 指到不存在的路徑會 Stat 失敗自然跳下一個候選;env 指到存在且含正確 sentinel 的路徑,代表使用者有意識地 override,行為合理 | 無風險。Resolve 函式的 `os.Stat` sentinel check 已是自然保險 |
|
|
||||||
| E-4 | `./scripts` 相對路徑候選(resolveBridgeScript 候選 6)在理論上會隨 cwd 漂移 | Grep 專案零匹配 `os.Chdir`,server 啟動流程不改 cwd,且相對候選放最後(env / base 相關的絕對候選都不命中才會走到),實務上只在純 dev mode cwd 對齊時觸發 | 無風險 |
|
|
||||||
|
|
||||||
### 新發現問題
|
|
||||||
|
|
||||||
**Critical:無。**
|
|
||||||
|
|
||||||
**Major:無。**
|
|
||||||
|
|
||||||
**Minor(非阻擋):**
|
|
||||||
|
|
||||||
| # | 檔案 | 行 | 問題 | 建議 |
|
|
||||||
|---|------|----|------|------|
|
|
||||||
| m2-1 | `server/main.go` | 100, 142 | 兩個 resolve 函式的 warn log 用 std `log.Printf` 而非 `pkglogger.logger.Warn`,與後續 `logger.Info("Built-in data dir: ...")` 格式不一致 | 下次重構 logger 時把 logger(或至少 pkglogger)注入 resolve 函式,統一格式。不影響本輪通過。 |
|
|
||||||
|
|
||||||
**Suggestion:**
|
|
||||||
|
|
||||||
| # | 檔案 | 行 | 建議 |
|
|
||||||
|---|------|----|------|
|
|
||||||
| s2-1 | `server/main.go` | 59-73 | `findFirstExisting` 若改 `(dir string, tried []string, ok bool)` 可讓 call site 更 idiomatic(`if dir, tried, ok := ...; ok { ... } else { log; fallback }`)。非必須。 |
|
|
||||||
|
|
||||||
### 優點(第二輪新增)
|
|
||||||
|
|
||||||
1. **修復完整度超出第一輪要求**:第一輪只要求 Major-1(Linux 資料目錄覆蓋),Orchestrator 把所有 Minor + 兩個 Suggestion 一併做掉,且把第一輪**備註**的技術債 `resolveBridgeScript` 也同步修了,避免「兩個函式結構一樣但一修一未修」的分裂狀態。
|
|
||||||
2. **候選順序保守**:FHS 候選放在既有候選之後,零誤命中風險(見上表);既有行為零 regression。
|
|
||||||
3. **`findFirstExisting` 抽象得當**:回傳 `tried` 清單給 log 用的設計雖然非完全 idiomatic,但對 debug 訊息清晰度貢獻很大(見全不命中情境 log 輸出)—— 這個權衡合理。
|
|
||||||
4. **註解品質高**:每個 resolve 函式的候選清單都有編號 + 情境說明;`dataDir` fallback 的註解把「dev-only / production Wails 永遠傳 / 壞了不 crash」三件事講清楚,未來維護者不會誤解。
|
|
||||||
5. **情境 2 / 3 雙路命中**:env var 和 FHS 兩層保險,即使未來 AppRun 的 env var 被移掉或名稱改了,FHS 還能兜底;反之亦然 —— 防禦性設計好。
|
|
||||||
6. **所有驗證全綠**:build / vet / test / 3 個布局實跑(bundle 第一輪已驗 / AppImage-env / AppImage-FHS / 全不命中 fallback)全通過。
|
|
||||||
|
|
||||||
### 總結意見(≤300 字)
|
|
||||||
|
|
||||||
Orchestrator 本輪把第一輪的 Major-1 + Minor-1/2 + Suggestion s-1/s-2 全數處理,且連帶修掉備註中 `resolveBridgeScript` 的同樣欠債,新增 `findFirstExisting` 共用 helper 讓兩個函式結構一致。候選清單補 env var(`VISIONA_BUNDLE_LIB_DIR`,AppRun 已 export)+ FHS 雙層保險,對 macOS bundle / Windows installer / Dev mode 零誤命中風險。三個布局實跑(AppImage + env var / AppImage FHS fallback / 全不命中 fallback)全綠,log 明確列出嘗試過的絕對路徑,fallback 也已 `filepath.Abs` 化。Build / vet / test 全綠。新觀察到的 2 個 Minor / Suggestion(std log vs pkglogger 格式一致性、helper 簽名 idiomatic 度)屬改善性,不阻擋交付。修復完整度超出第一輪最低要求,技術債清理徹底。
|
|
||||||
|
|
||||||
**結論:✅ 通過(可交付三平台:macOS bundle / Windows installer / Linux AppImage)。** 追蹤到下次 logger 重構時統一 std log 與 pkglogger 的格式(Minor m2-1)。
|
|
||||||
@ -1,385 +1,9 @@
|
|||||||
# 專案進度 — visionA-local
|
# 專案進度 — visionA-local
|
||||||
|
|
||||||
## 目的:全新專案(從 edge-ai-platform 衍生的 local 版本)
|
## 目的:全新專案(從 edge-ai-platform 衍生的 local 版本)
|
||||||
## 當前階段:🔴 **第一階段回溯** — L 級重大方向變更(Wails 內嵌 → Wails 控制台 + 瀏覽器 Web UI)
|
## 當前階段:第二階段 — M7 Windows 實機 build + splash regression 修復
|
||||||
## 當前狀態:✅ 使用者決策全部收齊(R5 第五輪決策),待三方產出正式 PRD v2 / Design Spec v2 / TDD v2
|
## 當前狀態:Windows 端待驗證重 build 後 UI
|
||||||
## 最後更新:2026-04-14
|
## 最後更新:2026-04-12
|
||||||
|
|
||||||
## 🔴 2026-04-14 使用者提出 L 級重大方向變更
|
|
||||||
|
|
||||||
### 使用者原話
|
|
||||||
> 推論只需包含這三種 camera/image/上傳影片(avi, mpeg, mp4, 瀏覽器能吃的格式)
|
|
||||||
> 模型除了預設的幾種只能用上傳的
|
|
||||||
> 介面希望是用網頁而不是包在應用程式中
|
|
||||||
> 我想像中的是 visionA local 安裝完 啟動後 應用程式介面會有可以顯示 local server log 的地方
|
|
||||||
> 有可以啟動/停止 重啟 local server 的介面 有打開 localhost 網頁的介面
|
|
||||||
> 網頁上會有 scan/connect device 的介面 選模型/上傳模型 推論的介面
|
|
||||||
|
|
||||||
### 變更解讀
|
|
||||||
1. **推論來源範圍縮減**:camera / image / 上傳影片,砍掉 URL 推論 + yt-dlp + YouTube/Vimeo
|
|
||||||
2. **模型管理縮減**:只保留「預設幾種 + 只能上傳」,砍掉任何 URL 下載 / Model Zoo 類功能
|
|
||||||
3. **介面架構巨變**:Wails 桌面 app 退化為「Local Server 控制台」(Log 面板 + Start/Stop/Restart + Open browser),真正的使用介面在**瀏覽器**跑(scan/connect/model/inference 全在 Web UI)
|
|
||||||
|
|
||||||
### 影響範圍(初判)
|
|
||||||
- 砍 yt-dlp 打包(M6 部分)→ 依賴瘦身 -35MB
|
|
||||||
- 砍 `ResolveWithYTDLP` / `ytdlpHosts` / `StartFromURL` yt-dlp 路徑 / 前端 URL tab
|
|
||||||
- Wails 控制台是**全新 UI**,和現有 splash + Next.js 完全不同
|
|
||||||
- 與第三輪決策 Q-A(砍 tray)、Q7(關閉視窗=結束 app)有潛在衝突,可能要復議
|
|
||||||
- M1-M7 的工作**大部分仍可沿用**(server / Next.js UI / 打包)只是 Wails 視窗內容要重寫
|
|
||||||
- 延伸的 yt-dlp 跳頁 bug 問題**自動消失**(功能直接砍)
|
|
||||||
|
|
||||||
### 第一輪三方分析狀態
|
|
||||||
- ✅ PM 分析完成:`01-requirements/pm-analysis-round2-refactor.md`(419 行)
|
|
||||||
- ✅ Design 分析完成:`03-design/design-analysis-round2-refactor.md`(537 行)
|
|
||||||
- ✅ Architect 分析完成:`04-architecture/architect-analysis-round2-refactor.md`(798 行)
|
|
||||||
|
|
||||||
### 三方共識(無分歧)
|
|
||||||
1. **技術可行**,沿用率 85-95%,估 ~10 人天
|
|
||||||
2. **砍 yt-dlp**(vendor 35MB + resolver + URL tab),dmg 220→~135-185MB
|
|
||||||
3. **ffmpeg 保留**(上傳影片仍需解碼),GPL blocker 延續,可能 M8 切 LGPL
|
|
||||||
4. **Q-A 砍 tray 必須復議** — 新方向下 tray 價值從「可有可無」變「核心」
|
|
||||||
5. **Q7 關閉=結束必須復議** — 否則關 Wails 視窗 = SIGTERM server = 瀏覽器 tab ECONNREFUSED
|
|
||||||
6. **Next.js UI 幾乎零改動**(80-90% 沿用,只砍 URL tab)
|
|
||||||
7. **Wails 控制台走 vanilla HTML/JS/CSS**(不新 Next.js mini app)
|
|
||||||
8. **CORS 要限制為 127.0.0.1/localhost**(瀏覽器模式新攻擊面)
|
|
||||||
9. **綁定維持 127.0.0.1**,不做 LAN mode
|
|
||||||
10. **watchServer 改為 Error state**,不 os.Exit
|
|
||||||
|
|
||||||
### 三方立場差異(待使用者裁決)
|
|
||||||
- **C1 動機問題**(PM 堅持前置條件):必須先知道使用者為什麼要改架構,PM 列出 9 種可能動機
|
|
||||||
- **首次啟動是否自動開瀏覽器**:Design 建議預設自動(Ollama 式)、PM 建議手動(C6 選 A)— 輕微分歧
|
|
||||||
- **First-Run 搬家策略**:PM 建議留瀏覽器端(C8 選 A)、Design 沒強烈意見
|
|
||||||
|
|
||||||
### 下一步
|
|
||||||
- ✅ 使用者決策 R5 全部收齊(見下方「R5 第五輪使用者決策」)
|
|
||||||
- ⏳ 三方依決策產出正式 PRD v2 / Design Spec v2 / TDD v2(下一步)
|
|
||||||
- ⏳ 三方互審 → 使用者確認 → 進開發
|
|
||||||
|
|
||||||
### R5 第五輪使用者決策(2026-04-14,重構方向變更)
|
|
||||||
|
|
||||||
| # | 題目 | 使用者決定 | 備註 |
|
|
||||||
|---|------|----------|------|
|
|
||||||
| R5-1 | 重構動機 | **A + B + G**(多視窗便利 + 瀏覽器 devtools + 需求方就是這麼要求) | 純 127.0.0.1,無 LAN / 無背景 daemon 需求 |
|
|
||||||
| R5-2 | Wails 視窗關閉行為(Q7 復議)| **維持關閉=結束 server**,瀏覽器網頁顯示「local server 已離線」覆蓋層 | 不改原 Q7 決策,但前端要新增「server 離線」UI |
|
|
||||||
| R5-3 | Tray 復議(Q-A 復議)| **T1:維持砍 tray** | 和 R5-2 一致,省 1.5 人天 |
|
|
||||||
| R5-4 | 首次啟動自動開瀏覽器 | **A:首次自動開,之後可設定** | Ollama 式零摩擦 |
|
|
||||||
| R5-5 | Wails 控制台 scope | 同意 PM 清單,**拿掉 Mock 模式切換** | |
|
|
||||||
| R5-5a | Mock 模式歸處 | **A:完全砍掉 Mock 模式** | 使用者明確:「沒插硬體就讓它是空的,不用 demo」 |
|
|
||||||
| R5-6 | ffmpeg 授權 | **LGPL 方案 B(混合)** | Windows/Linux 用 BtbN 現成 LGPL binary,macOS 自 build |
|
|
||||||
| R5-6a | macOS build 規模 | **A:最小 decoder-only build(~20MB)** | 只含 mp4/avi/mov/mpeg/mpg 五種解碼器 |
|
|
||||||
| R5-6b | macOS binary 存放 | **① commit 到 repo(`vendor/ffmpeg/macos/`)** | LGPL ffmpeg 幾乎不需更新,直接進 git |
|
|
||||||
| R5-6c | 是否打包 ffprobe | **一起包** | BtbN 本來就都有,0 成本 |
|
|
||||||
| R5-7 | M7 Windows build | **先不管,做完再驗** | 跳過 M7-B3 baseline 驗證 |
|
|
||||||
|
|
||||||
### 三方共識全部採納(無須使用者裁決)
|
|
||||||
1. 技術可行,沿用率 85-95%,~10 人天
|
|
||||||
2. 砍 yt-dlp 全套(vendor 35MB + resolver + URL tab + handler)
|
|
||||||
3. ffmpeg 保留(因 R5-6 走 LGPL,GPL blocker 解除)
|
|
||||||
4. Next.js Web UI 80-90% 沿用,只砍 URL tab
|
|
||||||
5. Wails 控制台走 vanilla HTML/JS/CSS(不新 Next.js mini app)
|
|
||||||
6. CORS 限制為 127.0.0.1/localhost
|
|
||||||
7. 綁定維持 127.0.0.1(不做 LAN mode)
|
|
||||||
8. watchServer 改為 Error state,不再 `os.Exit(1)`
|
|
||||||
9. 預設模型維持 8 個 .nef(「只能上傳」= 再次確認不做 Model Zoo)
|
|
||||||
10. 批次影像上傳保留
|
|
||||||
11. 上傳影片副檔名:`.mp4 / .avi / .mov / .mpeg / .mpg`
|
|
||||||
12. Server port、資料目錄、版本號、清 log 等工具資訊住 Wails 控制台
|
|
||||||
13. 硬體偵測結果、上傳模型、Settings > 語言 住瀏覽器 Web UI
|
|
||||||
14. Restart 期間瀏覽器 tab 用 `boot-id` + retry 重連(雖然 R5-2 選關閉=結束,此邏輯仍需做以支援 Restart Server 按鈕)
|
|
||||||
|
|
||||||
### 三方正式 v2 文件(已產出)
|
|
||||||
- ✅ PRD v2.0:`02-prd/PRD-v2.md`(484 行)— PM 5 個懸念見 §11
|
|
||||||
- ✅ Design Spec v2.0:`03-design/design-spec-v2.md`(99 行索引)+ `03-design/v2/*.md`(5 子檔)
|
|
||||||
- ✅ TDD v2.0:`04-architecture/TDD-v2.md`(136 行索引)+ `04-architecture/v2/*.md`(8 子檔,~3738 行)
|
|
||||||
|
|
||||||
### 三方 v2.1 補丁(已產出,吸收 R5-D + R5-E + 互審發現)
|
|
||||||
- ✅ PRD v2.1(原地更新 PRD-v2.md,500 行,卡在上限)
|
|
||||||
- ✅ Design Spec v2.1:索引 127 行 + settings-update 239 + control-panel 465 + **新檔 startup-progress 417**
|
|
||||||
- ✅ TDD v2.1:索引 162 行 + control-panel 830 + server-lifecycle 961 + web-ui-offline-overlay 更新 + deletions 更新 + milestone-plan 更新 + **新檔 startup-pipeline 518**
|
|
||||||
- **新工時預估**:10 → **12 人天**(+M8-4 +0.5 / +M8-4b +1 / +M8-7 +0.3 / +M8-10 +0.2),建議對外回報 ~13 人天含 buffer
|
|
||||||
|
|
||||||
### v2.1 新增懸而未決問題彙總
|
|
||||||
**Design 新增(3 題)**:
|
|
||||||
- D-Q1:20 秒 retry hint 文案「正在重試…」vs「正在處理中…」(Design 建議前者)
|
|
||||||
- D-Q2:WebSocket 被安全軟體擋的提示(Design 建議不做特殊偵測)
|
|
||||||
- D-Q3:Retry 按鈕語意「重置整個啟動」vs「重試當前階段」(Design 建議重置,需 Architect 確認 RestartStartupSequence 可行)
|
|
||||||
|
|
||||||
**Architect 新增(5 題)**:
|
|
||||||
- A-Q1:階段 6 WebSocket 首次連線實作方式 long-poll endpoint vs sentinel file(交 M8-4b 執行者)
|
|
||||||
- A-Q2:watcher goroutine 和使用者在 Starting 中按 Stop 的 race(action bar 禁用,M8-4b 實測)
|
|
||||||
- A-Q3:shutdownGracePeriod 7s/6s 對齊若實測常被 SIGKILL 則改 9+1 秒
|
|
||||||
- A-Q4:Linux notify-send 不存在時的 fallback(M8-10 實測 Ubuntu minimal)
|
|
||||||
- A-Q5:N-R4 CI/E2E 測試分層(blocked on testing agent)
|
|
||||||
|
|
||||||
**PM 保留**:
|
|
||||||
- §11-4 N-R4 CI/E2E 測試分層(同 A-Q5)
|
|
||||||
- §11-7 R5-E 6 階段中英雙語文案定稿(Design 已定版,使用者最後可 override)
|
|
||||||
|
|
||||||
### 第二輪三方互審結果(2026-04-14)— 🟢 全員通過
|
|
||||||
|
|
||||||
- ✅ **Design 審 PRD v2.1**:通過,3 Minor 不阻擋(Error 按鈕命名 / Linux OFF 階段描述 / v2.0 歷史字樣)
|
|
||||||
- ✅ **PM 審 TDD v2.1**:通過,2 Minor 不阻擋(code-reuse-v2.md:92 殘留 / milestone-plan.md:6 工時數字不同步)
|
|
||||||
- ✅ **Architect 審 Design Spec v2.1**:通過,3 Minor 全在 TDD 側(skipped status 枚舉 / WS sentinel file 決案 / 階段 6 soft timeout skip + Retry 機制)
|
|
||||||
|
|
||||||
### 第二輪關鍵仲裁
|
|
||||||
- **Error 按鈕命名分歧**:Architect 仲裁為**兩個獨立動作**
|
|
||||||
- Startup error(60s timeout 或階段失敗)→ 按鈕「**Retry**」= 呼叫 `RestartStartupSequence()` 重置整個啟動流程
|
|
||||||
- Running 階段 watchServer 失敗 → 按鈕「**Restart Server**」= 重 spawn server(既有行為)
|
|
||||||
- **D-Q3 RestartStartupSequence 可行性**:✅ 可行,新增 function(5 步驟實作細節已定)
|
|
||||||
- 停 watcher → ForceKill server → 重置 state machine → 重建 pipeline → 重跑 Start
|
|
||||||
- 階段 1 直接 Complete 不重跑
|
|
||||||
- sentinel file 必須先清
|
|
||||||
- Retry 情境下 port 允許 fallback(cold start 行為)
|
|
||||||
- **階段 6 WebSocket 就緒偵測方案**:採 **sentinel file** `<dataDir>/.first-ws-connected`(不用 long-poll endpoint)
|
|
||||||
- **D-Q2 WebSocket 被擋偵測**:不可行,不做特殊偵測
|
|
||||||
- **D-Q1 20s retry hint 文案**:不影響技術,Design 自由定稿
|
|
||||||
|
|
||||||
### Architect 自補 TDD 清單(M8-4b 前補完,估 1-2 小時,不啟動新 Agent)
|
|
||||||
第一輪遺留 4 項 + 第二輪新增 3 項:
|
|
||||||
1. offline-overlay 10s/2 次/3s active polling 參數
|
|
||||||
2. Gin SkipPaths + crypto/rand boot-id
|
|
||||||
3. Restart 強制同 port 規則
|
|
||||||
4. ExportLog binding
|
|
||||||
5. `StartupProgressEvent.Status` 新增 `"skipped"` 枚舉值
|
|
||||||
6. 階段 6 WebSocket sentinel file 決案寫入
|
|
||||||
7. 階段 6 Toggle OFF 時跳過 soft timeout + 新增 §9「Retry 機制」小節(含 RestartStartupSequence)
|
|
||||||
|
|
||||||
### v2.1 殘留 Minor(不阻擋開發,M8 過程中順手修)
|
|
||||||
- `04-architecture/v2/code-reuse-v2.md:92` 殘留「新增 autoOpenedThisSession 欄位」字樣(轉版漏改)
|
|
||||||
- `04-architecture/v2/milestone-plan.md:6` 摘要「~11.5 人天」和 L491 合計「12.0」不一致
|
|
||||||
- PRD v2.0 變更紀錄列殘留「首次自動開瀏覽器」(歷史紀錄,不修)
|
|
||||||
|
|
||||||
### M8 開發進度(2026-04-15)— 🟢 程式碼全部完成,只差 M8-10 交付
|
|
||||||
|
|
||||||
| Milestone | 狀態 | 備註 |
|
|
||||||
|-----------|------|------|
|
|
||||||
| Architect 自補 TDD 7 項 | ✅ 完成 | 7 項落地 + 意外發現(FAILURE_THRESHOLD 同步、ForceKill 缺失提醒、hard timeout skip)|
|
|
||||||
| M8-1 砍 yt-dlp | ✅ 完成 | +222/-555 行,18 檔案,5 項 build 全綠 |
|
|
||||||
| M8-2 砍 Mock | ✅ 完成 | -528 行,15 檔案,5 項 build 全綠,smoke test 通過 |
|
|
||||||
| M8-3 ffmpeg LGPL | ✅ 完成 | ffmpeg 5.7MB + ffprobe 5.6MB(比 GPL 版省 85% 空間),LGPL 合規,build 2m44s |
|
|
||||||
| M8-1+M8-2 Reviewer | ✅ 通過 | 親自 build/test/smoke,0 誤刪 0 殘留 |
|
|
||||||
| M8-3 Reviewer | ✅ 通過 | 18 項驗證全過,1 Minor + 2 Suggestion + 3 交付前事項 |
|
|
||||||
| M8-4 ServerController + log ring buffer | ✅ + Review 通過 + 4 Major 補丁 | 20 unit test + race -count=2 全綠 |
|
|
||||||
| M8-4b 啟動階段管線 | ✅ + Review 通過 + 3 Major 補丁 | 14+3 test,HasFailedStage / IsInColdStart helpers |
|
|
||||||
| M8-5 Wails 控制台 UI | ✅ + Review 通過 + 2 Critical 補丁 + Stage 6 CTA 補丁 | 9 檔 ~2012 行,wails build PASS |
|
|
||||||
| M8-6 source-selector 副檔名擴充 | ✅ 完成(未 Review 改動太小)| 4 檔案 ~4 行 |
|
|
||||||
| M8-7 Offline Overlay | ✅ + Review 通過 | role=alertdialog + focus trap + wsEverConnected 容錯 |
|
|
||||||
| M8-8 CORS middleware | ✅ + Review 通過 | 127.0.0.1/localhost + suffix attack 防護 |
|
|
||||||
| MAJ-4 shutdown broadcast | ✅ + Review 通過 | server/ws + visiona-local/notify helper,15 test |
|
|
||||||
| M8-9 Boot-ID + tab 重連 | ✅ + Review 通過 | 9 test + SSR 相容 + reload loop guard |
|
|
||||||
| **M8-10 端到端 smoke test + 三平台 build** | 🔄 **進行中** | macOS build ✅ + P0 latent bug 修復 ✅(預設 15 模型載入),待 Reviewer + Windows/Linux 驗證 |
|
|
||||||
|
|
||||||
### M8-3 Reviewer 交付前必做事項(M8-10 前)
|
|
||||||
1. ✅ **`vendor/ffmpeg/macos/` 4 檔 git add** — 已於 commit `8cd5751` 處理
|
|
||||||
2. ✅ **重跑 `make payload-macos`**(2026-04-15)— payload/darwin 204MB(原 GPL 版 ~280MB),LGPL 驗證通過,ffmpeg 5.7MB + ffprobe 5.6MB,無 yt-dlp 殘留
|
|
||||||
3. ✅ **`vendor/yt-dlp/` 87MB 殘留** — 已清除
|
|
||||||
|
|
||||||
### M8-10a macOS build + smoke test 結果(2026-04-15)
|
|
||||||
|
|
||||||
**✅ 通過項**:
|
|
||||||
- `make dmg` 成功:**163MB**(GPL 版 220 → LGPL 版 163,-57MB,符合 PRD v2.1 預估)
|
|
||||||
- `.app` bundle 215MB,codesign verify OK
|
|
||||||
- LGPL ffmpeg config 驗證:`--enable-version3` + 無 `--enable-gpl` + 無 libx264/libx265,只含 mp4/avi/mov/mpeg/mpg 所需 demuxer/decoder(符合 R5-6a 最小 decoder-only build)
|
|
||||||
- Server 從 bundle 正常啟動(127.0.0.1:3799)
|
|
||||||
- `VISIONA_BUNDLE_BIN_DIR` PATH 注入正確
|
|
||||||
- `deps/checker.go` **已檢查 ffprobe**(progress.md 舊標「⏳ 待補」實際已做,標記更正)
|
|
||||||
- `[OK] ffmpeg: (bundled)` ✅
|
|
||||||
- `[OK] ffprobe: (bundled)` ✅
|
|
||||||
- `[OK] python3: Python 3.14.3` ✅
|
|
||||||
- `GET /` → HTTP 200 size=24292(Next.js 首頁)✅ splash regression 不再發生
|
|
||||||
- `GET /api/system/health` → `{"status":"ok"}` ✅
|
|
||||||
- `GET /api/system/deps` → 三項全 available ✅
|
|
||||||
- `GET /api/devices` → 200(空陣列,無裝置)✅
|
|
||||||
- SIGTERM 優雅關閉 ✅
|
|
||||||
- CORS middleware init 無錯 ✅
|
|
||||||
|
|
||||||
### 🔴 M8-10a 抓到的 P0 latent bug(從 M1 就有,只是沒人測過)
|
|
||||||
|
|
||||||
**現象**:`GET /api/models` → `{"data":{"models":null,"total":0},"success":true}`
|
|
||||||
啟動 log:`Loaded 0 built-in models` + `Warning: could not load models from .../bin/data/models.json: no such file`
|
|
||||||
|
|
||||||
**根因**(`server/main.go:42-51` + `:99-108`):
|
|
||||||
- server 預設 `base = filepath.Dir(exe)` = `Contents/Resources/bin/`
|
|
||||||
- 預設 `dataDir = base + "/data"` = `Contents/Resources/bin/data/`(空目錄)
|
|
||||||
- 但 models.json + 8 個 .nef 實際住在 `Contents/Resources/data/`(上一層)
|
|
||||||
- Wails 端 `server_control.go:529` 明確傳 `--data-dir a.dataDir`,而 `a.dataDir = platformDataDir()` = `~/Library/Application Support/visiona-local/` — 使用者 dataDir,也**沒有** models.json(user dataDir 只存 lock / ipc-port / logs / custom-models / preferences.json)
|
|
||||||
- **結論**:正式啟動路徑下永遠載入 0 個預設模型
|
|
||||||
|
|
||||||
**為什麼 M1-M7 都沒抓到**:當時 smoke test 只測 `/api/health`、`/`、splash 跳轉,從沒跑過 `/api/models`。
|
|
||||||
|
|
||||||
**這違反 R5 第 9 點共識**:「預設模型維持 8 個 .nef(只能上傳 = 再次確認不做 Model Zoo)」— 8 個預設模型必須能載入,使用者才有基本 demo 體驗。
|
|
||||||
|
|
||||||
**影響範圍**:macOS / Windows / Linux 三平台都同樣這個 bug(server/main.go 是共用的)。
|
|
||||||
|
|
||||||
**採方案 B(使用者批准)+ 額外職責拆分**(2026-04-15)
|
|
||||||
|
|
||||||
實作:`server/main.go`
|
|
||||||
- 新增 `resolveBuiltInDataDir(base)` — 照 `resolveBridgeScript` 同款風格,依序試 `<base>/data` → `<base>/../data` → `<base>/../Resources/data`,**以 `models.json` 存在為命中條件**
|
|
||||||
- `main()` 拆出兩個獨立變數:
|
|
||||||
- `builtInDataDir`(read-only,bundle 內)— 給 `model.NewRepository(filepath.Join(builtInDataDir, "models.json"))` 與 `flash.NewService(deviceMgr, modelRepo, builtInDataDir)` 使用(因 flash 也要解析 model.filePath 相對路徑 `"data/nef/..."`)
|
|
||||||
- `dataDir`(writable,user home)— 給 custom-models / sentinel file / logs 使用,語意不變
|
|
||||||
- `cfg.DataDir == ""` 時 fallback 成 `builtInDataDir`(保 dev mode `go run ./server` 繼續可跑)
|
|
||||||
|
|
||||||
**為什麼順便拆職責**:原本的 bug 不只影響 `modelRepo`,也影響 `flash.Service`(`flash.service.go:115-121` 拿 `s.dataDir` 解析 `"data/nef/kl520/xxx.nef"` → 原本會指向 user dataDir 找不到檔案)。純 B 只修 main.go 一處還不夠,必須同時把 flash 切到 builtInDataDir。拆成兩個變數反而讓職責更清楚,未來不會再混淆。
|
|
||||||
|
|
||||||
**驗證結果**:
|
|
||||||
- `go build / vet / test -count=1 ./...` 全綠
|
|
||||||
- 重 build dmg 163MB(大小不變)
|
|
||||||
- Smoke test `/api/models` → `total: 15`(不是原估計的 8,因為 models.json 有 15 個條目,部分 model 共用 nef) ✅
|
|
||||||
- 啟動 log:`Built-in data dir: .../Contents/Resources/data` + `Loaded 15 built-in models` + 無 `could not load models` warning
|
|
||||||
- `/api/models/kl520-yolov5-detection` 回傳完整 metadata + filePath `data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef`
|
|
||||||
- flash 解析後指向的實體檔案在 bundle `.../data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef`(7.2MB)與 `kl720/...` (10MB),與 API 回傳的 modelSize 完全吻合 ✅
|
|
||||||
|
|
||||||
### Reviewer 第一輪(2026-04-15):⚠️ Major 1 / Minor 2 / Suggestion 2
|
|
||||||
報告:`.autoflow/05-implementation/reviews/review-m8-10a-builtin-data-dir-fix.md`
|
|
||||||
- **Major-1**:Linux AppImage 布局(`usr/bin/<exe>` + `usr/lib/visiona-local/data/`)三候選全不命中;AppRun 已 export `VISIONA_BUNDLE_LIB_DIR` 但 server 沒讀。備註 `resolveBridgeScript` 先前就有同樣缺失。
|
|
||||||
- **Minor-1**:fallback 沒 `filepath.Abs` 化
|
|
||||||
- **Minor-2**:fallback 沒 log 試過的候選
|
|
||||||
- **Suggestion s-1**:抽公用 `findFirstExisting` helper
|
|
||||||
- **Suggestion s-2**:dataDir dev mode fallback 註解
|
|
||||||
|
|
||||||
### Reviewer 第二輪修復(2026-04-15):Major + 所有 Minor + 兩個 Suggestion 一次全部處理
|
|
||||||
|
|
||||||
- 新增 `findFirstExisting(candidates, sentinel) (dir, tried)` helper(s-1)
|
|
||||||
- `resolveBuiltInDataDir` 候選 5 條:①env `VISIONA_BUNDLE_LIB_DIR/data` ②`<base>/data` ③`<base>/../data` ④`<base>/../Resources/data` ⑤`<base>/../lib/visiona-local/data`
|
|
||||||
- `resolveBridgeScript` 比照修復(技術債一起清),候選 6 條
|
|
||||||
- fallback 全 `filepath.Abs` 化(m-1)+ `log.Printf("warn: ... Tried: %v", tried)`(m-2)
|
|
||||||
- `main()` dataDir fallback 加 5 行註解解釋 dev-only 語意(s-2)
|
|
||||||
|
|
||||||
### 第二輪 Review(2026-04-15):✅ 通過,可交付三平台
|
|
||||||
- 逐項驗證:Major-1 ✅ / Minor-1 ✅ / Minor-2 ✅ / s-1 ✅ / s-2 ✅
|
|
||||||
- 獨立複驗:build / vet / test 全綠;AppImage 模擬(env var 路徑)✅;AppImage 模擬(FHS fallback 無 env)✅;全不命中情境 log + fallback + server 不 crash ✅;`os.Chdir` grep 零匹配(`./scripts` 相對候選無 cwd 漂移);候選順序對非 Linux 三平台零誤命中
|
|
||||||
- 新發現兩項**非阻擋**:
|
|
||||||
- Minor m2-1:resolve 函式用 std `log.Printf` 而非 `pkglogger.Warn`(logger 尚未初始化前呼叫,合理),下次 logger 重構時統一
|
|
||||||
- Suggestion s2-1:`findFirstExisting` 可改 `(dir, tried, ok bool)` 更 idiomatic,非必須
|
|
||||||
|
|
||||||
### M8-10b/c 待使用者驗證
|
|
||||||
- **Windows**:使用者在 Windows 實機跑 bootstrap + make exe → 驗證 splash → Wails 控制台 6 階段啟動 → 瀏覽器 Web UI
|
|
||||||
- **Linux**:Ubuntu 實機跑 bootstrap-linux.sh + make appimage → 驗證 xdg-open 預設 OFF + notify-send fallback
|
|
||||||
|
|
||||||
### M8-3 Minor + Suggestion(低優先)
|
|
||||||
- **Minor**:BUILD.md §Verification §5 預期 `spctl --assess=accepted` 實測會被 reject,改為 `codesign -v`
|
|
||||||
- **Suggestion 1**:`vendor-ffmpeg` target 可補 sha256 對比防呆
|
|
||||||
- **Suggestion 2**:`payload-windows` 授權檔 `skipifsourcedoesntexist` 若同時缺失會無授權交付
|
|
||||||
|
|
||||||
### 上一輪 Reviewer 提的 Minor(已解決 / 懸而未決)
|
|
||||||
- ✅ `source-selector.tsx` accept 清單已擴充 mpeg/mpg(M8-6 完成)
|
|
||||||
- ✅ `camera_handler.go` 後端副檔名白名單已擴充(M8-6 完成)
|
|
||||||
- ⏳ `deps/checker.go` 未加 ffprobe 檢查 — M8-3 後可補
|
|
||||||
- ⏳ `api_e2e_test.go` 整檔刪後失去 HTTP 層 smoke — 建議 M8-10 前補一份不依賴 mock 的 read-only e2e
|
|
||||||
|
|
||||||
### M8-4 Reviewer 結果:⚠️ 需修 5 Major(2026-04-15)
|
|
||||||
**親跑驗證全綠**:`go build/vet/test/test -race`、20 unit test、smoke test、SkipPaths 生效。
|
|
||||||
**5 個 Major**(4 個 M8-4 Agent 回修、1 個留 M8-4b 包辦):
|
|
||||||
- **MAJ-1** `server_control.go:198-229 / 251-265` Stop/ForceKill 不 cancel watchCancel → 30s 後誤翻 Error + 發崩潰通知
|
|
||||||
- **MAJ-2** `server_control.go:269-291` handleWatchFailure 未取 txMu → 與 Stop race
|
|
||||||
- **MAJ-3** `server/main.go:166` shutdownFn timeout 仍 10s,TDD §8.1 要求 6s(破壞 7+1 modal UX)
|
|
||||||
- **MAJ-4** 沒實作 `server:shutdown-imminent` WebSocket 廣播(阻擋 M8-9,不阻擋 M8-4b/5/7)→ **M8-4b 一起做**
|
|
||||||
- **MAJ-5** `server_control.go:579-608` logPump scanDone 不 drain lineCh → 丟最後 128 行崩潰 log
|
|
||||||
|
|
||||||
### M8-4 Reviewer 15 個 Minor(技術債,M8-5 後整理)
|
|
||||||
主要:Snapshot 效率、ShouldEmit CAS micro-race、Restart 拆兩段 txMu、stopGraceful 與 logPump file handle race、scanner select default、notify timeout、v1/v2 重複碼
|
|
||||||
|
|
||||||
### v1/v2 並存策略
|
|
||||||
**合理但需立即標記砍除時程**。v1 路徑(stopServer/stop()/kill()/watchServer/5s grace)已 dead code 但仍存在易誤用 → **M8-5 完成後立即砍 v1**(含 MIN-10/11/12 併處理)
|
|
||||||
|
|
||||||
### 待使用者決策
|
|
||||||
- **commit 策略**:Reviewer 建議分三個 commit(M8-1 / M8-2 / M8-3),或一個合併。使用者從未要求 commit,保守做法是先不 commit 等使用者說。
|
|
||||||
|
|
||||||
### R5-Design 補充決策(2026-04-14,Design v2 產出後使用者回答)
|
|
||||||
| # | 題目 | 使用者決定 |
|
|
||||||
|---|------|----------|
|
|
||||||
| R5-D1 | Server 崩潰時除了控制台 Error banner 是否仍發 OS 原生通知 | **保留** OS 通知 |
|
|
||||||
| R5-D2 | Linux 預設「啟動時自動開瀏覽器」 toggle 狀態 | **預設 OFF**(macOS/Windows 預設 ON),避免 xdg-open 在極簡 WM 異常 |
|
|
||||||
| R5-D3 | R5-4 字面歧義「首次啟動」vs「每次啟動」 | **每次啟動**都自動開瀏覽器(修正 R5-4 原本「首次」的字面,實際意圖是「每次 Start Server 成功後」) |
|
|
||||||
|
|
||||||
### 三方交叉審閱階段(進行中)
|
|
||||||
- ⏳ PM 審 TDD v2:驗證所有需求都有技術方案,R5 / R5-D 全部落地
|
|
||||||
- ⏳ Design 審 PRD v2:驗證體驗面沒遺漏,R5-D1/D2/D3 有無落地
|
|
||||||
- ⏳ Architect 審 Design Spec v2:驗證設計技術上可行
|
|
||||||
|
|
||||||
### 使用者授權
|
|
||||||
使用者已說「交互 review 完就進開發」— 審閱無衝突則直接進 M8,不用另外確認。
|
|
||||||
|
|
||||||
### 三方互審結果(2026-04-14)
|
|
||||||
|
|
||||||
**Design 審 PRD v2**:❌ 不通過(`02-prd/reviews/design-review-of-prd-v2.md`)
|
|
||||||
- Major 4 / Minor 4
|
|
||||||
- 核心問題:R5-D1/D2/D3 都沒吸收(PM 寫 PRD 時還不知道這三題)
|
|
||||||
- Major 4 auto-open toggle 位置分歧 → Design 仲裁「PRD 對(住 Wails 控制台)」,Design Spec v2 settings-update.md 要自修
|
|
||||||
- §11-5 徽章決定:**不加**
|
|
||||||
- Architect Q6 Overlay close tab 決定:**不設**
|
|
||||||
|
|
||||||
**PM 審 TDD v2**:⚠️ 條件通過(`04-architecture/reviews/pm-review-of-tdd-v2.md`)
|
|
||||||
- Major 4 / Minor 5
|
|
||||||
- 核心問題:R5-D 三題 TDD 零匹配 + M8-9 驗收條件 `autoOpenedThisSession` flag 和 R5-D3 相反(per-session-once 寫成成功條件,Reviewer 會誤判)
|
|
||||||
- PM 自行回答 PM §11 技術懸念:
|
|
||||||
- **AC-1.3 10 秒預算不可達**(估 5.5-18 秒)→ 建議放寬到 **15 秒**
|
|
||||||
- **idle RAM ≤ 450MB 可達**(估 275-405MB)
|
|
||||||
- **PM 對 Architect Q4 grace period 回答**:**7 秒 + 1 秒內顯示「停止中…」modal**(基於 Nielsen Norman 10 秒注意力臨界點)
|
|
||||||
- PM 判斷 M8-1/M8-2/M8-3 互不依賴,可在 Major 修復前先啟動(砍 yt-dlp / 砍 Mock / ffmpeg LGPL vendor)
|
|
||||||
|
|
||||||
**Architect 審 Design Spec v2**:⚠️ 有條件通過(`03-design/reviews/architect-review-of-design-spec-v2.md`)
|
|
||||||
- Major 2 / Minor 12
|
|
||||||
- Major 1:Design settings-update.md §2.2 誤稱「走 Wails 既有 settings store」(Wails v2 無此機制)+ 檔名不一致 → 採 TDD 的 `preferences.json @ <dataDir>/`
|
|
||||||
- Major 2:R5-D2 Linux 預設 OFF 兩份 spec 都沒落地 → 新增 `DefaultPreferences()` 依 `runtime.GOOS`
|
|
||||||
- Architect 7 懸念自決:
|
|
||||||
- **Q1** grep 確認 `NewVideoSourceFromURL` 只有 `StartFromURL` + `videoIsURL`-guarded seek handler 呼叫 → 整組砍(含 `videoIsURL` field)
|
|
||||||
- **Q3** `crypto/rand` 16 bytes → hex(不引入 google/uuid)
|
|
||||||
- **Q5** navigator.language fallback:zh* → zh-TW / en* → en-US / else → zh-TW
|
|
||||||
- **Q7** preferences JSON 用 write-rename atomic pattern
|
|
||||||
- **Architect 對 PM §11 回答**:
|
|
||||||
- **§11-1** `preferences.json` @ `<dataDir>/`,write-rename 原子寫,fallback DefaultPreferences
|
|
||||||
- **§11-2** 樂觀 ~4s / 悲觀 ~8s 達標,但 **Windows + Defender 最壞 ~11s 可能超時**,建議 M8-10 實測,超時則 AC-1.3 放寬到 **12 秒**(Architect 說 12,PM 說 15,差 3 秒)
|
|
||||||
- **§11-3** idle RAM 樂觀 ~370MB 達標,悲觀 ~500MB 超 50MB,**建議 PRD clarify「450MB 不含 browser tab」**
|
|
||||||
- **關鍵發現 F-2**:Restart Server port 保留 — TDD 允許 fallback 到 3722 會讓瀏覽器 tab URL 過期導致 Offline Overlay 永卡 → Restart 強制保留舊 port,不可 fallback,用不了就進 Error state(Architect 自補)
|
|
||||||
- **關鍵發現 B-1**:watchServer 改 Error state 時等於砍掉 OS 通知(違反 R5-D1)→ 新增 `sendCrashNotification()` non-blocking toast(新檔 `visiona-local/notify.go`,Architect 自補)
|
|
||||||
|
|
||||||
### R5-E 追加決策(2026-04-14,互審結論後使用者追加)
|
|
||||||
|
|
||||||
使用者把「AC-1.3 時間預算」問題從「要多快」翻轉成「**讓使用者感覺進度有在推動**」— 採 Nielsen Norman perceived performance 原則而非硬時間指標。
|
|
||||||
|
|
||||||
| # | 決定 |
|
|
||||||
|---|------|
|
|
||||||
| R5-E1 | **AC-1.3 時間上限放寬到 60 秒**(原 10 秒),原則是 perceived performance > 硬時間指標 |
|
|
||||||
| R5-E2 | **啟動全程必須有階段化進度顯示**:每個階段有編號 / 動作描述 / 視覺回饋 / 中英雙語文案 |
|
|
||||||
| R5-E3 | **任一階段卡超過 20 秒**要顯示「正在重試」類提示,不可白畫面 |
|
|
||||||
| R5-E4 | **超過 60 秒總上限仍未就緒** → 進 Error state(和 watchServer 3 次失敗一致),顯示重試 / 回報 / 檢視 log 三按鈕 |
|
|
||||||
| R5-E5 | **階段文字由 Design Agent 決定**(使用者授權)— 使用者最後審 wireframe 時可以 override |
|
|
||||||
| R5-E6 | **瀏覽器就緒偵測採 WebSocket 連上訊號**(不做新 endpoint,不做固定延遲),WebSocket hub 收到第一個 client 連線視為第 6 階段「ready」 |
|
|
||||||
|
|
||||||
### 啟動階段建議(6 階段,Design 最終定版)
|
|
||||||
1. 初始化 Wails 控制台
|
|
||||||
2. 檢查 Python runtime + 驅動
|
|
||||||
3. 啟動本機伺服器(port binding)
|
|
||||||
4. 偵測 Kneron 裝置
|
|
||||||
5. 開啟瀏覽器
|
|
||||||
6. 瀏覽器就緒(WebSocket 連上)
|
|
||||||
|
|
||||||
### 技術影響(三方 v2.1 補丁輪要吸收)
|
|
||||||
- 新增 Wails event:`startup:progress {stage, label_zh, label_en, status}`
|
|
||||||
- 新增 Wails event:`startup:stage-timeout {stage}`(20 秒卡住觸發)
|
|
||||||
- `StartServer()` 改為階段化,每個階段 emit event
|
|
||||||
- Wails 控制台 vanilla JS 要訂閱 event 更新進度面板
|
|
||||||
- 新增啟動進度面板 UI(Design Spec v2.1 wireframe)
|
|
||||||
- M8-4/M8-5 工時可能 +0.5-1 天
|
|
||||||
|
|
||||||
### 修正計畫(v2.1 補丁輪)
|
|
||||||
- PM → PRD v2.1:補 R5-D1/D2/D3、Minor 1-4、AC-1.3 放寬到 12 秒、idle RAM 加註「不含 browser tab」
|
|
||||||
- Design → Design Spec v2.1:
|
|
||||||
- settings-update.md 修 Major 1+2(`preferences.json @ <dataDir>/` + `DefaultPreferences()` 平台差異)
|
|
||||||
- control-panel.md §4.4 log 1000→2000 / §6.2 補 OS notification + Report 按鈕 hold 註記 / §7.1 第 5 步「首次→每次」
|
|
||||||
- Architect → TDD v2.1:
|
|
||||||
- R5-D1 sendCrashNotification 實作(新檔 notify.go)
|
|
||||||
- R5-D2 DefaultPreferences 依 GOOS
|
|
||||||
- R5-D3 砍 autoOpenedThisSession flag,每次 StartServer 都 trigger OpenInBrowser
|
|
||||||
- M8-9 驗收條件修正(移除「Restart 不會二次開」條件)
|
|
||||||
- Restart 同 port 規則
|
|
||||||
- PM §11-1/2/3 寫入 TDD
|
|
||||||
- Q4 grace period 採 PM 7 秒 + 1 秒 modal 建議
|
|
||||||
- Q1/Q3/Q5/Q7 自決結果寫入
|
|
||||||
|
|
||||||
> 以下是 2026-04-12 之前的進度快照,保留備查。變更確認後需要全面更新。
|
|
||||||
|
|
||||||
## 🎉 M1 達成總結
|
## 🎉 M1 達成總結
|
||||||
- `dist/visiona-local.dmg` (70MB) 可雙擊安裝
|
- `dist/visiona-local.dmg` (70MB) 可雙擊安裝
|
||||||
|
|||||||
9
local-tool/.github/workflows/build.yml
vendored
9
local-tool/.github/workflows/build.yml
vendored
@ -12,6 +12,9 @@ env:
|
|||||||
GO_VERSION: '1.26'
|
GO_VERSION: '1.26'
|
||||||
NODE_VERSION: '22'
|
NODE_VERSION: '22'
|
||||||
PNPM_VERSION: '9'
|
PNPM_VERSION: '9'
|
||||||
|
# 暫時允許 GPL ffmpeg build 通過 vendor-sync 的授權檢查。
|
||||||
|
# 發佈前若 ffmpeg 來源改為 LGPL,請移除此變數。
|
||||||
|
VISIONA_ALLOW_GPL_FFMPEG: '1'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
@ -132,7 +135,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
make vendor-python-windows \
|
make vendor-python-windows \
|
||||||
vendor-wheels-windows \
|
vendor-wheels-windows \
|
||||||
vendor-ffmpeg-windows
|
vendor-ffmpeg-windows \
|
||||||
|
vendor-ytdlp-windows
|
||||||
|
|
||||||
- name: Build server.exe
|
- name: Build server.exe
|
||||||
env:
|
env:
|
||||||
@ -229,7 +233,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
make vendor-python-linux \
|
make vendor-python-linux \
|
||||||
vendor-wheels-linux \
|
vendor-wheels-linux \
|
||||||
vendor-ffmpeg-linux
|
vendor-ffmpeg-linux \
|
||||||
|
vendor-ytdlp-linux
|
||||||
|
|
||||||
- name: Build server (Linux)
|
- name: Build server (Linux)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
7
local-tool/.gitignore
vendored
7
local-tool/.gitignore
vendored
@ -8,18 +8,11 @@
|
|||||||
/vendor/**
|
/vendor/**
|
||||||
!/vendor/.gitkeep
|
!/vendor/.gitkeep
|
||||||
!/vendor/README.md
|
!/vendor/README.md
|
||||||
# R5-6b:macOS LGPL ffmpeg binary 進 git(沒有現成 LGPL binary 來源,自 build 成本高,
|
|
||||||
# commit 後開發者 clone 即可用,不必每次重 build ~15 分鐘)
|
|
||||||
!/vendor/ffmpeg/
|
|
||||||
!/vendor/ffmpeg/macos/
|
|
||||||
!/vendor/ffmpeg/macos/**
|
|
||||||
|
|
||||||
# ── 建置產出 ──
|
# ── 建置產出 ──
|
||||||
/dist/
|
/dist/
|
||||||
/visiona-local/build/bin/
|
/visiona-local/build/bin/
|
||||||
/visiona-local/build/darwin/Resources/
|
/visiona-local/build/darwin/Resources/
|
||||||
# M8-3:頂層 build/ffmpeg-macos/ 是 ffmpeg self-build 中間產物(source + obj + install,~180MB),不進 git
|
|
||||||
/build/
|
|
||||||
/visiona-local/payload/
|
/visiona-local/payload/
|
||||||
!/visiona-local/payload/.gitkeep
|
!/visiona-local/payload/.gitkeep
|
||||||
# M1-11:頂層 payload/ 是 build 產物,不進 git(除了 .gitkeep)
|
# M1-11:頂層 payload/ 是 build 產物,不進 git(除了 .gitkeep)
|
||||||
|
|||||||
@ -19,16 +19,16 @@ DIST := dist
|
|||||||
PAYLOAD := visiona-local/payload
|
PAYLOAD := visiona-local/payload
|
||||||
|
|
||||||
.PHONY: help \
|
.PHONY: help \
|
||||||
vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ffmpeg-macos-build \
|
vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ytdlp \
|
||||||
vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows \
|
vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows vendor-ytdlp-windows \
|
||||||
vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux \
|
vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux vendor-ytdlp-linux \
|
||||||
server build-server \
|
server build-server \
|
||||||
frontend build-frontend build-embed \
|
frontend build-frontend build-embed \
|
||||||
payload payload-macos payload-windows payload-linux \
|
payload payload-macos payload-windows payload-linux \
|
||||||
stage-macos stage-windows \
|
stage-macos stage-windows \
|
||||||
wails-macos wails-windows wails-linux \
|
wails-macos wails-windows wails-linux \
|
||||||
dmg exe exe-only _run-iscc appimage \
|
dmg exe exe-only _run-iscc appimage \
|
||||||
dev test lint fmt \
|
dev dev-mock test lint fmt \
|
||||||
clean clean-all clean-build-exe clean-build-dmg clean-build-appimage
|
clean clean-all clean-build-exe clean-build-dmg clean-build-appimage
|
||||||
|
|
||||||
# ── 幫助 ───────────────────────────────────────────────────────────
|
# ── 幫助 ───────────────────────────────────────────────────────────
|
||||||
@ -36,7 +36,7 @@ help: ## 列出所有 make targets
|
|||||||
@echo "visionA-local — available targets (M1-1 skeleton)"
|
@echo "visionA-local — available targets (M1-1 skeleton)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " 依賴同步:"
|
@echo " 依賴同步:"
|
||||||
@echo " vendor-sync 下載 python-build-standalone / wheels / ffmpeg → vendor/"
|
@echo " vendor-sync 下載 python-build-standalone / wheels / ffmpeg / yt-dlp → vendor/"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " Build(單元):"
|
@echo " Build(單元):"
|
||||||
@echo " server build Go server binary (→ dist/visiona-local-server)"
|
@echo " server build Go server binary (→ dist/visiona-local-server)"
|
||||||
@ -67,16 +67,10 @@ PYTHON_VERSION := 3.12.9
|
|||||||
PBS_RELEASE := 20250317
|
PBS_RELEASE := 20250317
|
||||||
PBS_URL_DARWIN := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-apple-darwin-install_only.tar.gz
|
PBS_URL_DARWIN := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-apple-darwin-install_only.tar.gz
|
||||||
|
|
||||||
# ── ffmpeg(LGPL v3,方案 B 混合)──
|
FFMPEG_URL_DARWIN := https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip
|
||||||
# v2 TDD §2.2:macOS 自 build decoder-only(~20 MB,commit 到 vendor/ffmpeg/macos/),
|
YTDLP_URL_DARWIN := https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos
|
||||||
# Windows / Linux 用 BtbN 的 n7.1 LGPL build。
|
|
||||||
FFMPEG_VERSION := n7.1
|
|
||||||
FFMPEG_SRC_URL := https://github.com/FFmpeg/FFmpeg/archive/refs/tags/$(FFMPEG_VERSION).tar.gz
|
|
||||||
# sha256 於第一次 build 時由 `make vendor-ffmpeg-macos-build` 計算後填入 vendor/ffmpeg/macos/BUILD.md,
|
|
||||||
# 之後每次 build 使用此值做 integrity check(下方 Makefile 變數亦同步更新)。
|
|
||||||
FFMPEG_SRC_SHA256 := 7ddad2d992bd250a6c56053c26029f7e728bebf0f37f80cf3f8a0e6ec706431a
|
|
||||||
|
|
||||||
vendor-sync: vendor-python vendor-wheels vendor-ffmpeg ## 下載所有第三方依賴到 vendor/(不進 git,第三輪決策 Q-D=D2)
|
vendor-sync: vendor-python vendor-wheels vendor-ffmpeg vendor-ytdlp ## 下載所有第三方依賴到 vendor/(不進 git,第三輪決策 Q-D=D2)
|
||||||
@echo "==> vendor-sync 完成"
|
@echo "==> vendor-sync 完成"
|
||||||
|
|
||||||
vendor-python: ## 下載 python-build-standalone tarball → vendor/python/darwin/
|
vendor-python: ## 下載 python-build-standalone tarball → vendor/python/darwin/
|
||||||
@ -109,96 +103,45 @@ vendor-wheels: ## 同步 wheels → vendor/wheels/darwin/(內部 wheel 從 vis
|
|||||||
@ls -1 vendor/wheels/darwin/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
|
@ls -1 vendor/wheels/darwin/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
|
||||||
@du -sh vendor/wheels/darwin
|
@du -sh vendor/wheels/darwin
|
||||||
|
|
||||||
vendor-ffmpeg: ## macOS:LGPL v3 ffmpeg + ffprobe 已 commit 到 vendor/ffmpeg/macos/,本 target 只驗證存在 + LGPL 合規
|
vendor-ffmpeg: ## 下載 ffmpeg static build (macOS x86_64) → vendor/ffmpeg/darwin/(預設要求 LGPL,必要時可用 VISIONA_ALLOW_GPL_FFMPEG=1 暫時放行 GPL)
|
||||||
@if [ ! -f vendor/ffmpeg/macos/ffmpeg ]; then \
|
@mkdir -p vendor/ffmpeg/darwin
|
||||||
echo "❌ vendor/ffmpeg/macos/ffmpeg 不存在。"; \
|
@if [ -f vendor/ffmpeg/darwin/ffmpeg ]; then \
|
||||||
echo " 第一次 build 請執行:make vendor-ffmpeg-macos-build"; \
|
echo "==> ffmpeg 已存在,跳過 ($$(du -sh vendor/ffmpeg/darwin/ffmpeg | cut -f1))"; \
|
||||||
echo " (只需要在升級 ffmpeg 版本時跑一次;平常 clone repo 後 binary 已在 git 內)"; \
|
else \
|
||||||
exit 1; \
|
echo "==> 下載 ffmpeg static build (macOS) from evermeet.cx..."; \
|
||||||
|
curl -fL -o /tmp/ffmpeg-latest.zip "$(FFMPEG_URL_DARWIN)"; \
|
||||||
|
unzip -o /tmp/ffmpeg-latest.zip -d vendor/ffmpeg/darwin/; \
|
||||||
|
rm -f /tmp/ffmpeg-latest.zip; \
|
||||||
|
chmod +x vendor/ffmpeg/darwin/ffmpeg; \
|
||||||
|
echo "==> ffmpeg 大小:$$(du -sh vendor/ffmpeg/darwin/ffmpeg | cut -f1)"; \
|
||||||
|
vendor/ffmpeg/darwin/ffmpeg -version 2>&1 | head -3; \
|
||||||
|
echo "==> 授權檢查:"; \
|
||||||
|
if vendor/ffmpeg/darwin/ffmpeg -version 2>&1 | grep -q -- '--enable-gpl'; then \
|
||||||
|
if [ "$${VISIONA_ALLOW_GPL_FFMPEG:-0}" = "1" ]; then \
|
||||||
|
echo " !! WARNING: 此 build 含 --enable-gpl(GPL build) !!"; \
|
||||||
|
echo " !! VISIONA_ALLOW_GPL_FFMPEG=1 已啟用,暫時允許(僅限本地驗收,不可發佈) !!"; \
|
||||||
|
else \
|
||||||
|
echo "!! 錯誤:此 build 含 --enable-gpl,違反 LGPL 策略 !!"; \
|
||||||
|
echo "!! macOS 暫無現成 LGPL binary 來源,需自行 build 或用 VISIONA_ALLOW_GPL_FFMPEG=1 暫時放行 !!"; \
|
||||||
|
rm -f vendor/ffmpeg/darwin/ffmpeg; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
else \
|
||||||
|
echo " OK: 未偵測到 --enable-gpl"; \
|
||||||
|
fi; \
|
||||||
fi
|
fi
|
||||||
@if [ ! -f vendor/ffmpeg/macos/ffprobe ]; then \
|
|
||||||
echo "❌ vendor/ffmpeg/macos/ffprobe 不存在。"; \
|
|
||||||
echo " 請執行:make vendor-ffmpeg-macos-build"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
@echo "==> vendor/ffmpeg/macos/ffmpeg 存在:$$(du -h vendor/ffmpeg/macos/ffmpeg | cut -f1)"
|
|
||||||
@echo "==> vendor/ffmpeg/macos/ffprobe 存在:$$(du -h vendor/ffmpeg/macos/ffprobe | cut -f1)"
|
|
||||||
@if vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -qE -- '--enable-gpl|libx264|libx265'; then \
|
|
||||||
echo "❌ LGPL 驗證失敗:binary 含 --enable-gpl / libx264 / libx265"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
@echo "==> LGPL 驗證通過(無 --enable-gpl / libx264 / libx265)"
|
|
||||||
|
|
||||||
# 只有升級 ffmpeg 版本時才跑此 target;binary 產出後 commit 到 git(v2 TDD R5-6b)。
|
vendor-ytdlp: ## 下載 yt-dlp standalone (macOS) → vendor/yt-dlp/darwin/
|
||||||
# 需要的系統依賴(macOS):
|
@mkdir -p vendor/yt-dlp/darwin
|
||||||
# brew install pkg-config nasm # 或 yasm
|
@if [ ! -f vendor/yt-dlp/darwin/yt-dlp ]; then \
|
||||||
vendor-ffmpeg-macos-build: ## macOS:從源碼 build LGPL v3 decoder-only ffmpeg + ffprobe(升級時才跑,~15 分鐘)
|
echo "==> 下載 yt-dlp (macOS)..."; \
|
||||||
@if [ "$$(uname -s)" != "Darwin" ]; then \
|
curl -fL -o vendor/yt-dlp/darwin/yt-dlp "$(YTDLP_URL_DARWIN)"; \
|
||||||
echo "❌ vendor-ffmpeg-macos-build 只能在 macOS 上跑"; exit 1; \
|
chmod +x vendor/yt-dlp/darwin/yt-dlp; \
|
||||||
|
echo "==> yt-dlp 大小:$$(du -sh vendor/yt-dlp/darwin/yt-dlp | cut -f1)"; \
|
||||||
|
vendor/yt-dlp/darwin/yt-dlp --version 2>&1 | head -1; \
|
||||||
|
else \
|
||||||
|
echo "==> yt-dlp 已存在,跳過 ($$(du -sh vendor/yt-dlp/darwin/yt-dlp | cut -f1))"; \
|
||||||
fi
|
fi
|
||||||
@command -v pkg-config >/dev/null 2>&1 || { echo "❌ 需要 pkg-config(brew install pkg-config)"; exit 1; }
|
|
||||||
@command -v nasm >/dev/null 2>&1 || command -v yasm >/dev/null 2>&1 || { echo "❌ 需要 nasm 或 yasm(brew install nasm)"; exit 1; }
|
|
||||||
@mkdir -p vendor/ffmpeg/macos build/ffmpeg-macos
|
|
||||||
@echo "==> 下載 ffmpeg source $(FFMPEG_VERSION)..."
|
|
||||||
curl -fL -o build/ffmpeg-macos/source.tar.gz "$(FFMPEG_SRC_URL)"
|
|
||||||
@echo "==> 驗證 source tarball sha256..."
|
|
||||||
@echo "$(FFMPEG_SRC_SHA256) build/ffmpeg-macos/source.tar.gz" | shasum -a 256 -c || { \
|
|
||||||
echo "❌ sha256 不符,請更新 Makefile 中的 FFMPEG_SRC_SHA256 或檢查來源"; \
|
|
||||||
echo " 實際 sha256:$$(shasum -a 256 build/ffmpeg-macos/source.tar.gz | awk '{print $$1}')"; \
|
|
||||||
exit 1; }
|
|
||||||
@rm -rf build/ffmpeg-macos/src build/ffmpeg-macos/install
|
|
||||||
@mkdir -p build/ffmpeg-macos/src
|
|
||||||
tar xzf build/ffmpeg-macos/source.tar.gz -C build/ffmpeg-macos/src --strip-components=1
|
|
||||||
@echo "==> configure(decoder-only LGPL v3)..."
|
|
||||||
cd build/ffmpeg-macos/src && ./configure \
|
|
||||||
--prefix="$$(pwd)/../install" \
|
|
||||||
--enable-version3 \
|
|
||||||
--disable-debug \
|
|
||||||
--disable-doc \
|
|
||||||
--disable-ffplay \
|
|
||||||
--disable-network \
|
|
||||||
--disable-autodetect \
|
|
||||||
--disable-shared \
|
|
||||||
--enable-static \
|
|
||||||
--disable-everything \
|
|
||||||
--enable-small \
|
|
||||||
--enable-protocol=file,pipe \
|
|
||||||
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
|
|
||||||
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
|
|
||||||
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
|
|
||||||
--enable-filter=scale,format,fps,null,anull \
|
|
||||||
--enable-muxer=image2pipe,image2,null \
|
|
||||||
--enable-encoder=mjpeg \
|
|
||||||
--enable-swscale \
|
|
||||||
--enable-swresample \
|
|
||||||
--extra-cflags="-arch x86_64 -mmacosx-version-min=10.15" \
|
|
||||||
--extra-ldflags="-arch x86_64 -mmacosx-version-min=10.15 -Wl,-search_paths_first" \
|
|
||||||
--arch=x86_64 \
|
|
||||||
--target-os=darwin \
|
|
||||||
--cc="clang -arch x86_64"
|
|
||||||
cd build/ffmpeg-macos/src && make -j$$(sysctl -n hw.ncpu)
|
|
||||||
cd build/ffmpeg-macos/src && make install
|
|
||||||
@echo "==> 複製 ffmpeg + ffprobe 到 vendor/ffmpeg/macos/..."
|
|
||||||
cp build/ffmpeg-macos/install/bin/ffmpeg vendor/ffmpeg/macos/ffmpeg
|
|
||||||
cp build/ffmpeg-macos/install/bin/ffprobe vendor/ffmpeg/macos/ffprobe
|
|
||||||
@strip -S -x vendor/ffmpeg/macos/ffmpeg
|
|
||||||
@strip -S -x vendor/ffmpeg/macos/ffprobe
|
|
||||||
@chmod +x vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
|
|
||||||
@echo "==> ad-hoc 簽章..."
|
|
||||||
codesign --force --sign - vendor/ffmpeg/macos/ffmpeg
|
|
||||||
codesign --force --sign - vendor/ffmpeg/macos/ffprobe
|
|
||||||
@echo "==> 驗證授權(configuration line 不含 --enable-gpl / libx264 / libx265)..."
|
|
||||||
@if vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -E -- '--enable-gpl|libx264|libx265'; then \
|
|
||||||
echo "❌ LGPL 驗證失敗:build 不該出現 gpl / x264 / x265"; exit 1; \
|
|
||||||
fi
|
|
||||||
@echo "==> 複製 COPYING.LGPLv3 到 vendor/ffmpeg/macos/..."
|
|
||||||
cp build/ffmpeg-macos/src/COPYING.LGPLv3 vendor/ffmpeg/macos/COPYING.LGPLv3
|
|
||||||
@echo "==> ffmpeg 大小:$$(du -h vendor/ffmpeg/macos/ffmpeg | cut -f1)"
|
|
||||||
@echo "==> ffprobe 大小:$$(du -h vendor/ffmpeg/macos/ffprobe | cut -f1)"
|
|
||||||
@echo ""
|
|
||||||
@echo "✅ macOS LGPL ffmpeg build 完成。接下來請:"
|
|
||||||
@echo " 1) 更新 vendor/ffmpeg/macos/BUILD.md 的 Binary sha256 區塊"
|
|
||||||
@echo " 2) git add vendor/ffmpeg/macos/{ffmpeg,ffprobe,COPYING.LGPLv3,BUILD.md}"
|
|
||||||
|
|
||||||
# ── Build(單元) ──────────────────────────────────────────────────
|
# ── Build(單元) ──────────────────────────────────────────────────
|
||||||
server: build-server ## alias for build-server
|
server: build-server ## alias for build-server
|
||||||
@ -236,15 +179,14 @@ build-embed: build-frontend ## 同步 frontend/out → server/web/out 供 go:emb
|
|||||||
# ── Payload 準備 ───────────────────────────────────────────────────
|
# ── Payload 準備 ───────────────────────────────────────────────────
|
||||||
payload: payload-$(OS) ## 依當前 OS 準備 payload
|
payload: payload-$(OS) ## 依當前 OS 準備 payload
|
||||||
|
|
||||||
payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg ## 準備 macOS payload → payload/darwin/(含 python runtime + wheels + ffmpeg)
|
payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg vendor-ytdlp ## 準備 macOS payload → payload/darwin/(含 python runtime + wheels + ffmpeg + yt-dlp)
|
||||||
@echo "==> 建立 macOS payload (binary + models + scripts + python + wheels + ffmpeg + ffprobe)..."
|
@echo "==> 建立 macOS payload (binary + models + scripts + python + wheels + ffmpeg + yt-dlp)..."
|
||||||
rm -rf payload/darwin
|
rm -rf payload/darwin
|
||||||
mkdir -p payload/darwin/bin payload/darwin/data payload/darwin/scripts payload/darwin/python payload/darwin/wheels
|
mkdir -p payload/darwin/bin payload/darwin/data payload/darwin/scripts payload/darwin/python payload/darwin/wheels
|
||||||
cp dist/visiona-local-server payload/darwin/bin/
|
cp dist/visiona-local-server payload/darwin/bin/
|
||||||
cp vendor/ffmpeg/macos/ffmpeg payload/darwin/bin/
|
cp vendor/ffmpeg/darwin/ffmpeg payload/darwin/bin/
|
||||||
cp vendor/ffmpeg/macos/ffprobe payload/darwin/bin/
|
cp vendor/yt-dlp/darwin/yt-dlp payload/darwin/bin/
|
||||||
cp vendor/ffmpeg/macos/COPYING.LGPLv3 payload/darwin/bin/ffmpeg-COPYING.LGPLv3
|
chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/yt-dlp
|
||||||
chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/ffprobe
|
|
||||||
cp -R server/data/* payload/darwin/data/
|
cp -R server/data/* payload/darwin/data/
|
||||||
cp -R server/scripts/* payload/darwin/scripts/
|
cp -R server/scripts/* payload/darwin/scripts/
|
||||||
cp vendor/python/darwin/python.tar.gz payload/darwin/python/
|
cp vendor/python/darwin/python.tar.gz payload/darwin/python/
|
||||||
@ -273,8 +215,8 @@ stage-macos: payload-macos ## 將 payload/darwin/ 放到 Wails build/darwin/Reso
|
|||||||
# payload-windows / vendor-*-windows 是 curl 下載,跨平台可跑(server.exe 步驟除外)。
|
# payload-windows / vendor-*-windows 是 curl 下載,跨平台可跑(server.exe 步驟除外)。
|
||||||
|
|
||||||
PBS_URL_WINDOWS := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-pc-windows-msvc-install_only.tar.gz
|
PBS_URL_WINDOWS := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-pc-windows-msvc-install_only.tar.gz
|
||||||
# LGPL v3 build(BtbN n7.1 穩定分支,含 LGPL-safe extra libs)— v2 TDD §3
|
FFMPEG_URL_WINDOWS := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
|
||||||
FFMPEG_URL_WINDOWS := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-win64-lgpl-7.1.zip
|
YTDLP_URL_WINDOWS := https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe
|
||||||
|
|
||||||
vendor-python-windows: ## 下載 python-build-standalone Windows x86_64 → vendor/python/windows/
|
vendor-python-windows: ## 下載 python-build-standalone Windows x86_64 → vendor/python/windows/
|
||||||
@mkdir -p vendor/python/windows
|
@mkdir -p vendor/python/windows
|
||||||
@ -316,12 +258,13 @@ vendor-wheels-windows: ## 同步 Windows wheels → vendor/wheels/windows/
|
|||||||
@ls -1 vendor/wheels/windows/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
|
@ls -1 vendor/wheels/windows/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
|
||||||
@du -sh vendor/wheels/windows 2>/dev/null || true
|
@du -sh vendor/wheels/windows 2>/dev/null || true
|
||||||
|
|
||||||
vendor-ffmpeg-windows: ## 下載 ffmpeg Windows LGPL v3 build (n7.1) → vendor/ffmpeg/windows/
|
vendor-ffmpeg-windows: ## 下載 ffmpeg Windows static build → vendor/ffmpeg/windows/
|
||||||
@mkdir -p vendor/ffmpeg/windows
|
@mkdir -p vendor/ffmpeg/windows
|
||||||
@if [ -f vendor/ffmpeg/windows/ffmpeg.exe ] && [ -f vendor/ffmpeg/windows/ffprobe.exe ]; then \
|
@if [ -f vendor/ffmpeg/windows/ffmpeg.exe ]; then \
|
||||||
echo "==> ffmpeg.exe + ffprobe.exe 已存在,跳過"; \
|
echo "==> ffmpeg.exe 已存在,跳過"; \
|
||||||
else \
|
else \
|
||||||
echo "==> 下載 BtbN LGPL ffmpeg (Windows, n7.1)..."; \
|
echo "==> 下載 ffmpeg Windows build from BtbN..."; \
|
||||||
|
echo "!! WARNING: BtbN 為 GPL build;license 由 PM 最終確認 !!"; \
|
||||||
curl -fL -o vendor/ffmpeg/windows/ffmpeg-win.zip "$(FFMPEG_URL_WINDOWS)"; \
|
curl -fL -o vendor/ffmpeg/windows/ffmpeg-win.zip "$(FFMPEG_URL_WINDOWS)"; \
|
||||||
PY=""; \
|
PY=""; \
|
||||||
for candidate in "$$VISIONA_PYTHON" "py -3" python3 python; do \
|
for candidate in "$$VISIONA_PYTHON" "py -3" python3 python; do \
|
||||||
@ -331,24 +274,29 @@ vendor-ffmpeg-windows: ## 下載 ffmpeg Windows LGPL v3 build (n7.1) → vendor/
|
|||||||
if $$candidate --version >/dev/null 2>&1; then PY="$$candidate"; break; fi; \
|
if $$candidate --version >/dev/null 2>&1; then PY="$$candidate"; break; fi; \
|
||||||
done; \
|
done; \
|
||||||
if [ -z "$$PY" ]; then echo "ERROR: 需要真實 python 來解壓 zip(WindowsApps stub 無法使用)"; exit 1; fi; \
|
if [ -z "$$PY" ]; then echo "ERROR: 需要真實 python 來解壓 zip(WindowsApps stub 無法使用)"; exit 1; fi; \
|
||||||
echo "==> 使用 $$PY 解壓 ffmpeg zip(取出 ffmpeg.exe / ffprobe.exe / LICENSE.txt)"; \
|
echo "==> 使用 $$PY 解壓 ffmpeg zip"; \
|
||||||
$$PY -c "import zipfile, os; z=zipfile.ZipFile('vendor/ffmpeg/windows/ffmpeg-win.zip'); \
|
$$PY -c "import zipfile, shutil; z=zipfile.ZipFile('vendor/ffmpeg/windows/ffmpeg-win.zip'); \
|
||||||
wanted=['/bin/ffmpeg.exe','/bin/ffprobe.exe','/LICENSE.txt','/COPYING.LGPLv3']; \
|
members=[n for n in z.namelist() if n.endswith('/bin/ffmpeg.exe')]; \
|
||||||
members=[n for n in z.namelist() if any(n.endswith(w) for w in wanted)]; \
|
assert members, 'ffmpeg.exe not found in zip'; \
|
||||||
assert any(n.endswith('/bin/ffmpeg.exe') for n in members), 'ffmpeg.exe not found in zip'; \
|
src=z.open(members[0]); dst=open('vendor/ffmpeg/windows/ffmpeg.exe','wb'); \
|
||||||
assert any(n.endswith('/bin/ffprobe.exe') for n in members), 'ffprobe.exe not found in zip'; \
|
shutil.copyfileobj(src, dst); src.close(); dst.close(); z.close()" || { echo "ERROR: python 解壓失敗"; exit 1; }; \
|
||||||
os.makedirs('vendor/ffmpeg/windows', exist_ok=True); \
|
|
||||||
[open('vendor/ffmpeg/windows/'+m.rsplit('/',1)[1],'wb').write(z.read(m)) for m in members]; \
|
|
||||||
print('extracted:', [m.rsplit('/',1)[1] for m in members])" || { echo "ERROR: python 解壓失敗"; exit 1; }; \
|
|
||||||
rm -f vendor/ffmpeg/windows/ffmpeg-win.zip; \
|
rm -f vendor/ffmpeg/windows/ffmpeg-win.zip; \
|
||||||
[ -f vendor/ffmpeg/windows/ffmpeg.exe ] || { echo "ERROR: ffmpeg.exe 沒被寫出"; exit 1; }; \
|
[ -f vendor/ffmpeg/windows/ffmpeg.exe ] || { echo "ERROR: ffmpeg.exe 沒被寫出"; exit 1; }; \
|
||||||
[ -f vendor/ffmpeg/windows/ffprobe.exe ] || { echo "ERROR: ffprobe.exe 沒被寫出"; exit 1; }; \
|
echo "==> ffmpeg.exe 大小:$$(du -sh vendor/ffmpeg/windows/ffmpeg.exe | cut -f1)"; \
|
||||||
echo "==> ffmpeg.exe 大小:$$(du -h vendor/ffmpeg/windows/ffmpeg.exe | cut -f1)"; \
|
|
||||||
echo "==> ffprobe.exe 大小:$$(du -h vendor/ffmpeg/windows/ffprobe.exe | cut -f1)"; \
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
payload-windows: build-server-windows vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows ## 準備 Windows payload → payload/windows/
|
vendor-ytdlp-windows: ## 下載 yt-dlp.exe → vendor/yt-dlp/windows/
|
||||||
@echo "==> 建立 Windows payload (binary + models + scripts + python + wheels + ffmpeg)..."
|
@mkdir -p vendor/yt-dlp/windows
|
||||||
|
@if [ ! -f vendor/yt-dlp/windows/yt-dlp.exe ]; then \
|
||||||
|
echo "==> 下載 yt-dlp.exe..."; \
|
||||||
|
curl -fL -o vendor/yt-dlp/windows/yt-dlp.exe "$(YTDLP_URL_WINDOWS)"; \
|
||||||
|
echo "==> yt-dlp.exe 大小:$$(du -sh vendor/yt-dlp/windows/yt-dlp.exe | cut -f1)"; \
|
||||||
|
else \
|
||||||
|
echo "==> yt-dlp.exe 已存在,跳過"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload-windows: build-server-windows vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows vendor-ytdlp-windows ## 準備 Windows payload → payload/windows/
|
||||||
|
@echo "==> 建立 Windows payload (binary + models + scripts + python + wheels + ffmpeg + yt-dlp)..."
|
||||||
@# 注意:不 rm -rf payload/windows,因為 build-server-windows 已先把 .exe 放進去
|
@# 注意:不 rm -rf payload/windows,因為 build-server-windows 已先把 .exe 放進去
|
||||||
mkdir -p payload/windows/bin payload/windows/data payload/windows/scripts payload/windows/python payload/windows/wheels
|
mkdir -p payload/windows/bin payload/windows/data payload/windows/scripts payload/windows/python payload/windows/wheels
|
||||||
@if [ ! -f payload/windows/bin/visiona-local-server.exe ]; then \
|
@if [ ! -f payload/windows/bin/visiona-local-server.exe ]; then \
|
||||||
@ -356,10 +304,7 @@ payload-windows: build-server-windows vendor-python-windows vendor-wheels-window
|
|||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
cp vendor/ffmpeg/windows/ffmpeg.exe payload/windows/bin/
|
cp vendor/ffmpeg/windows/ffmpeg.exe payload/windows/bin/
|
||||||
cp vendor/ffmpeg/windows/ffprobe.exe payload/windows/bin/
|
cp vendor/yt-dlp/windows/yt-dlp.exe payload/windows/bin/
|
||||||
@# LGPL 授權條款(BtbN build 自帶 LICENSE.txt;COPYING.LGPLv3 不一定在壓縮檔內,失敗不致命)
|
|
||||||
@cp vendor/ffmpeg/windows/LICENSE.txt payload/windows/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
|
|
||||||
@cp vendor/ffmpeg/windows/COPYING.LGPLv3 payload/windows/bin/ffmpeg-COPYING.LGPLv3 2>/dev/null || true
|
|
||||||
cp -R server/data/. payload/windows/data/
|
cp -R server/data/. payload/windows/data/
|
||||||
cp -R server/scripts/. payload/windows/scripts/
|
cp -R server/scripts/. payload/windows/scripts/
|
||||||
cp vendor/python/windows/python.tar.gz payload/windows/python/
|
cp vendor/python/windows/python.tar.gz payload/windows/python/
|
||||||
@ -383,8 +328,8 @@ stage-windows: payload-windows ## 將 payload/windows/ 放到 Wails build/window
|
|||||||
# payload-linux / vendor-*-linux 是 curl 下載,跨平台可跑(server 步驟除外)。
|
# payload-linux / vendor-*-linux 是 curl 下載,跨平台可跑(server 步驟除外)。
|
||||||
|
|
||||||
PBS_URL_LINUX := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-unknown-linux-gnu-install_only.tar.gz
|
PBS_URL_LINUX := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-unknown-linux-gnu-install_only.tar.gz
|
||||||
# LGPL v3 build(BtbN n7.1 穩定分支)— v2 TDD §4
|
FFMPEG_URL_LINUX := https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
||||||
FFMPEG_URL_LINUX := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz
|
YTDLP_URL_LINUX := https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux
|
||||||
|
|
||||||
vendor-python-linux: ## 下載 python-build-standalone Linux x86_64 → vendor/python/linux/
|
vendor-python-linux: ## 下載 python-build-standalone Linux x86_64 → vendor/python/linux/
|
||||||
@mkdir -p vendor/python/linux
|
@mkdir -p vendor/python/linux
|
||||||
@ -414,27 +359,38 @@ vendor-wheels-linux: ## 同步 Linux wheels → vendor/wheels/linux/
|
|||||||
@ls -1 vendor/wheels/linux/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
|
@ls -1 vendor/wheels/linux/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
|
||||||
@du -sh vendor/wheels/linux 2>/dev/null || true
|
@du -sh vendor/wheels/linux 2>/dev/null || true
|
||||||
|
|
||||||
vendor-ffmpeg-linux: ## 下載 ffmpeg Linux LGPL v3 build (n7.1) → vendor/ffmpeg/linux/
|
vendor-ffmpeg-linux: ## 下載 ffmpeg Linux static build → vendor/ffmpeg/linux/
|
||||||
@mkdir -p vendor/ffmpeg/linux
|
@mkdir -p vendor/ffmpeg/linux
|
||||||
@if [ -f vendor/ffmpeg/linux/ffmpeg ] && [ -f vendor/ffmpeg/linux/ffprobe ]; then \
|
@if [ -f vendor/ffmpeg/linux/ffmpeg ]; then \
|
||||||
echo "==> ffmpeg + ffprobe (Linux) 已存在,跳過"; \
|
echo "==> ffmpeg (Linux) 已存在,跳過 ($$(du -sh vendor/ffmpeg/linux/ffmpeg | cut -f1))"; \
|
||||||
else \
|
else \
|
||||||
echo "==> 下載 BtbN LGPL ffmpeg (Linux, n7.1)..."; \
|
echo "==> 下載 ffmpeg static build (Linux x86_64) from johnvansickle..."; \
|
||||||
|
echo "!! WARNING: johnvansickle build 為 GPL build;正式發佈前需改用 LGPL 來源 !!"; \
|
||||||
curl -fL -o /tmp/ffmpeg-linux.tar.xz "$(FFMPEG_URL_LINUX)"; \
|
curl -fL -o /tmp/ffmpeg-linux.tar.xz "$(FFMPEG_URL_LINUX)"; \
|
||||||
rm -rf /tmp/ffmpeg-linux-extract; \
|
|
||||||
mkdir -p /tmp/ffmpeg-linux-extract; \
|
mkdir -p /tmp/ffmpeg-linux-extract; \
|
||||||
tar xf /tmp/ffmpeg-linux.tar.xz -C /tmp/ffmpeg-linux-extract --strip-components=1; \
|
tar xf /tmp/ffmpeg-linux.tar.xz -C /tmp/ffmpeg-linux-extract --strip-components=1; \
|
||||||
cp /tmp/ffmpeg-linux-extract/bin/ffmpeg vendor/ffmpeg/linux/; \
|
cp /tmp/ffmpeg-linux-extract/ffmpeg vendor/ffmpeg/linux/; \
|
||||||
cp /tmp/ffmpeg-linux-extract/bin/ffprobe vendor/ffmpeg/linux/; \
|
chmod +x vendor/ffmpeg/linux/ffmpeg; \
|
||||||
cp /tmp/ffmpeg-linux-extract/LICENSE.txt vendor/ffmpeg/linux/LICENSE.txt 2>/dev/null || true; \
|
|
||||||
chmod +x vendor/ffmpeg/linux/ffmpeg vendor/ffmpeg/linux/ffprobe; \
|
|
||||||
rm -rf /tmp/ffmpeg-linux* ; \
|
rm -rf /tmp/ffmpeg-linux* ; \
|
||||||
echo "==> ffmpeg (Linux) 大小:$$(du -h vendor/ffmpeg/linux/ffmpeg | cut -f1)"; \
|
echo "==> ffmpeg (Linux) 大小:$$(du -sh vendor/ffmpeg/linux/ffmpeg | cut -f1)"; \
|
||||||
echo "==> ffprobe (Linux) 大小:$$(du -h vendor/ffmpeg/linux/ffprobe | cut -f1)"; \
|
if [ "$${VISIONA_ALLOW_GPL_FFMPEG:-0}" != "1" ]; then \
|
||||||
|
echo " ⚠️ 提醒:此 build 為 GPL,僅限本地驗收,發佈前請改用 LGPL build"; \
|
||||||
|
fi; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
payload-linux: build-server-linux vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux ## 準備 Linux payload → payload/linux/
|
vendor-ytdlp-linux: ## 下載 yt-dlp (Linux) → vendor/yt-dlp/linux/
|
||||||
@echo "==> 建立 Linux payload (binary + models + scripts + python + wheels + ffmpeg)..."
|
@mkdir -p vendor/yt-dlp/linux
|
||||||
|
@if [ ! -f vendor/yt-dlp/linux/yt-dlp ]; then \
|
||||||
|
echo "==> 下載 yt-dlp (Linux)..."; \
|
||||||
|
curl -fL -o vendor/yt-dlp/linux/yt-dlp "$(YTDLP_URL_LINUX)"; \
|
||||||
|
chmod +x vendor/yt-dlp/linux/yt-dlp; \
|
||||||
|
echo "==> yt-dlp (Linux) 大小:$$(du -sh vendor/yt-dlp/linux/yt-dlp | cut -f1)"; \
|
||||||
|
else \
|
||||||
|
echo "==> yt-dlp (Linux) 已存在,跳過"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload-linux: build-server-linux vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux vendor-ytdlp-linux ## 準備 Linux payload → payload/linux/
|
||||||
|
@echo "==> 建立 Linux payload (binary + models + scripts + python + wheels + ffmpeg + yt-dlp)..."
|
||||||
mkdir -p payload/linux/bin payload/linux/data payload/linux/scripts payload/linux/python payload/linux/wheels
|
mkdir -p payload/linux/bin payload/linux/data payload/linux/scripts payload/linux/python payload/linux/wheels
|
||||||
@if [ ! -f payload/linux/bin/visiona-local-server ]; then \
|
@if [ ! -f payload/linux/bin/visiona-local-server ]; then \
|
||||||
echo "!! ERROR: payload/linux/bin/visiona-local-server 不存在,build-server-linux 可能失敗 !!"; \
|
echo "!! ERROR: payload/linux/bin/visiona-local-server 不存在,build-server-linux 可能失敗 !!"; \
|
||||||
@ -442,8 +398,7 @@ payload-linux: build-server-linux vendor-python-linux vendor-wheels-linux vendor
|
|||||||
fi
|
fi
|
||||||
@chmod +x payload/linux/bin/visiona-local-server
|
@chmod +x payload/linux/bin/visiona-local-server
|
||||||
@cp vendor/ffmpeg/linux/ffmpeg payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffmpeg || echo "!! WARN: ffmpeg 缺失"
|
@cp vendor/ffmpeg/linux/ffmpeg payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffmpeg || echo "!! WARN: ffmpeg 缺失"
|
||||||
@cp vendor/ffmpeg/linux/ffprobe payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffprobe || echo "!! WARN: ffprobe 缺失"
|
@cp vendor/yt-dlp/linux/yt-dlp payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/yt-dlp || echo "!! WARN: yt-dlp 缺失"
|
||||||
@cp vendor/ffmpeg/linux/LICENSE.txt payload/linux/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
|
|
||||||
@if [ -d server/data ]; then cp -R server/data/. payload/linux/data/; fi
|
@if [ -d server/data ]; then cp -R server/data/. payload/linux/data/; fi
|
||||||
@if [ -d server/scripts ]; then cp -R server/scripts/. payload/linux/scripts/; fi
|
@if [ -d server/scripts ]; then cp -R server/scripts/. payload/linux/scripts/; fi
|
||||||
@cp vendor/python/linux/python.tar.gz payload/linux/python/ 2>/dev/null || echo "!! WARN: python tarball 缺失"
|
@cp vendor/python/linux/python.tar.gz payload/linux/python/ 2>/dev/null || echo "!! WARN: python tarball 缺失"
|
||||||
@ -577,6 +532,9 @@ appimage: wails-linux ## ⚠️ 必須在 Linux 上跑:build-appimage.sh → d
|
|||||||
dev:
|
dev:
|
||||||
@echo "TODO: make -j2 dev-server dev-frontend(開發模式)"
|
@echo "TODO: make -j2 dev-server dev-frontend(開發模式)"
|
||||||
|
|
||||||
|
dev-mock:
|
||||||
|
@echo "TODO: make -j2 dev-mock-server dev-frontend(Mock 模式)"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@echo "TODO: go test + pnpm test"
|
@echo "TODO: go test + pnpm test"
|
||||||
|
|
||||||
|
|||||||
@ -18,9 +18,9 @@ visionA-local 是 `edge-ai-platform`(原本要部署到 EC2 + Docker 的 Knero
|
|||||||
|
|
||||||
三個核心承諾:
|
三個核心承諾:
|
||||||
|
|
||||||
- 🎒 **零依賴**:Python runtime、KneronPLUS SDK、ffmpeg、預置 `.nef` 模型全部內嵌
|
- 🎒 **零依賴**:Python runtime、KneronPLUS SDK、ffmpeg、yt-dlp、預置 `.nef` 模型全部內嵌
|
||||||
- ✈️ **零網路**:下載一次後完全離線可用(適合客戶現場 IT 鎖得死緊的場景)
|
- ✈️ **零網路**:下載一次後完全離線可用(適合客戶現場 IT 鎖得死緊的場景)
|
||||||
- 🖱️ **零學習成本**:雙擊安裝 → 開啟 → 插上 Kneron 裝置 30 秒內跑出第一幀推論
|
- 🖱️ **零學習成本**:雙擊安裝 → 開啟 → Mock 模式 30 秒內跑出第一幀推論
|
||||||
|
|
||||||
對標產品:Docker Desktop、Ollama。
|
對標產品:Docker Desktop、Ollama。
|
||||||
|
|
||||||
@ -70,9 +70,10 @@ visionA-local 是 `edge-ai-platform`(原本要部署到 EC2 + Docker 的 Knero
|
|||||||
|
|
||||||
- **裝置管理**:USB 自動偵測 Kneron KL520 / KL720,10 秒內連線
|
- **裝置管理**:USB 自動偵測 Kneron KL520 / KL720,10 秒內連線
|
||||||
- **攝影機推論**:MJPEG 串流 + 即時 overlay(首次延遲 ≤ 250ms,穩定後 ≤ 150ms)
|
- **攝影機推論**:MJPEG 串流 + 即時 overlay(首次延遲 ≤ 250ms,穩定後 ≤ 150ms)
|
||||||
|
- **Mock 模式**:零硬體入口,產品經理、SA 也能拿來說故事
|
||||||
- **模型管理**:8 個預置 `.nef` 模型(分類 / 偵測 / 臉辨)+ 自上傳切換
|
- **模型管理**:8 個預置 `.nef` 模型(分類 / 偵測 / 臉辨)+ 自上傳切換
|
||||||
- **核心推論引擎**:image classification、object detection、face recognition
|
- **核心推論引擎**:image classification、object detection、face recognition
|
||||||
- **媒體推論**:支援圖片與影片檔(本機上傳,R5 決策後不支援 URL 推論)
|
- **媒體推論**:支援圖片、影片檔、URL(內嵌 yt-dlp)
|
||||||
- **中英雙語**,跟隨系統 Dark Mode
|
- **中英雙語**,跟隨系統 Dark Mode
|
||||||
|
|
||||||
### ❌ 不做的事(明確排除)
|
### ❌ 不做的事(明確排除)
|
||||||
@ -125,10 +126,10 @@ make help
|
|||||||
|
|
||||||
| Target | 作用 |
|
| Target | 作用 |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| `vendor-sync` | 下載 python-build-standalone、wheels、ffmpeg |
|
| `vendor-sync` | 下載 python-build-standalone、wheels、ffmpeg、yt-dlp |
|
||||||
| `build-server` | 編譯 Go server binary(先 build frontend + embed) |
|
| `build-server` | 編譯 Go server binary(先 build frontend + embed) |
|
||||||
| `build-frontend` | pnpm build Next.js 靜態產物 |
|
| `build-frontend` | pnpm build Next.js 靜態產物 |
|
||||||
| `payload-macos` | 準備 macOS payload(binary + python + wheels + ffmpeg + 模型) |
|
| `payload-macos` | 準備 macOS payload(binary + python + wheels + ffmpeg + yt-dlp + 模型) |
|
||||||
| `wails-macos` | Wails build + ad-hoc codesign |
|
| `wails-macos` | Wails build + ad-hoc codesign |
|
||||||
| `dmg` | 產出 `dist/visiona-local.dmg` |
|
| `dmg` | 產出 `dist/visiona-local.dmg` |
|
||||||
| `exe` | Windows installer(需在 Windows runner 執行) |
|
| `exe` | Windows installer(需在 Windows runner 執行) |
|
||||||
@ -160,6 +161,7 @@ make help
|
|||||||
|
|
||||||
## 已知限制與 TODO
|
## 已知限制與 TODO
|
||||||
|
|
||||||
|
- 🔴 **ffmpeg 目前是 GPL build**(含 `--enable-gpl --enable-libx264`),由 `VISIONA_ALLOW_GPL_FFMPEG=1` flag 放行本地驗收,**發佈前等法務 review**
|
||||||
- 🟡 **Kneron 預置模型 re-distribution 授權**:開發階段假設可用,正式發佈前需與 Kneron 官方確認
|
- 🟡 **Kneron 預置模型 re-distribution 授權**:開發階段假設可用,正式發佈前需與 Kneron 官方確認
|
||||||
- 🟡 **Windows / Linux 安裝檔**:build script 就緒,等 CI runner 齊備
|
- 🟡 **Windows / Linux 安裝檔**:build script 就緒,等 CI runner 齊備
|
||||||
- 🟡 **Apple Silicon** 未經測試(理論上 Rosetta 2 可跑)
|
- 🟡 **Apple Silicon** 未經測試(理論上 Rosetta 2 可跑)
|
||||||
@ -177,7 +179,8 @@ make help
|
|||||||
|
|
||||||
| 元件 | 授權 | 備註 |
|
| 元件 | 授權 | 備註 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| ffmpeg | **LGPL v3**(方案 B 混合:macOS 自 build decoder-only / Windows & Linux 用 BtbN n7.1 LGPL) | v2 TDD §2.2 |
|
| ffmpeg | **GPL**(目前使用 GPL build) | ⚠️ 法務 review pending |
|
||||||
|
| yt-dlp | Unlicense | — |
|
||||||
| KneronPLUS SDK | Kneron 商用條款 | 再次確認 re-distribution 權利 |
|
| KneronPLUS SDK | Kneron 商用條款 | 再次確認 re-distribution 權利 |
|
||||||
| python-build-standalone | MPL 2.0 / PSFL | — |
|
| python-build-standalone | MPL 2.0 / PSFL | — |
|
||||||
| Python 標準函式庫 | PSFL | — |
|
| Python 標準函式庫 | PSFL | — |
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import { ThemeSync } from "@/components/theme-sync";
|
|||||||
import { LangSync } from "@/components/lang-sync";
|
import { LangSync } from "@/components/lang-sync";
|
||||||
import { StoreHydration } from "@/components/store-hydration";
|
import { StoreHydration } from "@/components/store-hydration";
|
||||||
import { GuidedTour } from "@/components/guided-tour";
|
import { GuidedTour } from "@/components/guided-tour";
|
||||||
import { ServerOfflineOverlay } from "@/components/system/server-offline-overlay";
|
|
||||||
import { ShutdownWatcherMount } from "@/components/system/shutdown-watcher-mount";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -55,8 +53,6 @@ export default function RootLayout({
|
|||||||
<ThemeSync />
|
<ThemeSync />
|
||||||
<LangSync />
|
<LangSync />
|
||||||
<GuidedTour />
|
<GuidedTour />
|
||||||
<ShutdownWatcherMount />
|
|
||||||
<ServerOfflineOverlay />
|
|
||||||
<Toaster richColors position="bottom-right" />
|
<Toaster richColors position="bottom-right" />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -138,6 +138,25 @@ export default function SettingsPage() {
|
|||||||
<CardTitle className="text-base">{t('settings.hardware.title')}</CardTitle>
|
<CardTitle className="text-base">{t('settings.hardware.title')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('settings.hardware.runtimeMode')}</Label>
|
||||||
|
{/* TODO: 連接 backend GET /api/system/config 讀實際 mode,現階段只顯示預設值 real */}
|
||||||
|
<Select value="real" disabled>
|
||||||
|
<SelectTrigger className="w-[420px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="mock">{t('settings.hardware.runtimeModeMock')}</SelectItem>
|
||||||
|
<SelectItem value="real">{t('settings.hardware.runtimeModeReal')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('settings.hardware.runtimeModeHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('settings.hardware.pythonMode')}</Label>
|
<Label>{t('settings.hardware.pythonMode')}</Label>
|
||||||
<Input value={BUNDLED_PYTHON_PLACEHOLDER} readOnly className="bg-muted w-80" />
|
<Input value={BUNDLED_PYTHON_PLACEHOLDER} readOnly className="bg-muted w-80" />
|
||||||
|
|||||||
@ -19,13 +19,7 @@ export function CameraControls({ deviceId }: CameraControlsProps) {
|
|||||||
{t('camera.stopCamera')}
|
{t('camera.stopCamera')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button onClick={() => startPipeline(cameras[0]?.id ?? 'mock-cam-0', deviceId)}>
|
||||||
disabled={cameras.length === 0}
|
|
||||||
onClick={() => {
|
|
||||||
const firstCam = cameras[0]?.id;
|
|
||||||
if (firstCam) startPipeline(firstCam, deviceId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('camera.startCamera')}
|
{t('camera.startCamera')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { useCameraStore } from '@/stores/camera-store';
|
import { useCameraStore } from '@/stores/camera-store';
|
||||||
import { useTranslation } from '@/lib/i18n';
|
import { useTranslation } from '@/lib/i18n';
|
||||||
@ -24,6 +25,7 @@ export function SourceSelector({ deviceId }: SourceSelectorProps) {
|
|||||||
uploadImage,
|
uploadImage,
|
||||||
uploadVideo,
|
uploadVideo,
|
||||||
uploadBatchImages,
|
uploadBatchImages,
|
||||||
|
startFromUrl,
|
||||||
} = useCameraStore();
|
} = useCameraStore();
|
||||||
|
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@ -46,6 +48,8 @@ export function SourceSelector({ deviceId }: SourceSelectorProps) {
|
|||||||
setCameraDisabled(false);
|
setCameraDisabled(false);
|
||||||
}
|
}
|
||||||
}, [hasCameras, activeTab]);
|
}, [hasCameras, activeTab]);
|
||||||
|
const [videoMode, setVideoMode] = useState<'file' | 'url'>('file');
|
||||||
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const imageFileRef = useRef<HTMLInputElement>(null);
|
const imageFileRef = useRef<HTMLInputElement>(null);
|
||||||
const videoFileRef = useRef<HTMLInputElement>(null);
|
const videoFileRef = useRef<HTMLInputElement>(null);
|
||||||
@ -87,6 +91,12 @@ export function SourceSelector({ deviceId }: SourceSelectorProps) {
|
|||||||
if (videoFileRef.current) videoFileRef.current.value = '';
|
if (videoFileRef.current) videoFileRef.current.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUrlSubmit = async () => {
|
||||||
|
if (!videoUrl.trim()) return;
|
||||||
|
await startFromUrl(videoUrl.trim(), deviceId);
|
||||||
|
setVideoUrl('');
|
||||||
|
};
|
||||||
|
|
||||||
const sourceLabel =
|
const sourceLabel =
|
||||||
sourceType === 'camera' ? t('camera.camera') : sourceType === 'image' ? t('camera.image') : sourceType === 'batch_image' ? t('camera.batchImages') : t('camera.video');
|
sourceType === 'camera' ? t('camera.camera') : sourceType === 'image' ? t('camera.image') : sourceType === 'batch_image' ? t('camera.batchImages') : t('camera.video');
|
||||||
|
|
||||||
@ -173,23 +183,73 @@ export function SourceSelector({ deviceId }: SourceSelectorProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'video' && (
|
{activeTab === 'video' && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => videoFileRef.current?.click()}
|
<Button
|
||||||
disabled={isUploading}
|
variant={videoMode === 'file' ? 'default' : 'outline'}
|
||||||
>
|
size="sm"
|
||||||
{isUploading ? t('common.uploading') : t('camera.selectVideo')}
|
onClick={() => setVideoMode('file')}
|
||||||
</Button>
|
>
|
||||||
<span className="text-sm text-muted-foreground">
|
{t('camera.uploadFile')}
|
||||||
{t('camera.mp4AviMov')}
|
</Button>
|
||||||
</span>
|
<Button
|
||||||
<input
|
variant={videoMode === 'url' ? 'default' : 'outline'}
|
||||||
ref={videoFileRef}
|
size="sm"
|
||||||
type="file"
|
onClick={() => setVideoMode('url')}
|
||||||
accept=".mp4,.avi,.mov,.mpeg,.mpg"
|
>
|
||||||
className="hidden"
|
{t('camera.pasteUrl')}
|
||||||
onChange={handleVideoSelect}
|
</Button>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
{videoMode === 'file' ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => videoFileRef.current?.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
{isUploading ? t('common.uploading') : t('camera.selectVideo')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t('camera.mp4AviMov')}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={videoFileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".mp4,.avi,.mov"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleVideoSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder={t('camera.urlPlaceholder')}
|
||||||
|
value={videoUrl}
|
||||||
|
onChange={(e) => setVideoUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleUrlSubmit();
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleUrlSubmit}
|
||||||
|
disabled={isUploading || !videoUrl.trim()}
|
||||||
|
>
|
||||||
|
{isUploading ? t('common.loading') : t('common.start')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isUploading ? (
|
||||||
|
<p className="text-xs text-amber-600 animate-pulse">
|
||||||
|
正在解析影片連結,YouTube 影片可能需要 10-30 秒,請耐心等待...
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('camera.urlHelpText')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,162 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
|
||||||
import { useSystemStore, FAILURE_THRESHOLD } from '@/stores/system-store';
|
|
||||||
import { useTranslation, type TranslationKey } from '@/lib/i18n';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { retryServerHealth } from '@/hooks/use-shutdown-watcher';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <ServerOfflineOverlay>
|
|
||||||
*
|
|
||||||
* 對應 TDD v2 §2.6(M8-7)+ Design v2 §2-§9(server-offline-overlay.md)。
|
|
||||||
*
|
|
||||||
* 規格摘要:
|
|
||||||
* - 全螢幕半透明蓋層 + 中央卡片(不可關閉)
|
|
||||||
* - role="alertdialog" + aria-modal + aria-live="assertive" + focus trap
|
|
||||||
* - 副標依 offlineReason 動態變化(quit / restart / healthcheck-failed / unknown)
|
|
||||||
* - 沒有關閉按鈕(window.close() 對主動開的 tab 無效);改顯示 help text
|
|
||||||
* - 重試按鈕:手動觸發 health check
|
|
||||||
* - Active retry 模式由 use-shutdown-watcher 自動每 3 s 重打 boot-id
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function ServerOfflineOverlay() {
|
|
||||||
const forcedOffline = useSystemStore((s) => s.forcedOffline);
|
|
||||||
const failures = useSystemStore((s) => s.consecutiveFailures);
|
|
||||||
const offlineReason = useSystemStore((s) => s.offlineReason);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const show = forcedOffline || failures >= FAILURE_THRESHOLD;
|
|
||||||
const [retrying, setRetrying] = useState(false);
|
|
||||||
const retryButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Focus trap:overlay 顯示時把焦點推到重試按鈕上。
|
|
||||||
useEffect(() => {
|
|
||||||
if (!show) return;
|
|
||||||
retryButtonRef.current?.focus();
|
|
||||||
}, [show]);
|
|
||||||
|
|
||||||
// Focus trap:Tab 鍵循環只在卡片內元素之間移動。
|
|
||||||
useEffect(() => {
|
|
||||||
if (!show) return;
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key !== 'Tab') return;
|
|
||||||
const card = cardRef.current;
|
|
||||||
if (!card) return;
|
|
||||||
const focusable = card.querySelectorAll<HTMLElement>(
|
|
||||||
'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
|
|
||||||
);
|
|
||||||
if (focusable.length === 0) return;
|
|
||||||
const first = focusable[0];
|
|
||||||
const last = focusable[focusable.length - 1];
|
|
||||||
if (e.shiftKey && document.activeElement === first) {
|
|
||||||
e.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!e.shiftKey && document.activeElement === last) {
|
|
||||||
e.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', onKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', onKeyDown);
|
|
||||||
}, [show]);
|
|
||||||
|
|
||||||
if (!show) return null;
|
|
||||||
|
|
||||||
const handleRetry = async () => {
|
|
||||||
if (retrying) return;
|
|
||||||
setRetrying(true);
|
|
||||||
try {
|
|
||||||
await retryServerHealth();
|
|
||||||
} finally {
|
|
||||||
// 給使用者一點點視覺反饋,避免 spinner 一閃而過
|
|
||||||
setTimeout(() => setRetrying(false), 300);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 依 reason 決定副標
|
|
||||||
const subtitleKey: TranslationKey = ((): TranslationKey => {
|
|
||||||
switch (offlineReason) {
|
|
||||||
case 'quit':
|
|
||||||
return 'offline.subtitle.quit';
|
|
||||||
case 'restart':
|
|
||||||
return 'offline.subtitle.restart';
|
|
||||||
case 'healthcheck-failed':
|
|
||||||
return 'offline.subtitle.healthcheck';
|
|
||||||
default:
|
|
||||||
return 'offline.subtitle.healthcheck';
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alertdialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="server-offline-title"
|
|
||||||
aria-describedby="server-offline-subtitle"
|
|
||||||
aria-live="assertive"
|
|
||||||
className={cn(
|
|
||||||
'fixed inset-0 z-[9999] flex items-center justify-center',
|
|
||||||
'bg-black/55 backdrop-blur-sm',
|
|
||||||
'dark:bg-black/70',
|
|
||||||
)}
|
|
||||||
data-testid="server-offline-overlay"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={cardRef}
|
|
||||||
className={cn(
|
|
||||||
'mx-4 w-full max-w-md rounded-2xl border bg-background p-10 shadow-2xl',
|
|
||||||
'flex flex-col items-center text-center',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-destructive/10 dark:bg-destructive/15">
|
|
||||||
<AlertTriangle className="h-10 w-10 text-destructive" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 標題 */}
|
|
||||||
<h1
|
|
||||||
id="server-offline-title"
|
|
||||||
className="mb-3 text-2xl font-bold text-foreground"
|
|
||||||
>
|
|
||||||
{t('offline.title')}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* 副標 — 動態 reason */}
|
|
||||||
<p
|
|
||||||
id="server-offline-subtitle"
|
|
||||||
className="mb-6 text-[15px] leading-relaxed text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t(subtitleKey)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 重試按鈕 */}
|
|
||||||
<Button
|
|
||||||
ref={retryButtonRef}
|
|
||||||
size="lg"
|
|
||||||
className="mb-4 w-60"
|
|
||||||
onClick={handleRetry}
|
|
||||||
disabled={retrying}
|
|
||||||
data-testid="server-offline-retry"
|
|
||||||
>
|
|
||||||
{retrying ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
|
||||||
{t('offline.retrying')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>{t('offline.retryButton')}</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Help text — 取代 close button(window.close() 對主動開的 tab 無效)*/}
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{t('offline.helpText')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useShutdownWatcher } from '@/hooks/use-shutdown-watcher';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把 useShutdownWatcher 掛到 root layout 的 client wrapper。
|
|
||||||
*
|
|
||||||
* useShutdownWatcher 是 React hook,不能直接在 server component(layout.tsx)裡呼叫,
|
|
||||||
* 因此需要這個薄薄的 'use client' wrapper。元件本身不 render 任何 DOM。
|
|
||||||
*
|
|
||||||
* 對應 TDD v2 §2.6(M8-7)。
|
|
||||||
*/
|
|
||||||
export function ShutdownWatcherMount() {
|
|
||||||
useShutdownWatcher();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -1,358 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useSystemStore, FAILURE_THRESHOLD, type OfflineReason } from '@/stores/system-store';
|
|
||||||
import { getApiBaseUrl, getWsBaseUrl, getRelayToken } from '@/lib/constants';
|
|
||||||
import { getRelayHeaders, ensureRelayToken } from '@/lib/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* use-shutdown-watcher
|
|
||||||
*
|
|
||||||
* 對應 TDD v2 §2.6(M8-7)+ §2.6.2a(Minor 4 WebSocket 廣播)+ §12(M8-9 Boot-ID reload)。
|
|
||||||
*
|
|
||||||
* 偵測機制(三個獨立管道,任一觸發即標記 forcedOffline):
|
|
||||||
* 1. WebSocket `server:shutdown-imminent` 廣播 → 立即標記,依 reason 決定後續行為
|
|
||||||
* 2. WebSocket onclose(非 clean close)→ 立即標記為 healthcheck-failed
|
|
||||||
* 3. 被動 polling fallback:health check 連續 2 次失敗 → 標記為 healthcheck-failed
|
|
||||||
*
|
|
||||||
* 模式:
|
|
||||||
* - normal:10 s polling,server 正常時運轉
|
|
||||||
* - active retry:3 s polling,overlay 顯示後自動重試直到 server 回來
|
|
||||||
*
|
|
||||||
* Page Visibility:背景 tab 暫停 polling,回前景立即 probe 一次。
|
|
||||||
*
|
|
||||||
* 容錯:server 端 `/ws/system` endpoint 可能尚未實作(M8-4b 才會做),
|
|
||||||
* 因此 WebSocket 連線失敗時不應觸發 forcedOffline,僅靠 polling fallback。
|
|
||||||
*
|
|
||||||
* Boot-ID reload(M8-9):
|
|
||||||
* - 每次 /system/boot-id 成功回來後,將 response.bootId 丟給 store.checkAndUpdateBootId:
|
|
||||||
* • first → 首次載入,記下 bootId,markOnline
|
|
||||||
* • match → bootId 一致,markOnline
|
|
||||||
* • mismatch → bootId 變了,代表 server 已重啟 → force `window.location.reload()`
|
|
||||||
* - reload 前用 sessionStorage 記下「已針對此 bootId 觸發過 reload」flag,避免
|
|
||||||
* 異常情況(例如每次 poll bootId 都不同的 bug)造成 reload loop。
|
|
||||||
* 正常情況下 reload 後 store reset bootId=null,會走 first 路徑,不會誤觸發。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** sessionStorage key:記錄上次針對哪個 bootId 觸發過 reload,防 reload loop。 */
|
|
||||||
const RELOAD_LOOP_GUARD_KEY = 'visiona-reload-loop-guard';
|
|
||||||
|
|
||||||
const POLL_INTERVAL_NORMAL_MS = 10_000;
|
|
||||||
const POLL_INTERVAL_ACTIVE_RETRY_MS = 3_000;
|
|
||||||
const FETCH_TIMEOUT_MS = 3_000;
|
|
||||||
const WS_RECONNECT_DELAY_MS = 5_000;
|
|
||||||
const RESTART_DEFER_MS = 10_000;
|
|
||||||
|
|
||||||
interface BootIdResponse {
|
|
||||||
success: boolean;
|
|
||||||
data?: {
|
|
||||||
bootId: string;
|
|
||||||
startedAt: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShutdownImminentMessage {
|
|
||||||
type: 'server:shutdown-imminent';
|
|
||||||
reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit';
|
|
||||||
ts?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把 server WebSocket reason 對應到本地 store 的 OfflineReason。 */
|
|
||||||
function mapReason(serverReason: ShutdownImminentMessage['reason']): OfflineReason {
|
|
||||||
switch (serverReason) {
|
|
||||||
case 'quit':
|
|
||||||
case 'app-closing':
|
|
||||||
return 'quit';
|
|
||||||
case 'manual-stop':
|
|
||||||
return 'quit';
|
|
||||||
case 'restart':
|
|
||||||
return 'restart';
|
|
||||||
default:
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 處理 boot-id 比對 + force reload(M8-9)。
|
|
||||||
*
|
|
||||||
* 回傳值:
|
|
||||||
* - `true` 代表呼叫端應繼續後續流程(first / match)
|
|
||||||
* - `false` 代表已觸發(或即將觸發)reload,呼叫端不需要再做任何事
|
|
||||||
*
|
|
||||||
* reload loop guard:用 sessionStorage 記下「上次針對哪個 bootId 觸發過 reload」。
|
|
||||||
* - 正常情況:reload 後 store.bootId=null → 走 first → 不會再 reload。
|
|
||||||
* - 異常情況(例如 server 每次回不同 bootId):sessionStorage 裡還是相同值
|
|
||||||
* → 跳過 reload 只 markOnline,避免 reload loop 癱瘓使用者介面。
|
|
||||||
*/
|
|
||||||
function handleBootIdCheck(newBootId: string): boolean {
|
|
||||||
const store = useSystemStore.getState();
|
|
||||||
const result = store.checkAndUpdateBootId(newBootId);
|
|
||||||
if (result === 'first' || result === 'match') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// mismatch:server 重啟,force reload。
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
// SSR / 測試環境保險:不做 reload,只標示不再繼續處理。
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lastGuard = window.sessionStorage.getItem(RELOAD_LOOP_GUARD_KEY);
|
|
||||||
if (lastGuard === newBootId) {
|
|
||||||
// 同一個 bootId 已經觸發過 reload,避免 loop:改為接受這個 bootId + markOnline。
|
|
||||||
console.warn(
|
|
||||||
'[boot-id] skip reload: already reloaded for this bootId this session',
|
|
||||||
newBootId,
|
|
||||||
);
|
|
||||||
store.setBootId(newBootId);
|
|
||||||
store.markOnline();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
window.sessionStorage.setItem(RELOAD_LOOP_GUARD_KEY, newBootId);
|
|
||||||
} catch {
|
|
||||||
// sessionStorage 可能在隱私模式等情境下失敗;失敗時仍然執行 reload。
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info('[boot-id] mismatch detected, force reloading tab', {
|
|
||||||
oldBootId: store.bootId,
|
|
||||||
newBootId,
|
|
||||||
});
|
|
||||||
window.location.reload();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打一次 boot-id;成功 → 比對 bootId(可能觸發 reload)+ markOnline;失敗 → recordFailure。 */
|
|
||||||
async function pollOnce(): Promise<void> {
|
|
||||||
const store = useSystemStore.getState();
|
|
||||||
try {
|
|
||||||
await ensureRelayToken();
|
|
||||||
const res = await fetch(`${getApiBaseUrl()}/system/boot-id`, {
|
|
||||||
cache: 'no-store',
|
|
||||||
headers: getRelayHeaders(),
|
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
store.recordFailure();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const json = (await res.json()) as BootIdResponse;
|
|
||||||
if (!json.success || !json.data?.bootId) {
|
|
||||||
store.recordFailure();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 成功:先比對 bootId(M8-9),若 mismatch → reload,直接結束;
|
|
||||||
// first / match → markOnline。
|
|
||||||
const shouldContinue = handleBootIdCheck(json.data.bootId);
|
|
||||||
if (!shouldContinue) return;
|
|
||||||
store.markOnline();
|
|
||||||
} catch {
|
|
||||||
store.recordFailure();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useShutdownWatcher(): void {
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let currentMode: 'normal' | 'active-retry' = 'normal';
|
|
||||||
let ws: WebSocket | null = null;
|
|
||||||
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let wsEverConnected = false; // 只有曾經連上才把 onclose 視為「server 斷了」
|
|
||||||
let cancelled = false;
|
|
||||||
let restartDeferTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const intervalFor = (mode: 'normal' | 'active-retry') =>
|
|
||||||
mode === 'active-retry' ? POLL_INTERVAL_ACTIVE_RETRY_MS : POLL_INTERVAL_NORMAL_MS;
|
|
||||||
|
|
||||||
const startPolling = (mode: 'normal' | 'active-retry') => {
|
|
||||||
if (intervalHandle !== null && currentMode === mode) return;
|
|
||||||
if (intervalHandle !== null) clearInterval(intervalHandle);
|
|
||||||
currentMode = mode;
|
|
||||||
intervalHandle = setInterval(() => {
|
|
||||||
void pollOnce();
|
|
||||||
}, intervalFor(mode));
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopPolling = () => {
|
|
||||||
if (intervalHandle !== null) {
|
|
||||||
clearInterval(intervalHandle);
|
|
||||||
intervalHandle = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 訂閱 store:當 forcedOffline / consecutiveFailures 改變時,自動切 polling 模式
|
|
||||||
const unsubscribe = useSystemStore.subscribe((state) => {
|
|
||||||
const shouldActiveRetry =
|
|
||||||
state.forcedOffline || state.consecutiveFailures >= FAILURE_THRESHOLD;
|
|
||||||
const targetMode: 'normal' | 'active-retry' = shouldActiveRetry ? 'active-retry' : 'normal';
|
|
||||||
// 連續失敗達到門檻但尚未 forcedOffline → 由 hook 補上 forceOffline
|
|
||||||
if (
|
|
||||||
!state.forcedOffline &&
|
|
||||||
state.consecutiveFailures >= FAILURE_THRESHOLD &&
|
|
||||||
state.serverOnline !== false
|
|
||||||
) {
|
|
||||||
useSystemStore.getState().forceOffline('healthcheck-failed');
|
|
||||||
}
|
|
||||||
if (intervalHandle !== null && targetMode !== currentMode) {
|
|
||||||
startPolling(targetMode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── WebSocket 訂閱 ───────────────────────────────────────────
|
|
||||||
// server 端 `/ws/system` 可能尚未實作(M8-4b),此處需容錯:
|
|
||||||
// - 連不上 → onerror / onclose(wsEverConnected=false)→ 不觸發 forcedOffline
|
|
||||||
// - 連上後再斷 → 視為 server 真的掛了 → 觸發 forcedOffline
|
|
||||||
const connectWs = () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
try {
|
|
||||||
const token = getRelayToken();
|
|
||||||
let wsUrl = `${getWsBaseUrl()}/ws/system`;
|
|
||||||
if (token) {
|
|
||||||
wsUrl += `?token=${encodeURIComponent(token)}`;
|
|
||||||
}
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
} catch {
|
|
||||||
scheduleWsReconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
wsEverConnected = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(String(ev.data)) as Partial<ShutdownImminentMessage>;
|
|
||||||
if (msg?.type !== 'server:shutdown-imminent') return;
|
|
||||||
const serverReason = (msg as ShutdownImminentMessage).reason;
|
|
||||||
const reason = mapReason(serverReason);
|
|
||||||
|
|
||||||
if (reason === 'restart') {
|
|
||||||
// restart:延遲 10 s 才顯示 overlay,期間若 polling 成功(bootId 變)→ M8-9 reload
|
|
||||||
if (restartDeferTimer !== null) clearTimeout(restartDeferTimer);
|
|
||||||
restartDeferTimer = setTimeout(() => {
|
|
||||||
const s = useSystemStore.getState();
|
|
||||||
if (!s.serverOnline || s.consecutiveFailures >= FAILURE_THRESHOLD) {
|
|
||||||
s.forceOffline('restart');
|
|
||||||
}
|
|
||||||
}, RESTART_DEFER_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// quit / unknown:立即顯示
|
|
||||||
useSystemStore.getState().forceOffline(reason);
|
|
||||||
} catch {
|
|
||||||
// 非 JSON / 格式錯誤 → 忽略
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (ev) => {
|
|
||||||
const wasEver = wsEverConnected;
|
|
||||||
ws = null;
|
|
||||||
// 只有「曾經連上後又斷」才視為 server 真的掛了;
|
|
||||||
// 若從未連上(server 端尚未實作 /ws/system),不觸發 forcedOffline。
|
|
||||||
if (wasEver && !ev.wasClean) {
|
|
||||||
// 立即觸發一次 polling 確認,若失敗才會被 polling 機制處理
|
|
||||||
void pollOnce();
|
|
||||||
}
|
|
||||||
scheduleWsReconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
// 忽略 — onclose 會接續處理
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleWsReconnect = () => {
|
|
||||||
if (cancelled || wsReconnectTimer !== null) return;
|
|
||||||
wsReconnectTimer = setTimeout(() => {
|
|
||||||
wsReconnectTimer = null;
|
|
||||||
if (!cancelled && document.visibilityState === 'visible') {
|
|
||||||
connectWs();
|
|
||||||
}
|
|
||||||
}, WS_RECONNECT_DELAY_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Page Visibility ───────────────────────────────────────────
|
|
||||||
const onVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
void pollOnce();
|
|
||||||
const s = useSystemStore.getState();
|
|
||||||
const mode: 'normal' | 'active-retry' =
|
|
||||||
s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD ? 'active-retry' : 'normal';
|
|
||||||
startPolling(mode);
|
|
||||||
if (ws === null && wsReconnectTimer === null) {
|
|
||||||
connectWs();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stopPolling();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── 初始啟動 ───────────────────────────────────────────────
|
|
||||||
void pollOnce();
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
startPolling('normal');
|
|
||||||
connectWs();
|
|
||||||
}
|
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
||||||
|
|
||||||
// ─── 清理 ───────────────────────────────────────────────
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
||||||
unsubscribe();
|
|
||||||
stopPolling();
|
|
||||||
if (wsReconnectTimer !== null) {
|
|
||||||
clearTimeout(wsReconnectTimer);
|
|
||||||
wsReconnectTimer = null;
|
|
||||||
}
|
|
||||||
if (restartDeferTimer !== null) {
|
|
||||||
clearTimeout(restartDeferTimer);
|
|
||||||
restartDeferTimer = null;
|
|
||||||
}
|
|
||||||
if (ws) {
|
|
||||||
ws.onclose = null;
|
|
||||||
ws.onerror = null;
|
|
||||||
ws.onmessage = null;
|
|
||||||
ws.close();
|
|
||||||
ws = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 匯出供 overlay 元件呼叫,手動 retry 按鈕用。 */
|
|
||||||
export async function retryServerHealth(): Promise<boolean> {
|
|
||||||
const store = useSystemStore.getState();
|
|
||||||
try {
|
|
||||||
await ensureRelayToken();
|
|
||||||
const res = await fetch(`${getApiBaseUrl()}/system/boot-id`, {
|
|
||||||
cache: 'no-store',
|
|
||||||
headers: getRelayHeaders(),
|
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
store.recordFailure();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const json = (await res.json()) as BootIdResponse;
|
|
||||||
if (!json.success || !json.data?.bootId) {
|
|
||||||
store.recordFailure();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// M8-9:手動 retry 也要比對 bootId,mismatch → reload。
|
|
||||||
const shouldContinue = handleBootIdCheck(json.data.bootId);
|
|
||||||
if (!shouldContinue) {
|
|
||||||
// 已觸發 reload(或在 SSR/guard 情境下被 skip):回傳 true 讓 overlay 視為成功。
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
store.markOnline();
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
store.recordFailure();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -67,7 +67,7 @@ export const en: TranslationDict = {
|
|||||||
subtitle: 'Manage your edge AI devices',
|
subtitle: 'Manage your edge AI devices',
|
||||||
scan: 'Scan Devices',
|
scan: 'Scan Devices',
|
||||||
scanning: 'Scanning...',
|
scanning: 'Scanning...',
|
||||||
noDevices: 'No Kneron devices detected. Please connect a KL520 / KL720 device and click "Scan".',
|
noDevices: 'No devices detected. Make sure mock mode is enabled or connect a device.',
|
||||||
type: 'Type',
|
type: 'Type',
|
||||||
firmware: 'Firmware',
|
firmware: 'Firmware',
|
||||||
flashedModel: 'Flashed Model',
|
flashedModel: 'Flashed Model',
|
||||||
@ -208,9 +208,13 @@ export const en: TranslationDict = {
|
|||||||
selectImage: 'Select Image',
|
selectImage: 'Select Image',
|
||||||
selectImages: 'Select Images',
|
selectImages: 'Select Images',
|
||||||
selectVideo: 'Select Video',
|
selectVideo: 'Select Video',
|
||||||
|
uploadFile: 'Upload File',
|
||||||
|
pasteUrl: 'Paste URL',
|
||||||
|
urlPlaceholder: 'https://example.com/video.mp4',
|
||||||
|
urlHelpText: 'Supports YouTube, direct video URL (.mp4 etc.), and RTSP stream.',
|
||||||
jpgPng: 'JPG, PNG',
|
jpgPng: 'JPG, PNG',
|
||||||
jpgPngMultiple: 'JPG, PNG (multiple)',
|
jpgPngMultiple: 'JPG, PNG (multiple)',
|
||||||
mp4AviMov: 'MP4 / AVI / MOV / MPEG / MPG',
|
mp4AviMov: 'MP4, AVI, MOV',
|
||||||
noInputSource: 'No input source',
|
noInputSource: 'No input source',
|
||||||
selectSourceHint: 'Select a camera, image, or video above',
|
selectSourceHint: 'Select a camera, image, or video above',
|
||||||
uploadedImage: 'Uploaded Image',
|
uploadedImage: 'Uploaded Image',
|
||||||
@ -267,6 +271,10 @@ export const en: TranslationDict = {
|
|||||||
},
|
},
|
||||||
hardware: {
|
hardware: {
|
||||||
title: 'Hardware',
|
title: 'Hardware',
|
||||||
|
runtimeMode: 'Runtime Mode',
|
||||||
|
runtimeModeMock: 'Mock (simulated devices, no hardware required — for development/testing)',
|
||||||
|
runtimeModeReal: 'Real Hardware (default — connects to actual Kneron devices)',
|
||||||
|
runtimeModeHint: 'Default is Real Hardware mode. To force Mock mode for development, set environment variable VISIONA_MOCK=1 before launching. Runtime switching will be available in a future release.',
|
||||||
pythonMode: 'Python Runtime',
|
pythonMode: 'Python Runtime',
|
||||||
pythonModeAuto: 'Auto (prefer bundled)',
|
pythonModeAuto: 'Auto (prefer bundled)',
|
||||||
pythonModeBundled: 'Bundled (recommended)',
|
pythonModeBundled: 'Bundled (recommended)',
|
||||||
@ -413,18 +421,8 @@ export const en: TranslationDict = {
|
|||||||
cannotStopStream: 'Cannot stop stream',
|
cannotStopStream: 'Cannot stop stream',
|
||||||
imageUploadFailed: 'Image upload failed',
|
imageUploadFailed: 'Image upload failed',
|
||||||
videoUploadFailed: 'Video upload failed',
|
videoUploadFailed: 'Video upload failed',
|
||||||
|
cannotOpenVideoUrl: 'Cannot open video URL',
|
||||||
batchUploadFailed: 'Batch image upload failed',
|
batchUploadFailed: 'Batch image upload failed',
|
||||||
unexpectedError: 'An unexpected error occurred',
|
unexpectedError: 'An unexpected error occurred',
|
||||||
},
|
},
|
||||||
offline: {
|
|
||||||
title: 'Local Server Offline',
|
|
||||||
subtitle: {
|
|
||||||
quit: 'visionA Local has stopped. Please relaunch the application.',
|
|
||||||
restart: 'Server is restarting, please wait…',
|
|
||||||
healthcheck: 'The server is not responding. Reconnecting…',
|
|
||||||
},
|
|
||||||
retryButton: 'Retry connection',
|
|
||||||
retrying: 'Retrying…',
|
|
||||||
helpText: 'To leave this page, simply close this browser tab.',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -206,6 +206,10 @@ export interface TranslationDict {
|
|||||||
selectImage: string;
|
selectImage: string;
|
||||||
selectImages: string;
|
selectImages: string;
|
||||||
selectVideo: string;
|
selectVideo: string;
|
||||||
|
uploadFile: string;
|
||||||
|
pasteUrl: string;
|
||||||
|
urlPlaceholder: string;
|
||||||
|
urlHelpText: string;
|
||||||
jpgPng: string;
|
jpgPng: string;
|
||||||
jpgPngMultiple: string;
|
jpgPngMultiple: string;
|
||||||
mp4AviMov: string;
|
mp4AviMov: string;
|
||||||
@ -265,6 +269,10 @@ export interface TranslationDict {
|
|||||||
};
|
};
|
||||||
hardware: {
|
hardware: {
|
||||||
title: string;
|
title: string;
|
||||||
|
runtimeMode: string;
|
||||||
|
runtimeModeMock: string;
|
||||||
|
runtimeModeReal: string;
|
||||||
|
runtimeModeHint: string;
|
||||||
pythonMode: string;
|
pythonMode: string;
|
||||||
pythonModeAuto: string;
|
pythonModeAuto: string;
|
||||||
pythonModeBundled: string;
|
pythonModeBundled: string;
|
||||||
@ -411,20 +419,10 @@ export interface TranslationDict {
|
|||||||
cannotStopStream: string;
|
cannotStopStream: string;
|
||||||
imageUploadFailed: string;
|
imageUploadFailed: string;
|
||||||
videoUploadFailed: string;
|
videoUploadFailed: string;
|
||||||
|
cannotOpenVideoUrl: string;
|
||||||
batchUploadFailed: string;
|
batchUploadFailed: string;
|
||||||
unexpectedError: string;
|
unexpectedError: string;
|
||||||
};
|
};
|
||||||
offline: {
|
|
||||||
title: string;
|
|
||||||
subtitle: {
|
|
||||||
quit: string;
|
|
||||||
restart: string;
|
|
||||||
healthcheck: string;
|
|
||||||
};
|
|
||||||
retryButton: string;
|
|
||||||
retrying: string;
|
|
||||||
helpText: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Join<K, P> = K extends string
|
type Join<K, P> = K extends string
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export const zhTW: TranslationDict = {
|
|||||||
subtitle: '管理你的 Edge AI 裝置',
|
subtitle: '管理你的 Edge AI 裝置',
|
||||||
scan: '掃描裝置',
|
scan: '掃描裝置',
|
||||||
scanning: '掃描中...',
|
scanning: '掃描中...',
|
||||||
noDevices: '未偵測到 Kneron 裝置。請連接 KL520 / KL720 後按「掃描」。',
|
noDevices: '未偵測到裝置。請確認已啟用 Mock 模式或連接裝置。',
|
||||||
type: '類型',
|
type: '類型',
|
||||||
firmware: '韌體',
|
firmware: '韌體',
|
||||||
flashedModel: '已燒錄模型',
|
flashedModel: '已燒錄模型',
|
||||||
@ -208,9 +208,13 @@ export const zhTW: TranslationDict = {
|
|||||||
selectImage: '選擇圖片',
|
selectImage: '選擇圖片',
|
||||||
selectImages: '選擇圖片',
|
selectImages: '選擇圖片',
|
||||||
selectVideo: '選擇影片',
|
selectVideo: '選擇影片',
|
||||||
|
uploadFile: '上傳檔案',
|
||||||
|
pasteUrl: '貼上連結',
|
||||||
|
urlPlaceholder: 'https://example.com/video.mp4',
|
||||||
|
urlHelpText: '支援 YouTube、直接影片 URL(.mp4 等)及 RTSP 串流。',
|
||||||
jpgPng: 'JPG, PNG',
|
jpgPng: 'JPG, PNG',
|
||||||
jpgPngMultiple: 'JPG, PNG(支援多選)',
|
jpgPngMultiple: 'JPG, PNG(支援多選)',
|
||||||
mp4AviMov: 'MP4 / AVI / MOV / MPEG / MPG',
|
mp4AviMov: 'MP4, AVI, MOV',
|
||||||
noInputSource: '無輸入來源',
|
noInputSource: '無輸入來源',
|
||||||
selectSourceHint: '請在上方選擇攝影機、圖片或影片',
|
selectSourceHint: '請在上方選擇攝影機、圖片或影片',
|
||||||
uploadedImage: '已上傳圖片',
|
uploadedImage: '已上傳圖片',
|
||||||
@ -267,6 +271,10 @@ export const zhTW: TranslationDict = {
|
|||||||
},
|
},
|
||||||
hardware: {
|
hardware: {
|
||||||
title: '硬體',
|
title: '硬體',
|
||||||
|
runtimeMode: '執行模式',
|
||||||
|
runtimeModeMock: 'Mock(模擬裝置,不需真實硬體 — 開發 / 測試用)',
|
||||||
|
runtimeModeReal: '真實硬體(預設 — 連接實體 Kneron 裝置)',
|
||||||
|
runtimeModeHint: '預設為真實硬體模式。若要強制使用 Mock 模式進行開發,啟動前設定環境變數 VISIONA_MOCK=1。未來版本會加入 UI 切換功能。',
|
||||||
pythonMode: 'Python 執行模式',
|
pythonMode: 'Python 執行模式',
|
||||||
pythonModeAuto: '自動(優先內嵌)',
|
pythonModeAuto: '自動(優先內嵌)',
|
||||||
pythonModeBundled: '內嵌(推薦)',
|
pythonModeBundled: '內嵌(推薦)',
|
||||||
@ -413,18 +421,8 @@ export const zhTW: TranslationDict = {
|
|||||||
cannotStopStream: '無法停止串流',
|
cannotStopStream: '無法停止串流',
|
||||||
imageUploadFailed: '圖片上傳失敗',
|
imageUploadFailed: '圖片上傳失敗',
|
||||||
videoUploadFailed: '影片上傳失敗',
|
videoUploadFailed: '影片上傳失敗',
|
||||||
|
cannotOpenVideoUrl: '無法開啟影片連結',
|
||||||
batchUploadFailed: '批次圖片上傳失敗',
|
batchUploadFailed: '批次圖片上傳失敗',
|
||||||
unexpectedError: '發生未預期的錯誤',
|
unexpectedError: '發生未預期的錯誤',
|
||||||
},
|
},
|
||||||
offline: {
|
|
||||||
title: 'Local Server 已離線',
|
|
||||||
subtitle: {
|
|
||||||
quit: 'visionA Local 已結束,請重新開啟應用程式。',
|
|
||||||
restart: 'Server 正在重新啟動,請稍候…',
|
|
||||||
healthcheck: '偵測到 Server 無回應,正在重試連線…',
|
|
||||||
},
|
|
||||||
retryButton: '重試連線',
|
|
||||||
retrying: '正在重試中…',
|
|
||||||
helpText: '如要離開本頁,請直接關閉此分頁。',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -29,6 +29,7 @@ interface CameraState {
|
|||||||
uploadImage: (file: File, deviceId: string) => Promise<void>;
|
uploadImage: (file: File, deviceId: string) => Promise<void>;
|
||||||
uploadVideo: (file: File, deviceId: string) => Promise<void>;
|
uploadVideo: (file: File, deviceId: string) => Promise<void>;
|
||||||
uploadBatchImages: (files: File[], deviceId: string) => Promise<void>;
|
uploadBatchImages: (files: File[], deviceId: string) => Promise<void>;
|
||||||
|
startFromUrl: (url: string, deviceId: string) => Promise<void>;
|
||||||
setBatchSelectedIndex: (index: number) => void;
|
setBatchSelectedIndex: (index: number) => void;
|
||||||
setBatchProgress: (count: number) => void;
|
setBatchProgress: (count: number) => void;
|
||||||
setVideoProgress: (frameIndex: number) => void;
|
setVideoProgress: (frameIndex: number) => void;
|
||||||
@ -163,6 +164,37 @@ export const useCameraStore = create<CameraState>((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
startFromUrl: async (url, deviceId) => {
|
||||||
|
set({ isUploading: true });
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ streamUrl: string; filename: string }>('/media/url', {
|
||||||
|
url,
|
||||||
|
deviceId,
|
||||||
|
});
|
||||||
|
if (res.success && res.data) {
|
||||||
|
const rawUrl = res.data.streamUrl.startsWith('http')
|
||||||
|
? res.data.streamUrl
|
||||||
|
: `${getBackendUrl()}${res.data.streamUrl}`;
|
||||||
|
const data = res.data as Record<string, unknown>;
|
||||||
|
set({
|
||||||
|
isStreaming: true,
|
||||||
|
streamUrl: appendRelayToken(rawUrl),
|
||||||
|
sourceType: 'video',
|
||||||
|
sourceFilename: res.data.filename || url,
|
||||||
|
videoTotalFrames: (data.totalFrames as number) || 0,
|
||||||
|
videoDurationSeconds: (data.durationSeconds as number) || 0,
|
||||||
|
videoFrameIndex: 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showApiError(res.error);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showError(getTranslation().t('errors.cannotOpenVideoUrl'));
|
||||||
|
} finally {
|
||||||
|
set({ isUploading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
uploadBatchImages: async (files, deviceId) => {
|
uploadBatchImages: async (files, deviceId) => {
|
||||||
set({ isUploading: true });
|
set({ isUploading: true });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System store — 全域系統健康狀態。
|
|
||||||
*
|
|
||||||
* 對應 TDD v2 §2.6(M8-7 Web UI Server-Offline Overlay)+ §9(M8-9 boot-id reload)。
|
|
||||||
*
|
|
||||||
* 本 store 的單一職責:保存「server 是否還活著」+「為什麼掛了」+「目前的 bootId」,
|
|
||||||
* 由 hook(use-shutdown-watcher)負責 polling、WebSocket 訂閱、計算失敗次數,
|
|
||||||
* 由 component(server-offline-overlay)負責呈現。
|
|
||||||
*
|
|
||||||
* M8-9:新增 `checkAndUpdateBootId` action,供 hook 在每次 health check 成功時
|
|
||||||
* 比對 bootId 判斷 server 是否已重啟,由 hook 決定是否觸發 force reload。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type OfflineReason = 'quit' | 'restart' | 'healthcheck-failed' | 'unknown';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* boot-id 比對結果:
|
|
||||||
* - `first` : store 尚未記錄 bootId,這是首次成功 health check,已記下。
|
|
||||||
* - `match` : 新舊 bootId 一致,server 沒重啟,一切正常。
|
|
||||||
* - `mismatch` : bootId 變化,代表 server 重啟(M8-9 會觸發 force reload)。
|
|
||||||
* 注意:mismatch 時不更新 store 裡的 bootId,讓 reload 後的新 tab
|
|
||||||
* 重走 `first` 路徑避免髒狀態。
|
|
||||||
*/
|
|
||||||
export type BootIdCheckResult = 'first' | 'match' | 'mismatch';
|
|
||||||
|
|
||||||
interface SystemState {
|
|
||||||
/** server 目前是否在線(由 polling / WebSocket 共同判定)。 */
|
|
||||||
serverOnline: boolean;
|
|
||||||
/** 是否被 server 主動 broadcast `shutdown-imminent` 強制標記為離線。 */
|
|
||||||
forcedOffline: boolean;
|
|
||||||
/** 為什麼離線;用於 overlay 動態文案。 */
|
|
||||||
offlineReason: OfflineReason | null;
|
|
||||||
/** server 啟動時產生的 bootId;給 M8-9 比對用,本 milestone 只負責保存。 */
|
|
||||||
bootId: string | null;
|
|
||||||
/** 連續 health check 失敗次數;達到門檻才顯示 overlay,避免抖動誤觸發。 */
|
|
||||||
consecutiveFailures: number;
|
|
||||||
|
|
||||||
/** 把 server 標記為離線(由 polling 連續失敗 / WebSocket close / shutdown-imminent 觸發)。 */
|
|
||||||
forceOffline: (reason: OfflineReason) => void;
|
|
||||||
/** 把 server 標記為在線(active retry / 手動重試成功觸發)。 */
|
|
||||||
markOnline: () => void;
|
|
||||||
/** 累計一次 health check 失敗。 */
|
|
||||||
recordFailure: () => void;
|
|
||||||
/** 設定 / 更新 bootId。 */
|
|
||||||
setBootId: (id: string) => void;
|
|
||||||
/**
|
|
||||||
* 比對 bootId(M8-9):
|
|
||||||
* - 若 store.bootId === null → 記下並回傳 'first'
|
|
||||||
* - 若相同 → 回傳 'match'
|
|
||||||
* - 若不同 → 回傳 'mismatch'(不更新 store,由呼叫端決定是否 reload)
|
|
||||||
*/
|
|
||||||
checkAndUpdateBootId: (newBootId: string) => BootIdCheckResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 連續失敗門檻:v2.1 定版 = 2 次(約 20 s),避免偶發網路抖動誤觸發。
|
|
||||||
* 由 hook 端計算 `consecutiveFailures >= FAILURE_THRESHOLD` 時呼叫 forceOffline。
|
|
||||||
*/
|
|
||||||
export const FAILURE_THRESHOLD = 2;
|
|
||||||
|
|
||||||
export const useSystemStore = create<SystemState>((set, get) => ({
|
|
||||||
serverOnline: true,
|
|
||||||
forcedOffline: false,
|
|
||||||
offlineReason: null,
|
|
||||||
bootId: null,
|
|
||||||
consecutiveFailures: 0,
|
|
||||||
|
|
||||||
forceOffline: (reason) =>
|
|
||||||
set({
|
|
||||||
serverOnline: false,
|
|
||||||
forcedOffline: true,
|
|
||||||
offlineReason: reason,
|
|
||||||
}),
|
|
||||||
|
|
||||||
markOnline: () =>
|
|
||||||
set({
|
|
||||||
serverOnline: true,
|
|
||||||
forcedOffline: false,
|
|
||||||
offlineReason: null,
|
|
||||||
consecutiveFailures: 0,
|
|
||||||
}),
|
|
||||||
|
|
||||||
recordFailure: () =>
|
|
||||||
set((s) => ({
|
|
||||||
consecutiveFailures: s.consecutiveFailures + 1,
|
|
||||||
})),
|
|
||||||
|
|
||||||
setBootId: (id) => set({ bootId: id }),
|
|
||||||
|
|
||||||
checkAndUpdateBootId: (newBootId) => {
|
|
||||||
const current = get().bootId;
|
|
||||||
if (current === null) {
|
|
||||||
set({ bootId: newBootId });
|
|
||||||
return 'first';
|
|
||||||
}
|
|
||||||
if (current === newBootId) {
|
|
||||||
return 'match';
|
|
||||||
}
|
|
||||||
// mismatch:刻意不更新 store,由呼叫端負責 force reload。
|
|
||||||
return 'mismatch';
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
||||||
import { useSystemStore } from '@/stores/system-store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* M8-9:use-shutdown-watcher 的 boot-id reload 行為測試。
|
|
||||||
*
|
|
||||||
* 透過 retryServerHealth(匯出可測)間接覆蓋 handleBootIdCheck 路徑:
|
|
||||||
* - 首次 fetch → setBootId,markOnline
|
|
||||||
* - 相同 bootId → markOnline,不 reload
|
|
||||||
* - 不同 bootId → 觸發 window.location.reload()
|
|
||||||
* - 已觸發過 reload(sessionStorage guard)→ 同一 bootId 不再 reload
|
|
||||||
*/
|
|
||||||
|
|
||||||
vi.mock('@/lib/api', () => ({
|
|
||||||
getRelayHeaders: () => ({}),
|
|
||||||
ensureRelayToken: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/constants', () => ({
|
|
||||||
getApiBaseUrl: () => 'http://localhost:3000/api',
|
|
||||||
getWsBaseUrl: () => 'ws://localhost:3000',
|
|
||||||
getRelayToken: () => 'test-token',
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { retryServerHealth } from '@/hooks/use-shutdown-watcher';
|
|
||||||
|
|
||||||
const RELOAD_LOOP_GUARD_KEY = 'visiona-reload-loop-guard';
|
|
||||||
|
|
||||||
function mockFetchBootId(bootId: string) {
|
|
||||||
return vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
success: true,
|
|
||||||
data: { bootId, startedAt: Date.now() },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('use-shutdown-watcher — boot-id reload (M8-9)', () => {
|
|
||||||
const reloadSpy = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
useSystemStore.setState({
|
|
||||||
serverOnline: true,
|
|
||||||
forcedOffline: false,
|
|
||||||
offlineReason: null,
|
|
||||||
bootId: null,
|
|
||||||
consecutiveFailures: 0,
|
|
||||||
});
|
|
||||||
window.sessionStorage.clear();
|
|
||||||
reloadSpy.mockClear();
|
|
||||||
|
|
||||||
// 替換 window.location.reload 為 spy(jsdom 的 location 非 configurable,
|
|
||||||
// 用 defineProperty 套一層替代物件)。
|
|
||||||
Object.defineProperty(window, 'location', {
|
|
||||||
configurable: true,
|
|
||||||
value: { ...window.location, reload: reloadSpy },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('first fetch should persist bootId and mark online without reload', async () => {
|
|
||||||
const fetchMock = mockFetchBootId('boot-1');
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const ok = await retryServerHealth();
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
expect(useSystemStore.getState().bootId).toBe('boot-1');
|
|
||||||
expect(useSystemStore.getState().serverOnline).toBe(true);
|
|
||||||
expect(reloadSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('second fetch with same bootId should just mark online', async () => {
|
|
||||||
useSystemStore.setState({ bootId: 'boot-1' });
|
|
||||||
const fetchMock = mockFetchBootId('boot-1');
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const ok = await retryServerHealth();
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
expect(useSystemStore.getState().bootId).toBe('boot-1');
|
|
||||||
expect(reloadSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetch with different bootId should trigger window.location.reload', async () => {
|
|
||||||
useSystemStore.setState({ bootId: 'boot-1' });
|
|
||||||
const fetchMock = mockFetchBootId('boot-2');
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const ok = await retryServerHealth();
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
|
||||||
// mismatch 不該更新 store 的 bootId(等 reload 後重走 first 路徑)
|
|
||||||
expect(useSystemStore.getState().bootId).toBe('boot-1');
|
|
||||||
// guard 應被設為新的 bootId
|
|
||||||
expect(window.sessionStorage.getItem(RELOAD_LOOP_GUARD_KEY)).toBe('boot-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not reload twice for the same new bootId (loop guard)', async () => {
|
|
||||||
useSystemStore.setState({ bootId: 'boot-1' });
|
|
||||||
window.sessionStorage.setItem(RELOAD_LOOP_GUARD_KEY, 'boot-2');
|
|
||||||
const fetchMock = mockFetchBootId('boot-2');
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const ok = await retryServerHealth();
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
expect(reloadSpy).not.toHaveBeenCalled();
|
|
||||||
// guard 命中時應該把 store 對齊新 bootId 並 markOnline,避免卡在假離線
|
|
||||||
expect(useSystemStore.getState().bootId).toBe('boot-2');
|
|
||||||
expect(useSystemStore.getState().serverOnline).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetch failure should record failure and not reload', async () => {
|
|
||||||
useSystemStore.setState({ bootId: 'boot-1' });
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const ok = await retryServerHealth();
|
|
||||||
expect(ok).toBe(false);
|
|
||||||
expect(reloadSpy).not.toHaveBeenCalled();
|
|
||||||
expect(useSystemStore.getState().consecutiveFailures).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { useSystemStore } from '@/stores/system-store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* M8-9:system-store 的 boot-id 比對行為測試。
|
|
||||||
*
|
|
||||||
* 覆蓋:
|
|
||||||
* - checkAndUpdateBootId 首次呼叫 → 'first',並把 bootId 記下
|
|
||||||
* - 相同 bootId → 'match',store 不變
|
|
||||||
* - 不同 bootId → 'mismatch',store 裡的 bootId 不被更新(保留舊值給呼叫端判斷)
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('SystemStore — boot-id check (M8-9)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
useSystemStore.setState({
|
|
||||||
serverOnline: true,
|
|
||||||
forcedOffline: false,
|
|
||||||
offlineReason: null,
|
|
||||||
bootId: null,
|
|
||||||
consecutiveFailures: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return "first" and persist bootId when store is empty', () => {
|
|
||||||
const result = useSystemStore.getState().checkAndUpdateBootId('boot-aaa');
|
|
||||||
expect(result).toBe('first');
|
|
||||||
expect(useSystemStore.getState().bootId).toBe('boot-aaa');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return "match" when the same bootId is seen again', () => {
|
|
||||||
useSystemStore.getState().setBootId('boot-aaa');
|
|
||||||
const result = useSystemStore.getState().checkAndUpdateBootId('boot-aaa');
|
|
||||||
expect(result).toBe('match');
|
|
||||||
expect(useSystemStore.getState().bootId).toBe('boot-aaa');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return "mismatch" and NOT update bootId when a new one arrives', () => {
|
|
||||||
useSystemStore.getState().setBootId('boot-aaa');
|
|
||||||
const result = useSystemStore.getState().checkAndUpdateBootId('boot-bbb');
|
|
||||||
expect(result).toBe('mismatch');
|
|
||||||
// 關鍵:mismatch 時 store 不該更新,等 reload 後新 tab 走 'first' 路徑。
|
|
||||||
expect(useSystemStore.getState().bootId).toBe('boot-aaa');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should treat second fresh call after reset as "first" again', () => {
|
|
||||||
useSystemStore.getState().checkAndUpdateBootId('boot-aaa');
|
|
||||||
// 模擬 reload 後 store 被 reset
|
|
||||||
useSystemStore.setState({ bootId: null });
|
|
||||||
const result = useSystemStore.getState().checkAndUpdateBootId('boot-bbb');
|
|
||||||
expect(result).toBe('first');
|
|
||||||
expect(useSystemStore.getState().bootId).toBe('boot-bbb');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -58,9 +58,8 @@ else
|
|||||||
echo "⚠️ payload/linux/bin/visiona-local-server 不存在(需要在 Linux 上 go build server)"
|
echo "⚠️ payload/linux/bin/visiona-local-server 不存在(需要在 Linux 上 go build server)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ffmpeg / ffprobe
|
# ffmpeg / yt-dlp
|
||||||
# ffmpeg 為 LGPL v3 build(BtbN n7.1,v2 TDD §4)
|
for tool in ffmpeg yt-dlp; do
|
||||||
for tool in ffmpeg ffprobe; do
|
|
||||||
if [ -f "$PAYLOAD_LINUX/bin/$tool" ]; then
|
if [ -f "$PAYLOAD_LINUX/bin/$tool" ]; then
|
||||||
cp "$PAYLOAD_LINUX/bin/$tool" "$APPDIR/usr/bin/$tool"
|
cp "$PAYLOAD_LINUX/bin/$tool" "$APPDIR/usr/bin/$tool"
|
||||||
else
|
else
|
||||||
@ -70,12 +69,6 @@ done
|
|||||||
|
|
||||||
chmod +x "$APPDIR/usr/bin/"* 2>/dev/null || true
|
chmod +x "$APPDIR/usr/bin/"* 2>/dev/null || true
|
||||||
|
|
||||||
# 把 ffmpeg 授權條款放到 AppImage 的 share/doc/
|
|
||||||
mkdir -p "$APPDIR/usr/share/doc/visiona-local"
|
|
||||||
if [ -f "$PAYLOAD_LINUX/bin/ffmpeg-LICENSE.txt" ]; then
|
|
||||||
cp "$PAYLOAD_LINUX/bin/ffmpeg-LICENSE.txt" "$APPDIR/usr/share/doc/visiona-local/ffmpeg-LICENSE.txt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> 複製資料、腳本、Python runtime、wheels"
|
echo "==> 複製資料、腳本、Python runtime、wheels"
|
||||||
[ -d "$PAYLOAD_LINUX/data" ] && cp -R "$PAYLOAD_LINUX/data/." "$APPDIR/usr/lib/visiona-local/data/" || true
|
[ -d "$PAYLOAD_LINUX/data" ] && cp -R "$PAYLOAD_LINUX/data/." "$APPDIR/usr/lib/visiona-local/data/" || true
|
||||||
[ -d "$PAYLOAD_LINUX/scripts" ] && cp -R "$PAYLOAD_LINUX/scripts/." "$APPDIR/usr/lib/visiona-local/scripts/" || true
|
[ -d "$PAYLOAD_LINUX/scripts" ] && cp -R "$PAYLOAD_LINUX/scripts/." "$APPDIR/usr/lib/visiona-local/scripts/" || true
|
||||||
|
|||||||
@ -71,12 +71,9 @@ Source: "..\..\visiona-local\build\bin\visiona-local.exe"; DestDir: "{app}"; Fla
|
|||||||
; ── Server binary(Go build,必須在 Windows runner 上 GOOS=windows go build)──
|
; ── Server binary(Go build,必須在 Windows runner 上 GOOS=windows go build)──
|
||||||
Source: "..\..\payload\windows\bin\visiona-local-server.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
|
Source: "..\..\payload\windows\bin\visiona-local-server.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
|
||||||
|
|
||||||
; ── ffmpeg + ffprobe ─────────────────────────────────────────────
|
; ── ffmpeg + yt-dlp ───────────────────────────────────────────────
|
||||||
; ffmpeg 為 LGPL v3 build(BtbN n7.1,v2 TDD §3),附上 LGPL 授權條款
|
|
||||||
Source: "..\..\payload\windows\bin\ffmpeg.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
|
Source: "..\..\payload\windows\bin\ffmpeg.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
|
||||||
Source: "..\..\payload\windows\bin\ffprobe.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
|
Source: "..\..\payload\windows\bin\yt-dlp.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
|
||||||
Source: "..\..\payload\windows\bin\ffmpeg-LICENSE.txt"; DestDir: "{app}\bin"; Flags: ignoreversion skipifsourcedoesntexist
|
|
||||||
Source: "..\..\payload\windows\bin\ffmpeg-COPYING.LGPLv3"; DestDir: "{app}\bin"; Flags: ignoreversion skipifsourcedoesntexist
|
|
||||||
|
|
||||||
; ── Python runtime tarball + wheels ───────────────────────────────
|
; ── Python runtime tarball + wheels ───────────────────────────────
|
||||||
Source: "..\..\payload\windows\python\python.tar.gz"; DestDir: "{app}\python"; Flags: ignoreversion
|
Source: "..\..\payload\windows\python\python.tar.gz"; DestDir: "{app}\python"; Flags: ignoreversion
|
||||||
|
|||||||
@ -55,9 +55,10 @@ fi
|
|||||||
wails doctor || log "wails doctor 有警告,繼續"
|
wails doctor || log "wails doctor 有警告,繼續"
|
||||||
|
|
||||||
log "[5/5] 開始 build(target=$TARGET)"
|
log "[5/5] 開始 build(target=$TARGET)"
|
||||||
log "ffmpeg 使用 LGPL v3 build(v2 TDD §4:BtbN n7.1 LGPL)"
|
log "⚠️ ffmpeg 使用 GPL build,需設定 VISIONA_ALLOW_GPL_FFMPEG=1"
|
||||||
|
export VISIONA_ALLOW_GPL_FFMPEG=1
|
||||||
|
|
||||||
make vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux
|
make vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux vendor-ytdlp-linux
|
||||||
make payload-linux
|
make payload-linux
|
||||||
case "$TARGET" in
|
case "$TARGET" in
|
||||||
payload-linux) ;;
|
payload-linux) ;;
|
||||||
|
|||||||
@ -68,7 +68,7 @@ if (-not (Test-Path 'C:\msys64\usr\bin\make.exe')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Log "[4/4] 開始 build(target=$Target)"
|
Log "[4/4] 開始 build(target=$Target)"
|
||||||
Log 'ffmpeg 使用 LGPL v3 build(v2 TDD §3:BtbN n7.1 LGPL)'
|
Log '⚠️ ffmpeg 使用 GPL build,需設定 VISIONA_ALLOW_GPL_FFMPEG=1'
|
||||||
|
|
||||||
# 讓 MSYS2 bash 繼承 Windows PATH(才找得到 go / pnpm / python / wails)
|
# 讓 MSYS2 bash 繼承 Windows PATH(才找得到 go / pnpm / python / wails)
|
||||||
$env:MSYS2_PATH_TYPE = 'inherit'
|
$env:MSYS2_PATH_TYPE = 'inherit'
|
||||||
@ -176,6 +176,7 @@ $msysPath = '/' + $projectPath.Substring(0,1).ToLower() + '/' + `
|
|||||||
|
|
||||||
$bashParts = @(
|
$bashParts = @(
|
||||||
"cd '$msysPath'",
|
"cd '$msysPath'",
|
||||||
|
'export VISIONA_ALLOW_GPL_FFMPEG=1',
|
||||||
"export VISIONA_PYTHON='$msysPython'"
|
"export VISIONA_PYTHON='$msysPython'"
|
||||||
)
|
)
|
||||||
if ($msysIsccDir) {
|
if ($msysIsccDir) {
|
||||||
@ -187,7 +188,7 @@ if ($msysIsccExe) {
|
|||||||
# Build 模式:
|
# Build 模式:
|
||||||
# VISIONA_FAST=1 → 前置產物齊全時跳過 vendor/payload/wails,只重跑 iscc(debug iteration 用)
|
# VISIONA_FAST=1 → 前置產物齊全時跳過 vendor/payload/wails,只重跑 iscc(debug iteration 用)
|
||||||
# 預設 → 每次 clean build(wails build / server binary / frontend embed 全重做)
|
# 預設 → 每次 clean build(wails build / server binary / frontend embed 全重做)
|
||||||
# 保留 vendor/ 快取(Python runtime / wheels / ffmpeg)以免重下
|
# 保留 vendor/ 快取(Python runtime / wheels / ffmpeg / yt-dlp)以免重下 200MB
|
||||||
$fastPath = (Test-Path 'visiona-local\build\bin\visiona-local.exe') -and `
|
$fastPath = (Test-Path 'visiona-local\build\bin\visiona-local.exe') -and `
|
||||||
(Test-Path 'payload\windows\bin\visiona-local-server.exe') -and `
|
(Test-Path 'payload\windows\bin\visiona-local-server.exe') -and `
|
||||||
(Test-Path 'payload\windows\bin\ffmpeg.exe') -and `
|
(Test-Path 'payload\windows\bin\ffmpeg.exe') -and `
|
||||||
@ -198,12 +199,12 @@ if ($env:VISIONA_FAST -eq '1' -and $fastPath -and ($Target -eq 'exe' -or -not $e
|
|||||||
$bashParts += 'make exe-only'
|
$bashParts += 'make exe-only'
|
||||||
} else {
|
} else {
|
||||||
Log '預設 clean build:每次重做 wails + server binary + frontend embed(vendor cache 保留)'
|
Log '預設 clean build:每次重做 wails + server binary + frontend embed(vendor cache 保留)'
|
||||||
# 清 wails / frontend / server/web/out,但不清 vendor/(避免重下 Python / wheels / ffmpeg)
|
# 清 wails / frontend / server/web/out,但不清 vendor/(避免重下 Python / wheels / ffmpeg / yt-dlp 200MB+)
|
||||||
$bashParts += 'rm -rf visiona-local/build/bin visiona-local/build/windows/Resources'
|
$bashParts += 'rm -rf visiona-local/build/bin visiona-local/build/windows/Resources'
|
||||||
$bashParts += 'rm -rf frontend/out frontend/.next server/web/out'
|
$bashParts += 'rm -rf frontend/out frontend/.next server/web/out'
|
||||||
$bashParts += 'rm -rf payload/windows/bin/visiona-local-server.exe'
|
$bashParts += 'rm -rf payload/windows/bin/visiona-local-server.exe'
|
||||||
$bashParts += 'rm -rf dist/visiona-local-*-windows-x64.exe'
|
$bashParts += 'rm -rf dist/visiona-local-*-windows-x64.exe'
|
||||||
$bashParts += 'make vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows'
|
$bashParts += 'make vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows vendor-ytdlp-windows'
|
||||||
$bashParts += 'make payload-windows'
|
$bashParts += 'make payload-windows'
|
||||||
switch ($Target) {
|
switch ($Target) {
|
||||||
'payload-windows' { }
|
'payload-windows' { }
|
||||||
|
|||||||
247
local-tool/server/internal/api/api_e2e_test.go
Normal file
247
local-tool/server/internal/api/api_e2e_test.go
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"visiona-local/server/internal/api"
|
||||||
|
"visiona-local/server/internal/api/handlers"
|
||||||
|
"visiona-local/server/internal/api/ws"
|
||||||
|
"visiona-local/server/internal/camera"
|
||||||
|
"visiona-local/server/internal/device"
|
||||||
|
"visiona-local/server/internal/inference"
|
||||||
|
"visiona-local/server/internal/model"
|
||||||
|
"visiona-local/server/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apiResponse is the standard JSON envelope for all API responses.
|
||||||
|
type apiResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
Error *struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTestServer creates a fully wired Gin router with mock devices.
|
||||||
|
func setupTestServer(t *testing.T, mockCount int) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
registry := device.NewRegistry()
|
||||||
|
deviceMgr := device.NewManager(registry, true, mockCount, "")
|
||||||
|
// Drain event bus to prevent blocking
|
||||||
|
go func() {
|
||||||
|
for range deviceMgr.Events() {
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
modelRepo := model.NewRepository("")
|
||||||
|
modelStore := model.NewModelStore(t.TempDir())
|
||||||
|
cameraMgr := camera.NewManager(true)
|
||||||
|
inferenceSvc := inference.NewService(deviceMgr)
|
||||||
|
wsHub := ws.NewHub()
|
||||||
|
logBroadcaster := logger.NewBroadcaster(100, nil)
|
||||||
|
sysHandler := handlers.NewSystemHandler("test", "now", nil)
|
||||||
|
|
||||||
|
router := api.NewRouter(
|
||||||
|
modelRepo, modelStore, deviceMgr, cameraMgr,
|
||||||
|
inferenceSvc, wsHub, nil, logBroadcaster, sysHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
return httptest.NewServer(router)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getJSON(t *testing.T, ts *httptest.Server, path string) apiResponse {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := http.Get(ts.URL + path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET %s: %v", path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var r apiResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||||
|
t.Fatalf("GET %s: decode error: %v", path, err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSON(t *testing.T, ts *httptest.Server, path string, body string) apiResponse {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST %s: %v", path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var r apiResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||||
|
t.Fatalf("POST %s: decode error: %v", path, err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHealthCheck(t *testing.T) {
|
||||||
|
ts := setupTestServer(t, 0)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/api/system/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("health check status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var data map[string]string
|
||||||
|
json.NewDecoder(resp.Body).Decode(&data)
|
||||||
|
if data["status"] != "ok" {
|
||||||
|
t.Fatalf("health check status = %q, want 'ok'", data["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceWorkflow_MockMode(t *testing.T) {
|
||||||
|
ts := setupTestServer(t, 2)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// 1. List devices — should have 2 mock devices
|
||||||
|
t.Run("list devices", func(t *testing.T) {
|
||||||
|
r := getJSON(t, ts, "/api/devices")
|
||||||
|
if !r.Success {
|
||||||
|
t.Fatal("list devices should succeed")
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Devices []json.RawMessage `json:"devices"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(r.Data, &data)
|
||||||
|
if len(data.Devices) != 2 {
|
||||||
|
t.Fatalf("expected 2 devices, got %d", len(data.Devices))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Get single device
|
||||||
|
t.Run("get device", func(t *testing.T) {
|
||||||
|
r := getJSON(t, ts, "/api/devices/mock-device-1")
|
||||||
|
if !r.Success {
|
||||||
|
t.Fatal("get device should succeed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Get non-existing device → 404
|
||||||
|
t.Run("get device not found", func(t *testing.T) {
|
||||||
|
r := getJSON(t, ts, "/api/devices/nonexistent")
|
||||||
|
if r.Success {
|
||||||
|
t.Fatal("should fail for non-existing device")
|
||||||
|
}
|
||||||
|
if r.Error == nil || r.Error.Code != "DEVICE_NOT_FOUND" {
|
||||||
|
t.Fatalf("expected DEVICE_NOT_FOUND error, got %+v", r.Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Connect device
|
||||||
|
t.Run("connect device", func(t *testing.T) {
|
||||||
|
r := postJSON(t, ts, "/api/devices/mock-device-1/connect", "")
|
||||||
|
if !r.Success {
|
||||||
|
t.Fatalf("connect should succeed: %+v", r.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify status changed to connected
|
||||||
|
r2 := getJSON(t, ts, "/api/devices/mock-device-1")
|
||||||
|
var info struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(r2.Data, &info)
|
||||||
|
if info.Status != "connected" {
|
||||||
|
t.Fatalf("expected status 'connected', got '%s'", info.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. Start inference
|
||||||
|
t.Run("start inference", func(t *testing.T) {
|
||||||
|
r := postJSON(t, ts, "/api/devices/mock-device-1/inference/start", "")
|
||||||
|
if !r.Success {
|
||||||
|
t.Fatalf("start inference should succeed: %+v", r.Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 6. Stop inference
|
||||||
|
t.Run("stop inference", func(t *testing.T) {
|
||||||
|
r := postJSON(t, ts, "/api/devices/mock-device-1/inference/stop", "")
|
||||||
|
if !r.Success {
|
||||||
|
t.Fatalf("stop inference should succeed: %+v", r.Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. Disconnect device
|
||||||
|
t.Run("disconnect device", func(t *testing.T) {
|
||||||
|
r := postJSON(t, ts, "/api/devices/mock-device-1/disconnect", "")
|
||||||
|
if !r.Success {
|
||||||
|
t.Fatalf("disconnect should succeed: %+v", r.Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceScan_MockMode(t *testing.T) {
|
||||||
|
ts := setupTestServer(t, 1)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// In mock mode, scan just returns existing mock devices
|
||||||
|
r := postJSON(t, ts, "/api/devices/scan", "")
|
||||||
|
if !r.Success {
|
||||||
|
t.Fatalf("scan should succeed: %+v", r.Error)
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Devices []json.RawMessage `json:"devices"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(r.Data, &data)
|
||||||
|
if len(data.Devices) != 1 {
|
||||||
|
t.Fatalf("expected 1 device after scan, got %d", len(data.Devices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelList(t *testing.T) {
|
||||||
|
ts := setupTestServer(t, 0)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
r := getJSON(t, ts, "/api/models")
|
||||||
|
if !r.Success {
|
||||||
|
t.Fatal("list models should succeed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectNonExistentDevice(t *testing.T) {
|
||||||
|
ts := setupTestServer(t, 1)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
r := postJSON(t, ts, "/api/devices/nonexistent/connect", "")
|
||||||
|
if r.Success {
|
||||||
|
t.Fatal("connect nonexistent device should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiDeviceIsolation(t *testing.T) {
|
||||||
|
ts := setupTestServer(t, 3)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Connect device 1 only
|
||||||
|
r := postJSON(t, ts, "/api/devices/mock-device-1/connect", "")
|
||||||
|
if !r.Success {
|
||||||
|
t.Fatalf("connect device 1 should succeed: %+v", r.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device 2 should still be detected (not connected)
|
||||||
|
r2 := getJSON(t, ts, "/api/devices/mock-device-2")
|
||||||
|
var info struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(r2.Data, &info)
|
||||||
|
if info.Status != "detected" {
|
||||||
|
t.Fatalf("device 2 should be 'detected', got '%s'", info.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -29,7 +30,8 @@ type CameraHandler struct {
|
|||||||
sourceType camera.SourceType
|
sourceType camera.SourceType
|
||||||
|
|
||||||
// Video seek state — preserved across seek operations
|
// Video seek state — preserved across seek operations
|
||||||
videoPath string // original file path
|
videoPath string // original file path or resolved URL
|
||||||
|
videoIsURL bool // true if source is a URL
|
||||||
videoFPS float64 // target FPS
|
videoFPS float64 // target FPS
|
||||||
videoInfo camera.VideoInfo // duration, total frames
|
videoInfo camera.VideoInfo // duration, total frames
|
||||||
activeDeviceID string // device ID for current video session
|
activeDeviceID string // device ID for current video session
|
||||||
@ -246,8 +248,8 @@ func (h *CameraHandler) UploadVideo(c *gin.Context) {
|
|||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
if ext != ".mp4" && ext != ".avi" && ext != ".mov" && ext != ".mpeg" && ext != ".mpg" {
|
if ext != ".mp4" && ext != ".avi" && ext != ".mov" {
|
||||||
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "only MP4/AVI/MOV/MPEG/MPG files are supported"}})
|
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "only MP4/AVI/MOV files are supported"}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -300,6 +302,7 @@ func (h *CameraHandler) UploadVideo(c *gin.Context) {
|
|||||||
h.activeSource = videoSource
|
h.activeSource = videoSource
|
||||||
h.sourceType = camera.SourceVideo
|
h.sourceType = camera.SourceVideo
|
||||||
h.videoPath = tmpFile.Name()
|
h.videoPath = tmpFile.Name()
|
||||||
|
h.videoIsURL = false
|
||||||
h.videoFPS = 15
|
h.videoFPS = 15
|
||||||
h.videoInfo = videoInfo
|
h.videoInfo = videoInfo
|
||||||
h.activeDeviceID = deviceID
|
h.activeDeviceID = deviceID
|
||||||
@ -335,6 +338,164 @@ func (h *CameraHandler) UploadVideo(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ytdlpHosts lists hostnames where yt-dlp should be used to resolve the actual
|
||||||
|
// video stream URL before passing to ffmpeg.
|
||||||
|
var ytdlpHosts = map[string]bool{
|
||||||
|
"youtube.com": true, "www.youtube.com": true, "youtu.be": true, "m.youtube.com": true,
|
||||||
|
"vimeo.com": true, "www.vimeo.com": true,
|
||||||
|
"dailymotion.com": true, "www.dailymotion.com": true,
|
||||||
|
"twitch.tv": true, "www.twitch.tv": true,
|
||||||
|
"bilibili.com": true, "www.bilibili.com": true,
|
||||||
|
"tiktok.com": true, "www.tiktok.com": true,
|
||||||
|
"facebook.com": true, "www.facebook.com": true, "fb.watch": true,
|
||||||
|
"instagram.com": true, "www.instagram.com": true,
|
||||||
|
"twitter.com": true, "x.com": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
type urlKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
urlDirect urlKind = iota // direct video file or RTSP, pass to ffmpeg directly
|
||||||
|
urlYTDLP // needs yt-dlp to resolve first
|
||||||
|
urlBad // invalid or unsupported
|
||||||
|
)
|
||||||
|
|
||||||
|
// classifyVideoURL determines how to handle the given URL.
|
||||||
|
func classifyVideoURL(rawURL string) (urlKind, string) {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return urlBad, "Invalid URL format"
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := strings.ToLower(parsed.Scheme)
|
||||||
|
host := strings.ToLower(parsed.Hostname())
|
||||||
|
|
||||||
|
// RTSP streams — direct to ffmpeg
|
||||||
|
if scheme == "rtsp" || scheme == "rtsps" {
|
||||||
|
return urlDirect, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be http or https
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return urlBad, "Unsupported protocol: " + scheme + ". Use http, https, or rtsp."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known video platforms — use yt-dlp
|
||||||
|
if ytdlpHosts[host] {
|
||||||
|
return urlYTDLP, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else — pass directly to ffmpeg
|
||||||
|
return urlDirect, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartFromURL handles video/stream inference from a URL (HTTP, HTTPS, RTSP).
|
||||||
|
func (h *CameraHandler) StartFromURL(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": err.Error()}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.URL == "" {
|
||||||
|
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "url is required"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.DeviceID == "" {
|
||||||
|
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "deviceId is required"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify the URL
|
||||||
|
kind, reason := classifyVideoURL(req.URL)
|
||||||
|
if kind == urlBad {
|
||||||
|
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "UNSUPPORTED_URL", "message": reason}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For video platforms (YouTube, etc.), resolve actual stream URL via yt-dlp
|
||||||
|
videoURL := req.URL
|
||||||
|
if kind == urlYTDLP {
|
||||||
|
resolved, err := camera.ResolveWithYTDLP(req.URL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "URL_RESOLVE_FAILED", "message": "無法解析影片連結: " + err.Error()}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoURL = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
h.stopActivePipeline()
|
||||||
|
|
||||||
|
// Probe video info (duration, frame count) - may be slow for remote URLs
|
||||||
|
videoInfo := camera.ProbeVideoInfo(videoURL, 15)
|
||||||
|
|
||||||
|
// Create VideoSource from URL (ffmpeg supports HTTP/HTTPS/RTSP natively)
|
||||||
|
videoSource, err := camera.NewVideoSourceFromURL(videoURL, 15)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "URL_OPEN_FAILED", "message": err.Error()}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if videoInfo.TotalFrames > 0 {
|
||||||
|
videoSource.SetTotalFrames(videoInfo.TotalFrames)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get device driver
|
||||||
|
session, err := h.deviceMgr.GetDevice(req.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
videoSource.Close()
|
||||||
|
c.JSON(404, gin.H{"success": false, "error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resultCh := make(chan *driver.InferenceResult, 10)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
room := "inference:" + req.DeviceID
|
||||||
|
for result := range resultCh {
|
||||||
|
h.wsHub.BroadcastToRoom(room, result)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
h.activeSource = videoSource
|
||||||
|
h.sourceType = camera.SourceVideo
|
||||||
|
h.videoPath = videoURL
|
||||||
|
h.videoIsURL = true
|
||||||
|
h.videoFPS = 15
|
||||||
|
h.videoInfo = videoInfo
|
||||||
|
h.activeDeviceID = req.DeviceID
|
||||||
|
h.pipeline = camera.NewInferencePipeline(
|
||||||
|
videoSource,
|
||||||
|
camera.SourceVideo,
|
||||||
|
session.Driver,
|
||||||
|
h.streamer.FrameChannel(),
|
||||||
|
resultCh,
|
||||||
|
)
|
||||||
|
h.pipeline.Start()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-h.pipeline.Done()
|
||||||
|
close(resultCh)
|
||||||
|
h.wsHub.BroadcastToRoom("inference:"+req.DeviceID, map[string]interface{}{
|
||||||
|
"type": "pipeline_complete",
|
||||||
|
"sourceType": "video",
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
streamURL := "/api/camera/stream"
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": gin.H{
|
||||||
|
"streamUrl": streamURL,
|
||||||
|
"sourceType": "video",
|
||||||
|
"filename": req.URL,
|
||||||
|
"totalFrames": videoInfo.TotalFrames,
|
||||||
|
"durationSeconds": videoInfo.DurationSec,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// UploadBatchImages handles multiple image files for sequential batch inference.
|
// UploadBatchImages handles multiple image files for sequential batch inference.
|
||||||
func (h *CameraHandler) UploadBatchImages(c *gin.Context) {
|
func (h *CameraHandler) UploadBatchImages(c *gin.Context) {
|
||||||
h.stopActivePipeline()
|
h.stopActivePipeline()
|
||||||
@ -533,6 +694,7 @@ func (h *CameraHandler) stopActivePipeline() {
|
|||||||
h.activeSource = nil
|
h.activeSource = nil
|
||||||
h.sourceType = ""
|
h.sourceType = ""
|
||||||
h.videoPath = ""
|
h.videoPath = ""
|
||||||
|
h.videoIsURL = false
|
||||||
h.activeDeviceID = ""
|
h.activeDeviceID = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -563,7 +725,13 @@ func (h *CameraHandler) SeekVideo(c *gin.Context) {
|
|||||||
h.stopPipelineForSeek()
|
h.stopPipelineForSeek()
|
||||||
|
|
||||||
// Create new VideoSource with seek position
|
// Create new VideoSource with seek position
|
||||||
videoSource, err := camera.NewVideoSourceWithSeek(h.videoPath, h.videoFPS, req.TimeSeconds)
|
var videoSource *camera.VideoSource
|
||||||
|
var err error
|
||||||
|
if h.videoIsURL {
|
||||||
|
videoSource, err = camera.NewVideoSourceFromURLWithSeek(h.videoPath, h.videoFPS, req.TimeSeconds)
|
||||||
|
} else {
|
||||||
|
videoSource, err = camera.NewVideoSourceWithSeek(h.videoPath, h.videoFPS, req.TimeSeconds)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "SEEK_FAILED", "message": err.Error()}})
|
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "SEEK_FAILED", "message": err.Error()}})
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1,30 +1,15 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"visiona-local/server/internal/api/ws"
|
|
||||||
"visiona-local/server/internal/deps"
|
"visiona-local/server/internal/deps"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// shutdownNotifyBroadcaster 是 SystemHandler 呼叫 Hub.BroadcastToRoom 的抽象,
|
|
||||||
// 方便單元測試用 spy 注入。預設由 *ws.Hub 滿足。
|
|
||||||
type shutdownNotifyBroadcaster interface {
|
|
||||||
BroadcastToRoom(room string, data interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// shutdownNotifySleepDuration 是 ShutdownNotify 廣播後等 client 收訊息的時間。
|
|
||||||
// 對應 TDD v2/server-lifecycle.md §2.3(Minor 4)的 "best-effort" 設計:
|
|
||||||
// 我們不等待實際送達 ACK,只 sleep 固定時間,讓 write pump 有機會把 byte 推出去。
|
|
||||||
// 單元測試可 override 成 0 加速。
|
|
||||||
var shutdownNotifySleepDuration = 100 * time.Millisecond
|
|
||||||
|
|
||||||
type SystemHandler struct {
|
type SystemHandler struct {
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
version string
|
version string
|
||||||
@ -32,24 +17,9 @@ type SystemHandler struct {
|
|||||||
pythonBin string // 由 main.go 傳入,InstallDriver 會用到
|
pythonBin string // 由 main.go 傳入,InstallDriver 會用到
|
||||||
shutdownFn func()
|
shutdownFn func()
|
||||||
depsCache []deps.Dependency
|
depsCache []deps.Dependency
|
||||||
bootID string // M8-4:server 啟動時產生的 boot-id(32 字元 hex)
|
|
||||||
wsHub shutdownNotifyBroadcaster // MAJ-4 補丁:用於 shutdown-imminent 廣播
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newBootID 產生 32 字元 hex 字串(16 bytes 隨機)。
|
func NewSystemHandler(version, buildTime, pythonBin string, shutdownFn func()) *SystemHandler {
|
||||||
// 對應 TDD v2/server-lifecycle.md §9.1:用純標準庫 crypto/rand,不引 google/uuid。
|
|
||||||
func newBootID() string {
|
|
||||||
b := make([]byte, 16)
|
|
||||||
// crypto/rand.Read 在實務上不會失敗;即使失敗(回 zero bytes)仍然可用
|
|
||||||
_, _ = rand.Read(b)
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSystemHandler(version, buildTime, pythonBin string, shutdownFn func(), wsHub *ws.Hub) *SystemHandler {
|
|
||||||
var b shutdownNotifyBroadcaster
|
|
||||||
if wsHub != nil {
|
|
||||||
b = wsHub
|
|
||||||
}
|
|
||||||
return &SystemHandler{
|
return &SystemHandler{
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
version: version,
|
version: version,
|
||||||
@ -57,8 +27,6 @@ func NewSystemHandler(version, buildTime, pythonBin string, shutdownFn func(), w
|
|||||||
pythonBin: pythonBin,
|
pythonBin: pythonBin,
|
||||||
shutdownFn: shutdownFn,
|
shutdownFn: shutdownFn,
|
||||||
depsCache: deps.CheckAll(),
|
depsCache: deps.CheckAll(),
|
||||||
bootID: newBootID(),
|
|
||||||
wsHub: b,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,19 +34,6 @@ func (h *SystemHandler) HealthCheck(c *gin.Context) {
|
|||||||
c.JSON(200, gin.H{"status": "ok"})
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// BootID 回傳此 server process 啟動時產生的 boot-id。
|
|
||||||
// 瀏覽器 tab 每 10 秒 poll 一次,用於偵測 server 重啟 → 觸發 window.location.reload()。
|
|
||||||
// 對應 TDD v2/server-lifecycle.md §9。
|
|
||||||
func (h *SystemHandler) BootID(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"data": gin.H{
|
|
||||||
"bootId": h.bootID,
|
|
||||||
"startedAt": h.startTime.UnixMilli(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *SystemHandler) Info(c *gin.Context) {
|
func (h *SystemHandler) Info(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
@ -160,47 +115,6 @@ func (h *SystemHandler) InstallDriver(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShutdownNotify 發送 server:shutdown-imminent 廣播到 /ws/system 的所有 client。
|
|
||||||
//
|
|
||||||
// 路由:POST /api/system/shutdown-notify?reason=quit|restart
|
|
||||||
//
|
|
||||||
// 對應 TDD v2/server-lifecycle.md §2.3(Minor 4)與 v2/web-ui-offline-overlay.md §3.2a:
|
|
||||||
// Wails 在 ctrl.Stop() SIGTERM 之前先呼叫這個 endpoint,讓瀏覽器 tab 的 Offline Overlay
|
|
||||||
// 立即顯示,避免只靠 health polling 導致的 15 秒延遲 + race condition。
|
|
||||||
//
|
|
||||||
// 設計原則:best-effort
|
|
||||||
// - 沒有 WebSocket client 也回 200(失敗情境也視為正常)
|
|
||||||
// - reason 非 quit / restart 時視為 "unknown"(仍 broadcast 讓前端決定怎麼處理)
|
|
||||||
// - broadcast 完固定 sleep 100 ms,給 write pump 時間把 byte 真的推到 TCP socket
|
|
||||||
// (不等 ACK,server 馬上就要 SIGTERM 了)
|
|
||||||
//
|
|
||||||
// Request: POST /api/system/shutdown-notify?reason=quit
|
|
||||||
// Response 200: {"ok": true, "reason": "quit"}
|
|
||||||
func (h *SystemHandler) ShutdownNotify(c *gin.Context) {
|
|
||||||
reason := c.Query("reason")
|
|
||||||
switch reason {
|
|
||||||
case "quit", "restart":
|
|
||||||
// 正常路徑
|
|
||||||
default:
|
|
||||||
reason = "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.wsHub != nil {
|
|
||||||
payload := gin.H{
|
|
||||||
"type": "server:shutdown-imminent",
|
|
||||||
"reason": reason,
|
|
||||||
"ts": time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
h.wsHub.BroadcastToRoom("system", payload)
|
|
||||||
// 等 client 有時間把訊息 flush 出去
|
|
||||||
if shutdownNotifySleepDuration > 0 {
|
|
||||||
time.Sleep(shutdownNotifySleepDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "reason": reason})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *SystemHandler) Restart(c *gin.Context) {
|
func (h *SystemHandler) Restart(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"success": true, "data": gin.H{"message": "restarting"}})
|
c.JSON(200, gin.H{"success": true, "data": gin.H{"message": "restarting"}})
|
||||||
if f, ok := c.Writer.(http.Flusher); ok {
|
if f, ok := c.Writer.(http.Flusher); ok {
|
||||||
|
|||||||
@ -1,231 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
// system_handler_test.go — MAJ-4 補丁:shutdown-notify endpoint 單元測試
|
|
||||||
//
|
|
||||||
// 驗證 POST /api/system/shutdown-notify 的行為:
|
|
||||||
// 1. reason=quit → 200 + 廣播 payload.reason = "quit"
|
|
||||||
// 2. reason=restart → 200 + 廣播 payload.reason = "restart"
|
|
||||||
// 3. reason=invalid / 空 → 200 + 廣播 payload.reason = "unknown"
|
|
||||||
// 4. wsHub = nil → 仍回 200(不 panic)
|
|
||||||
//
|
|
||||||
// 用 spy broadcaster 代替 real *ws.Hub,避免測試需要真的 goroutine / channel。
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// spyBroadcaster 實作 shutdownNotifyBroadcaster,把每次呼叫紀錄起來供斷言。
|
|
||||||
type spyBroadcaster struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
calls []spyCall
|
|
||||||
}
|
|
||||||
|
|
||||||
type spyCall struct {
|
|
||||||
room string
|
|
||||||
data map[string]interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *spyBroadcaster) BroadcastToRoom(room string, data interface{}) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
// 把 gin.H 轉成 map[string]interface{} 方便比對
|
|
||||||
m := map[string]interface{}{}
|
|
||||||
switch v := data.(type) {
|
|
||||||
case gin.H:
|
|
||||||
for k, val := range v {
|
|
||||||
m[k] = val
|
|
||||||
}
|
|
||||||
case map[string]interface{}:
|
|
||||||
m = v
|
|
||||||
default:
|
|
||||||
// 最後一招:透過 JSON round-trip
|
|
||||||
b, _ := json.Marshal(data)
|
|
||||||
_ = json.Unmarshal(b, &m)
|
|
||||||
}
|
|
||||||
s.calls = append(s.calls, spyCall{room: room, data: m})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *spyBroadcaster) snapshot() []spyCall {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
out := make([]spyCall, len(s.calls))
|
|
||||||
copy(out, s.calls)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestHandler 組一個 SystemHandler 但用 spy broadcaster 替代 real Hub。
|
|
||||||
func newTestHandler(spy *spyBroadcaster) *SystemHandler {
|
|
||||||
h := &SystemHandler{
|
|
||||||
startTime: time.Now(),
|
|
||||||
version: "test",
|
|
||||||
buildTime: "test-build",
|
|
||||||
bootID: "test-boot-id",
|
|
||||||
}
|
|
||||||
if spy != nil {
|
|
||||||
h.wsHub = spy
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestRouter 建一個只掛 shutdown-notify 的最小 router。
|
|
||||||
func newTestRouter(h *SystemHandler) *gin.Engine {
|
|
||||||
r := gin.New()
|
|
||||||
r.POST("/api/system/shutdown-notify", h.ShutdownNotify)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// 整組測試前把 sleep 時間歸零,避免拖慢 test suite。
|
|
||||||
func withNoSleep(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
orig := shutdownNotifySleepDuration
|
|
||||||
shutdownNotifySleepDuration = 0
|
|
||||||
t.Cleanup(func() {
|
|
||||||
shutdownNotifySleepDuration = orig
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShutdownNotify_ReasonQuit(t *testing.T) {
|
|
||||||
withNoSleep(t)
|
|
||||||
|
|
||||||
spy := &spyBroadcaster{}
|
|
||||||
h := newTestHandler(spy)
|
|
||||||
r := newTestRouter(h)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=quit", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("want 200, got %d; body=%s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
||||||
t.Fatalf("bad json response: %v", err)
|
|
||||||
}
|
|
||||||
if !body.OK || body.Reason != "quit" {
|
|
||||||
t.Errorf("response fields wrong: %+v", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
calls := spy.snapshot()
|
|
||||||
if len(calls) != 1 {
|
|
||||||
t.Fatalf("want 1 broadcast, got %d", len(calls))
|
|
||||||
}
|
|
||||||
if calls[0].room != "system" {
|
|
||||||
t.Errorf("want room=system, got %q", calls[0].room)
|
|
||||||
}
|
|
||||||
if calls[0].data["type"] != "server:shutdown-imminent" {
|
|
||||||
t.Errorf("want type=server:shutdown-imminent, got %v", calls[0].data["type"])
|
|
||||||
}
|
|
||||||
if calls[0].data["reason"] != "quit" {
|
|
||||||
t.Errorf("want reason=quit, got %v", calls[0].data["reason"])
|
|
||||||
}
|
|
||||||
if _, ok := calls[0].data["ts"]; !ok {
|
|
||||||
t.Errorf("payload missing ts")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShutdownNotify_ReasonRestart(t *testing.T) {
|
|
||||||
withNoSleep(t)
|
|
||||||
|
|
||||||
spy := &spyBroadcaster{}
|
|
||||||
h := newTestHandler(spy)
|
|
||||||
r := newTestRouter(h)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=restart", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("want 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
calls := spy.snapshot()
|
|
||||||
if len(calls) != 1 || calls[0].data["reason"] != "restart" {
|
|
||||||
t.Fatalf("want 1 broadcast reason=restart, got %+v", calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShutdownNotify_ReasonInvalid(t *testing.T) {
|
|
||||||
withNoSleep(t)
|
|
||||||
|
|
||||||
cases := []string{"", "halt", "kill9", "QUIT" /* 大小寫不同應視為 unknown */}
|
|
||||||
for _, reasonQuery := range cases {
|
|
||||||
name := reasonQuery
|
|
||||||
if name == "" {
|
|
||||||
name = "empty"
|
|
||||||
}
|
|
||||||
t.Run("reason_"+name, func(t *testing.T) {
|
|
||||||
spy := &spyBroadcaster{}
|
|
||||||
h := newTestHandler(spy)
|
|
||||||
r := newTestRouter(h)
|
|
||||||
|
|
||||||
url := "/api/system/shutdown-notify"
|
|
||||||
if reasonQuery != "" {
|
|
||||||
url += "?reason=" + reasonQuery
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(http.MethodPost, url, nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("want 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
|
||||||
if body.Reason != "unknown" {
|
|
||||||
t.Errorf("want response.reason=unknown, got %q", body.Reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
calls := spy.snapshot()
|
|
||||||
if len(calls) != 1 {
|
|
||||||
t.Fatalf("want 1 broadcast, got %d", len(calls))
|
|
||||||
}
|
|
||||||
if calls[0].data["reason"] != "unknown" {
|
|
||||||
t.Errorf("want payload.reason=unknown, got %v", calls[0].data["reason"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShutdownNotify_NoHub(t *testing.T) {
|
|
||||||
withNoSleep(t)
|
|
||||||
|
|
||||||
// wsHub = nil 代表單元測試或啟動失敗情境。handler 不應 panic,必須回 200。
|
|
||||||
h := newTestHandler(nil)
|
|
||||||
r := newTestRouter(h)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=quit", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("want 200 even without hub, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShutdownNotify_DefaultSleepIsPositive 保護常數不被誤改為 0 在生產路徑上。
|
|
||||||
// 單元測試透過 withNoSleep 暫時設為 0,這裡只驗證原始預設值。
|
|
||||||
func TestShutdownNotify_DefaultSleepIsPositive(t *testing.T) {
|
|
||||||
if shutdownNotifySleepDuration <= 0 {
|
|
||||||
t.Errorf("shutdownNotifySleepDuration should be > 0 in production, got %v", shutdownNotifySleepDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,105 +2,28 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// allowedHosts 定義 CORS 白名單的 hostname。
|
|
||||||
// 任何 port 都允許,scheme 只允許 http(本機不可能是 https)。
|
|
||||||
//
|
|
||||||
// M8-8(TDD v2/cors-security.md §3.1):
|
|
||||||
// v2 模式下 UI 改在使用者瀏覽器中跑,server 同時暴露給其他瀏覽器分頁,
|
|
||||||
// 必須限定 cross-origin 來源在本機 loopback,避免惡意網站透過 CORS 攻擊。
|
|
||||||
var allowedHosts = map[string]bool{
|
|
||||||
"127.0.0.1": true,
|
|
||||||
"localhost": true,
|
|
||||||
"[::1]": true,
|
|
||||||
"::1": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// isAllowedOrigin 判斷 Origin header 是否屬於白名單。
|
|
||||||
//
|
|
||||||
// 合法例:http://127.0.0.1:3721 / http://localhost:3721 / http://[::1]:3721
|
|
||||||
// 不合法例:https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721
|
|
||||||
//
|
|
||||||
// 注意:
|
|
||||||
// - 空字串視為非白名單(呼叫端會自行決定 same-origin 路徑)。
|
|
||||||
// - "null"(local file、某些 sandboxed iframe)一律拒絕。
|
|
||||||
// - 只允許 http scheme,本機不會有 https。
|
|
||||||
func isAllowedOrigin(origin string) bool {
|
|
||||||
if origin == "" || origin == "null" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
u, err := url.Parse(origin)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if u.Scheme != "http" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
host := strings.ToLower(u.Hostname())
|
|
||||||
return allowedHosts[host]
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORSMiddleware 僅允許 127.0.0.1/localhost/::1 任意 port 的跨來源請求。
|
|
||||||
//
|
|
||||||
// 行為(M8-8 / TDD v2/cors-security.md §4.1):
|
|
||||||
//
|
|
||||||
// 1. Origin header 為空 → same-origin(瀏覽器 same-origin 不送 Origin)→ 直接放行;
|
|
||||||
// 若是 OPTIONS 預檢則回 204 即停(避免帶 ACA* 給沒人看的請求)。
|
|
||||||
// 2. Origin 在白名單 → 回完整 ACA* headers;OPTIONS → 204;其他方法 → 繼續執行 handler。
|
|
||||||
// 3. Origin 不在白名單:
|
|
||||||
// - state-changing 方法(POST/PUT/DELETE/PATCH/OPTIONS)→ 403 Forbidden,不回 ACA*。
|
|
||||||
// - 簡單讀取(GET/HEAD)→ 執行 handler 但不回 ACA*,瀏覽器 JS 讀不到 body。
|
|
||||||
//
|
|
||||||
// 為什麼 GET/HEAD 不直接擋:CORS 的設計就是讓 GET 可以執行(畢竟 `<img>`、`<script>` tag
|
|
||||||
// 也會送 GET),擋掉反而可能影響 same-origin 的 sub-resource。瀏覽器層的保護
|
|
||||||
// 是「不讓 JS 讀回應」,已足夠。對副作用操作我們強制走 POST + 不在白名單時 403。
|
|
||||||
func CORSMiddleware() gin.HandlerFunc {
|
func CORSMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
origin := c.GetHeader("Origin")
|
origin := c.GetHeader("Origin")
|
||||||
method := c.Request.Method
|
if origin != "" {
|
||||||
|
// In production, frontend is same-origin so browsers don't send Origin header.
|
||||||
// Same-origin 請求:瀏覽器 same-origin 不送 Origin,這條走最快路徑。
|
// In dev, Next.js on :3000 needs CORS to reach Go on :3721.
|
||||||
if origin == "" {
|
// Allow all origins since this is a local-first application.
|
||||||
if method == http.MethodOptions {
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isAllowedOrigin(origin) {
|
|
||||||
// 非白名單 Origin
|
|
||||||
// - state-changing 方法 → 403(嚴格擋)
|
|
||||||
// - GET/HEAD → 執行但不回 ACA*(瀏覽器層擋)
|
|
||||||
if method == http.MethodOptions ||
|
|
||||||
method == http.MethodPost ||
|
|
||||||
method == http.MethodPut ||
|
|
||||||
method == http.MethodDelete ||
|
|
||||||
method == http.MethodPatch {
|
|
||||||
c.AbortWithStatus(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 白名單 Origin:回完整 ACA* headers
|
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Relay-Token")
|
||||||
c.Header("Access-Control-Allow-Credentials", "true")
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
c.Header("Vary", "Origin")
|
|
||||||
|
|
||||||
if method == http.MethodOptions {
|
if c.Request.Method == http.MethodOptions {
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,201 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIsAllowedOrigin 驗證 CORS 白名單判斷邏輯(M8-8 / TDD §4.2)。
|
|
||||||
func TestIsAllowedOrigin(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
origin string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
// 白名單合法情境
|
|
||||||
{"http://127.0.0.1:3721", true},
|
|
||||||
{"http://127.0.0.1", true},
|
|
||||||
{"http://localhost:3000", true},
|
|
||||||
{"http://localhost:8080", true},
|
|
||||||
{"http://localhost", true},
|
|
||||||
{"http://[::1]:3721", true},
|
|
||||||
{"http://LOCALHOST:9999", true}, // hostname 應大小寫不敏感
|
|
||||||
|
|
||||||
// scheme 不對
|
|
||||||
{"https://127.0.0.1:3721", false},
|
|
||||||
{"https://localhost:3000", false},
|
|
||||||
{"ws://127.0.0.1:3721", false},
|
|
||||||
|
|
||||||
// hostname 不在白名單
|
|
||||||
{"http://192.168.1.5:3721", false},
|
|
||||||
{"http://example.com", false},
|
|
||||||
{"http://malicious.local", false},
|
|
||||||
{"http://127.0.0.1.evil.com", false}, // suffix 攻擊
|
|
||||||
{"http://evil-127.0.0.1.com", false},
|
|
||||||
|
|
||||||
// 特殊情境
|
|
||||||
{"", false},
|
|
||||||
{"null", false},
|
|
||||||
{"http://", false},
|
|
||||||
{"not-a-url", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
got := isAllowedOrigin(tc.origin)
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("isAllowedOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestRouter 建一台只掛 CORSMiddleware 的最小 router,用於測試 middleware 行為。
|
|
||||||
func newTestRouter() *gin.Engine {
|
|
||||||
r := gin.New()
|
|
||||||
r.Use(CORSMiddleware())
|
|
||||||
r.GET("/api/ping", func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
||||||
})
|
|
||||||
r.POST("/api/do", func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
||||||
})
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_AllowedOriginGET:白名單 Origin 的 GET 應回 200 且帶 ACA header。
|
|
||||||
func TestCORSMiddleware_AllowedOriginGET(t *testing.T) {
|
|
||||||
r := newTestRouter()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
|
||||||
req.Header.Set("Origin", "http://127.0.0.1:3000")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:3000" {
|
|
||||||
t.Errorf("ACA-Origin = %q, want http://127.0.0.1:3000", got)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
|
||||||
t.Errorf("ACA-Credentials = %q, want true", got)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Vary"); got != "Origin" {
|
|
||||||
t.Errorf("Vary = %q, want Origin", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_LocalhostAllowed:localhost 任意 port 都應放行。
|
|
||||||
func TestCORSMiddleware_LocalhostAllowed(t *testing.T) {
|
|
||||||
r := newTestRouter()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
|
||||||
req.Header.Set("Origin", "http://localhost:8080")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:8080" {
|
|
||||||
t.Errorf("ACA-Origin = %q, want http://localhost:8080", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_DisallowedOriginPOST:非白名單 Origin 的 POST 必須 403。
|
|
||||||
func TestCORSMiddleware_DisallowedOriginPOST(t *testing.T) {
|
|
||||||
r := newTestRouter()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/do", nil)
|
|
||||||
req.Header.Set("Origin", "https://example.com")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403", w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
||||||
t.Errorf("非白名單不應回 ACA-Origin,got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_DisallowedOriginGET:非白名單 GET 應該執行 handler 但不回 ACA。
|
|
||||||
func TestCORSMiddleware_DisallowedOriginGET(t *testing.T) {
|
|
||||||
r := newTestRouter()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
|
||||||
req.Header.Set("Origin", "http://malicious.local")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200 (handler 仍執行,瀏覽器層擋讀取)", w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
||||||
t.Errorf("非白名單不應回 ACA-Origin,got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_PreflightAllowed:白名單 Origin 的 OPTIONS preflight 應回 204 + 完整 headers。
|
|
||||||
func TestCORSMiddleware_PreflightAllowed(t *testing.T) {
|
|
||||||
r := newTestRouter()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
|
|
||||||
req.Header.Set("Origin", "http://127.0.0.1:9999")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
||||||
req.Header.Set("Access-Control-Request-Headers", "Content-Type")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNoContent {
|
|
||||||
t.Fatalf("status = %d, want 204", w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:9999" {
|
|
||||||
t.Errorf("ACA-Origin = %q, want http://127.0.0.1:9999", got)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
|
||||||
t.Errorf("ACA-Methods 不應為空")
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Headers"); got == "" {
|
|
||||||
t.Errorf("ACA-Headers 不應為空")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_PreflightDisallowed:非白名單 OPTIONS preflight 應 403,不回 ACA。
|
|
||||||
func TestCORSMiddleware_PreflightDisallowed(t *testing.T) {
|
|
||||||
r := newTestRouter()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
|
|
||||||
req.Header.Set("Origin", "http://evil.com")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403", w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
||||||
t.Errorf("非白名單不應回 ACA-Origin,got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCORSMiddleware_SameOrigin:沒帶 Origin(same-origin)應放行。
|
|
||||||
func TestCORSMiddleware_SameOrigin(t *testing.T) {
|
|
||||||
r := newTestRouter()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
||||||
t.Errorf("same-origin 不應回 ACA-Origin,got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -35,12 +35,6 @@ func NewRouter(
|
|||||||
// with one that also pushes to the WebSocket broadcaster.
|
// with one that also pushes to the WebSocket broadcaster.
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
// M8-4:跳過高頻輪詢 endpoint 的 access log(TDD v2/server-lifecycle.md §9.1a)。
|
|
||||||
// 瀏覽器每 10s poll 一次 boot-id;business code 也會定期輪詢 health,
|
|
||||||
// 若每次都寫 access log 會把 business log 淹沒。
|
|
||||||
//
|
|
||||||
// 注意:broadcasterLogger 是我們自製的 middleware,不會直接套用 gin.LoggerConfig,
|
|
||||||
// 因此 skip 邏輯在 broadcasterLogger 內部手動實作(見下方)。
|
|
||||||
r.Use(broadcasterLogger(logBroadcaster))
|
r.Use(broadcasterLogger(logBroadcaster))
|
||||||
r.Use(CORSMiddleware())
|
r.Use(CORSMiddleware())
|
||||||
|
|
||||||
@ -56,12 +50,8 @@ func NewRouter(
|
|||||||
api.GET("/system/info", systemHandler.Info)
|
api.GET("/system/info", systemHandler.Info)
|
||||||
api.GET("/system/metrics", systemHandler.Metrics)
|
api.GET("/system/metrics", systemHandler.Metrics)
|
||||||
api.GET("/system/deps", systemHandler.Deps)
|
api.GET("/system/deps", systemHandler.Deps)
|
||||||
api.GET("/system/boot-id", systemHandler.BootID) // M8-4:瀏覽器 tab 用於偵測 server 重啟
|
|
||||||
api.POST("/system/restart", systemHandler.Restart)
|
api.POST("/system/restart", systemHandler.Restart)
|
||||||
api.POST("/system/install-driver", systemHandler.InstallDriver)
|
api.POST("/system/install-driver", systemHandler.InstallDriver)
|
||||||
// MAJ-4 補丁:Wails shutdown / Restart 前廣播 server:shutdown-imminent
|
|
||||||
// 到 /ws/system,讓瀏覽器 tab 立即顯示 Offline Overlay。
|
|
||||||
api.POST("/system/shutdown-notify", systemHandler.ShutdownNotify)
|
|
||||||
|
|
||||||
// Models
|
// Models
|
||||||
api.GET("/models", modelHandler.ListModels)
|
api.GET("/models", modelHandler.ListModels)
|
||||||
@ -90,6 +80,7 @@ func NewRouter(
|
|||||||
api.POST("/media/upload/video", cameraHandler.UploadVideo)
|
api.POST("/media/upload/video", cameraHandler.UploadVideo)
|
||||||
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages)
|
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages)
|
||||||
api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
|
api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
|
||||||
|
api.POST("/media/url", cameraHandler.StartFromURL)
|
||||||
api.POST("/media/seek", cameraHandler.SeekVideo)
|
api.POST("/media/seek", cameraHandler.SeekVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,8 +89,6 @@ func NewRouter(
|
|||||||
r.GET("/ws/devices/:id/flash-progress", ws.FlashProgressHandler(wsHub))
|
r.GET("/ws/devices/:id/flash-progress", ws.FlashProgressHandler(wsHub))
|
||||||
r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc))
|
r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc))
|
||||||
r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster))
|
r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster))
|
||||||
// MAJ-4 補丁:/ws/system — server:shutdown-imminent 事件訂閱
|
|
||||||
r.GET("/ws/system", ws.SystemEventsHandler(wsHub))
|
|
||||||
|
|
||||||
// Embedded frontend static file serving (production mode)
|
// Embedded frontend static file serving (production mode)
|
||||||
if staticFS != nil {
|
if staticFS != nil {
|
||||||
@ -120,19 +109,9 @@ func NewRouter(
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcasterLoggerSkipPaths 列出不寫 access log 的 endpoint(M8-4 TDD §9.1a)。
|
|
||||||
// 這些 endpoint 被瀏覽器或業務 code 高頻輪詢,每次都寫 log 會把 log 噴滿。
|
|
||||||
var broadcasterLoggerSkipPaths = map[string]struct{}{
|
|
||||||
"/api/system/boot-id": {},
|
|
||||||
"/api/system/health": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
// broadcasterLogger is a Gin middleware that logs HTTP requests to both
|
// broadcasterLogger is a Gin middleware that logs HTTP requests to both
|
||||||
// stdout (like gin.Logger) and the WebSocket log broadcaster so that
|
// stdout (like gin.Logger) and the WebSocket log broadcaster so that
|
||||||
// request logs are visible in the frontend Settings page.
|
// request logs are visible in the frontend Settings page.
|
||||||
//
|
|
||||||
// M8-4:對 broadcasterLoggerSkipPaths 裡列出的 endpoint 不寫 log,
|
|
||||||
// 避免把 access log 淹沒(瀏覽器每 10s poll boot-id,health 被業務 code 高頻輪詢)。
|
|
||||||
func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
|
func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@ -141,11 +120,6 @@ func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
|
|||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
// M8-4:跳過高頻輪詢 endpoint(比對只看 path,不含 query)
|
|
||||||
if _, skip := broadcasterLoggerSkipPaths[path]; skip {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
status := c.Writer.Status()
|
status := c.Writer.Status()
|
||||||
method := c.Request.Method
|
method := c.Request.Method
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"visiona-local/server/internal/device"
|
"visiona-local/server/internal/device"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// upgrader 是所有 WS handler 共用的 gorilla upgrader。
|
|
||||||
// M8-8:CheckOrigin 改為 ws.CheckOrigin(loopback 白名單),
|
|
||||||
// 不再接受任意 Origin(v1 模式是 wails:// 才放行所有 origin)。
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
CheckOrigin: CheckOrigin,
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeviceEventsHandler(hub *Hub, deviceMgr *device.Manager) gin.HandlerFunc {
|
func DeviceEventsHandler(hub *Hub, deviceMgr *device.Manager) gin.HandlerFunc {
|
||||||
|
|||||||
@ -2,11 +2,7 @@ package ws
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
@ -27,25 +23,12 @@ type RoomMessage struct {
|
|||||||
Message []byte
|
Message []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hub 管理 WebSocket client 訂閱與訊息廣播。
|
|
||||||
//
|
|
||||||
// M8-4b:Hub 額外負責「第一個 client 連上時寫 sentinel file」,
|
|
||||||
// 讓 Wails 端的 StartupPipeline 知道階段 6(Wait for Web UI WebSocket)已完成。
|
|
||||||
// 詳細設計見 .autoflow/04-architecture/v2/startup-pipeline.md §3。
|
|
||||||
//
|
|
||||||
// dataDir 由 main.go 在初始化 Hub 後透過 SetStartupSentinel(dataDir) 注入。
|
|
||||||
// 若 dataDir 為空,sentinel 寫入會被跳過(單元測試或缺少資料目錄時的安全行為)。
|
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
rooms map[string]map[*Client]bool
|
rooms map[string]map[*Client]bool
|
||||||
register chan *Subscription
|
register chan *Subscription
|
||||||
unregister chan *Subscription
|
unregister chan *Subscription
|
||||||
broadcast chan *RoomMessage
|
broadcast chan *RoomMessage
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
// M8-4b: 啟動 sentinel file
|
|
||||||
sentinelDataDir string // <dataDir>,由 SetStartupSentinel 設定
|
|
||||||
sentinelOnce sync.Once // 確保只在「第一個」client 連上時寫一次
|
|
||||||
bootID string // 寫入 sentinel 內容供 debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHub() *Hub {
|
func NewHub() *Hub {
|
||||||
@ -54,49 +37,9 @@ func NewHub() *Hub {
|
|||||||
register: make(chan *Subscription, 10),
|
register: make(chan *Subscription, 10),
|
||||||
unregister: make(chan *Subscription, 10),
|
unregister: make(chan *Subscription, 10),
|
||||||
broadcast: make(chan *RoomMessage, 100),
|
broadcast: make(chan *RoomMessage, 100),
|
||||||
bootID: fmt.Sprintf("boot-%d", time.Now().UnixNano()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetStartupSentinel 設定 sentinel file 的根目錄。
|
|
||||||
// main.go 在 NewHub() 之後、Run() 之前呼叫一次,dataDir 應為完整路徑。
|
|
||||||
//
|
|
||||||
// 寫入路徑:<dataDir>/.first-ws-connected
|
|
||||||
// 內容:boot-id + timestamp(用於 debug,內容對 Wails 端的判斷沒有意義,存在即可)
|
|
||||||
//
|
|
||||||
// dataDir 為空字串時 sentinel 機制完全停用。
|
|
||||||
func (h *Hub) SetStartupSentinel(dataDir string) {
|
|
||||||
h.mu.Lock()
|
|
||||||
h.sentinelDataDir = dataDir
|
|
||||||
h.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeStartupSentinel 在第一個 WebSocket client 連上時呼叫一次。
|
|
||||||
// 由 sentinelOnce 確保只執行一次;後續連線完全 no-op。
|
|
||||||
//
|
|
||||||
// 寫入失敗不會 panic 也不會回 error:sentinel 是 best-effort 機制,
|
|
||||||
// 若 disk 滿/權限錯,Wails 端會走 hard timeout 路徑進 Error state。
|
|
||||||
func (h *Hub) writeStartupSentinel() {
|
|
||||||
h.sentinelOnce.Do(func() {
|
|
||||||
h.mu.RLock()
|
|
||||||
dir := h.sentinelDataDir
|
|
||||||
bootID := h.bootID
|
|
||||||
h.mu.RUnlock()
|
|
||||||
if dir == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path := filepath.Join(dir, ".first-ws-connected")
|
|
||||||
// 確保父目錄存在(dataDir 通常已存在,但保險起見)
|
|
||||||
_ = os.MkdirAll(dir, 0o755)
|
|
||||||
f, err := os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(f, "bootId=%s\nts=%d\n", bootID, time.Now().UnixMilli())
|
|
||||||
_ = f.Close()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) Run() {
|
func (h *Hub) Run() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@ -107,9 +50,6 @@ func (h *Hub) Run() {
|
|||||||
}
|
}
|
||||||
h.rooms[sub.Room][sub.Client] = true
|
h.rooms[sub.Room][sub.Client] = true
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
// M8-4b:第一次有 client 加入任何 room → 寫 sentinel file
|
|
||||||
// (sync.Once 保證後續呼叫 no-op)
|
|
||||||
h.writeStartupSentinel()
|
|
||||||
if sub.done != nil {
|
if sub.done != nil {
|
||||||
close(sub.done)
|
close(sub.done)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,132 +0,0 @@
|
|||||||
package ws
|
|
||||||
|
|
||||||
// hub_broadcast_test.go — MAJ-4 補丁:驗證 Hub.BroadcastToRoom 在 system room 的行為
|
|
||||||
//
|
|
||||||
// 涵蓋:
|
|
||||||
// 1. 多個 client 都收到同一則訊息
|
|
||||||
// 2. 空 room(無 client)不 panic、不 block
|
|
||||||
// 3. client send channel 滿時不 block hub(會 drop 該 client)
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// makeRegisteredClient 註冊一個 buffered send channel 的 dummy client 到指定 room。
|
|
||||||
func makeRegisteredClient(h *Hub, room string, bufSize int) *Client {
|
|
||||||
c := &Client{Send: make(chan []byte, bufSize)}
|
|
||||||
h.RegisterSync(&Subscription{Client: c, Room: room})
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHub_BroadcastToRoom_MultipleClients(t *testing.T) {
|
|
||||||
hub := NewHub()
|
|
||||||
go hub.Run()
|
|
||||||
|
|
||||||
c1 := makeRegisteredClient(hub, "system", 4)
|
|
||||||
c2 := makeRegisteredClient(hub, "system", 4)
|
|
||||||
c3 := makeRegisteredClient(hub, "system", 4)
|
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"type": "server:shutdown-imminent",
|
|
||||||
"reason": "quit",
|
|
||||||
"ts": int64(1234567890),
|
|
||||||
}
|
|
||||||
hub.BroadcastToRoom("system", payload)
|
|
||||||
|
|
||||||
for i, c := range []*Client{c1, c2, c3} {
|
|
||||||
select {
|
|
||||||
case msg := <-c.Send:
|
|
||||||
var got map[string]interface{}
|
|
||||||
if err := json.Unmarshal(msg, &got); err != nil {
|
|
||||||
t.Fatalf("client %d bad json: %v", i, err)
|
|
||||||
}
|
|
||||||
if got["type"] != "server:shutdown-imminent" || got["reason"] != "quit" {
|
|
||||||
t.Errorf("client %d wrong payload: %+v", i, got)
|
|
||||||
}
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatalf("client %d did not receive broadcast within 500ms", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHub_BroadcastToRoom_EmptyRoom(t *testing.T) {
|
|
||||||
hub := NewHub()
|
|
||||||
go hub.Run()
|
|
||||||
|
|
||||||
// 無 client 註冊在 "system" room → BroadcastToRoom 應該 no-op 且不 panic
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
hub.BroadcastToRoom("system", map[string]string{"type": "server:shutdown-imminent"})
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
// OK
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatalf("BroadcastToRoom with empty room blocked > 500ms")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHub_BroadcastToRoom_FullChannelDoesNotBlock(t *testing.T) {
|
|
||||||
hub := NewHub()
|
|
||||||
go hub.Run()
|
|
||||||
|
|
||||||
// buffer = 1 的 slow client,先塞滿 → hub 接著 broadcast 時會打到 default case
|
|
||||||
// (select 非 blocking send),hub goroutine 必須仍能繼續處理後續訊息。
|
|
||||||
//
|
|
||||||
// 同時註冊一個 healthy client 觀察:hub 沒被卡住的證據 = healthy client 仍能收到訊息。
|
|
||||||
slow := makeRegisteredClient(hub, "system", 1)
|
|
||||||
slow.Send <- []byte("pre-existing") // 塞滿 slow 的 buffer
|
|
||||||
|
|
||||||
healthy := makeRegisteredClient(hub, "system", 4)
|
|
||||||
|
|
||||||
// 先量 broadcast 的時間,若 hub 被 slow client 卡住,這行會 block 直到 test timeout
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
hub.BroadcastToRoom("system", map[string]string{"type": "server:shutdown-imminent"})
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
// OK — broadcast 沒 block
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatalf("BroadcastToRoom blocked — slow client 未被 drop")
|
|
||||||
}
|
|
||||||
|
|
||||||
// healthy client 必須收到訊息(證明 hub goroutine 沒被 slow client 卡住)
|
|
||||||
select {
|
|
||||||
case msg := <-healthy.Send:
|
|
||||||
var got map[string]interface{}
|
|
||||||
if err := json.Unmarshal(msg, &got); err != nil {
|
|
||||||
t.Fatalf("healthy client bad json: %v", err)
|
|
||||||
}
|
|
||||||
if got["type"] != "server:shutdown-imminent" {
|
|
||||||
t.Errorf("healthy client wrong payload: %+v", got)
|
|
||||||
}
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatalf("healthy client did not receive broadcast — hub 被 slow client 卡住")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 再次 broadcast 驗證 hub 持續工作(slow 已被 drop,broadcast 仍該回來)
|
|
||||||
done2 := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done2)
|
|
||||||
hub.BroadcastToRoom("system", map[string]string{"type": "server:shutdown-imminent", "n": "2"})
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-done2:
|
|
||||||
// OK
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatalf("second BroadcastToRoom blocked")
|
|
||||||
}
|
|
||||||
|
|
||||||
// healthy 應收到第二則
|
|
||||||
select {
|
|
||||||
case <-healthy.Send:
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatalf("healthy client 未收到第二則")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
package ws
|
|
||||||
|
|
||||||
// hub_sentinel_test.go — M8-4b:驗證 Hub 在第一個 client 連上時寫 sentinel file
|
|
||||||
//
|
|
||||||
// 測試對應 .autoflow/04-architecture/v2/startup-pipeline.md §3 階段 6。
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 模擬一個 dummy client(不需要真的 WebSocket 連線,Hub.Run 只關心 *Client pointer)
|
|
||||||
func dummyClient() *Client {
|
|
||||||
return &Client{Send: make(chan []byte, 1)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHub_StartupSentinel_WrittenOnFirstRegister(t *testing.T) {
|
|
||||||
dir, err := os.MkdirTemp("", "ws-hub-sentinel-*")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mkdtemp: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(dir)
|
|
||||||
|
|
||||||
hub := NewHub()
|
|
||||||
hub.SetStartupSentinel(dir)
|
|
||||||
go hub.Run()
|
|
||||||
|
|
||||||
// 第一個 client 加入任意 room
|
|
||||||
hub.RegisterSync(&Subscription{Client: dummyClient(), Room: "test-room"})
|
|
||||||
|
|
||||||
// sentinel 應該在 dir 下出現
|
|
||||||
path := filepath.Join(dir, ".first-ws-connected")
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
t.Fatalf("sentinel file 應該被寫入:%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHub_StartupSentinel_WrittenOnlyOnce(t *testing.T) {
|
|
||||||
dir, err := os.MkdirTemp("", "ws-hub-sentinel-once-*")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mkdtemp: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(dir)
|
|
||||||
|
|
||||||
hub := NewHub()
|
|
||||||
hub.SetStartupSentinel(dir)
|
|
||||||
go hub.Run()
|
|
||||||
|
|
||||||
// 第一個 client
|
|
||||||
hub.RegisterSync(&Subscription{Client: dummyClient(), Room: "test-room"})
|
|
||||||
path := filepath.Join(dir, ".first-ws-connected")
|
|
||||||
info1, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first sentinel: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等一點時間確保 mtime 會不同(如果有寫第二次)
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
// 第二、三個 client 加入
|
|
||||||
hub.RegisterSync(&Subscription{Client: dummyClient(), Room: "test-room"})
|
|
||||||
hub.RegisterSync(&Subscription{Client: dummyClient(), Room: "another-room"})
|
|
||||||
|
|
||||||
info2, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second stat: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !info1.ModTime().Equal(info2.ModTime()) {
|
|
||||||
t.Fatalf("sentinel file 不應該被重寫;mtime1=%v mtime2=%v", info1.ModTime(), info2.ModTime())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHub_StartupSentinel_DisabledWhenDataDirEmpty(t *testing.T) {
|
|
||||||
hub := NewHub()
|
|
||||||
// 不呼叫 SetStartupSentinel → sentinelDataDir 為空
|
|
||||||
go hub.Run()
|
|
||||||
|
|
||||||
// 加 client 不該 panic、也不該寫任何檔案
|
|
||||||
hub.RegisterSync(&Subscription{Client: dummyClient(), Room: "test-room"})
|
|
||||||
|
|
||||||
// 沒有路徑可以驗證,這個測試主要驗證「不 panic」即可
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CheckOrigin 決定 WebSocket upgrade 是否允許。
|
|
||||||
//
|
|
||||||
// M8-8 / TDD v2/cors-security.md §5:
|
|
||||||
// 與 HTTP CORS 白名單一致,只允許本機 loopback 來源:
|
|
||||||
// - http://127.0.0.1:* / http://localhost:* / http://[::1]:*
|
|
||||||
// - same-origin(Origin header 為空,gorilla 預設行為)
|
|
||||||
//
|
|
||||||
// 注意:這個 helper 故意與 api package 的 isAllowedOrigin 重複實作(不直接 import),
|
|
||||||
// 避免 ws → api 反向 import(會造成 cycle)。兩邊邏輯保持一致。
|
|
||||||
func CheckOrigin(r *http.Request) bool {
|
|
||||||
origin := r.Header.Get("Origin")
|
|
||||||
if origin == "" {
|
|
||||||
// same-origin 或非瀏覽器 client(websocat、Postman 等)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if origin == "null" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
u, err := url.Parse(origin)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if u.Scheme != "http" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
host := strings.ToLower(u.Hostname())
|
|
||||||
switch host {
|
|
||||||
case "127.0.0.1", "localhost", "::1", "[::1]":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestCheckOrigin 驗證 WebSocket upgrade 的 origin 白名單(M8-8 / TDD §5)。
|
|
||||||
func TestCheckOrigin(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
origin string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"empty same-origin", "", true},
|
|
||||||
{"loopback 127.0.0.1", "http://127.0.0.1:3721", true},
|
|
||||||
{"loopback localhost", "http://localhost:3000", true},
|
|
||||||
{"loopback ipv6", "http://[::1]:3721", true},
|
|
||||||
{"https 不允許", "https://127.0.0.1:3721", false},
|
|
||||||
{"非 loopback hostname", "http://192.168.1.5:3721", false},
|
|
||||||
{"惡意網站", "http://evil.com", false},
|
|
||||||
{"null origin", "null", false},
|
|
||||||
{"suffix 攻擊", "http://127.0.0.1.evil.com", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "http://127.0.0.1:3721/ws/devices/events", nil)
|
|
||||||
if tc.origin != "" {
|
|
||||||
req.Header.Set("Origin", tc.origin)
|
|
||||||
}
|
|
||||||
got := CheckOrigin(req)
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("CheckOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
package ws
|
|
||||||
|
|
||||||
// system_ws.go — MAJ-4 補丁:/ws/system WebSocket endpoint
|
|
||||||
//
|
|
||||||
// 對應 TDD v2/server-lifecycle.md §2.3(Minor 4)與 v2/web-ui-offline-overlay.md §3.2a。
|
|
||||||
//
|
|
||||||
// 用途:讓瀏覽器 tab 訂閱 server 的 `server:shutdown-imminent` 廣播,
|
|
||||||
// 以便在 Wails 關閉 / Restart 流程 SIGTERM 前立即顯示 Offline Overlay,
|
|
||||||
// 避免只靠 health polling 導致的 15 秒延遲與 race。
|
|
||||||
//
|
|
||||||
// 室(room)名稱:`system`
|
|
||||||
// - Hub.BroadcastToRoom("system", payload) 由 system_handler.ShutdownNotify 呼叫
|
|
||||||
// - 目前只支援單一類型事件(server:shutdown-imminent),未來若有其他 system 事件可共用此 room
|
|
||||||
//
|
|
||||||
// 設計注意:
|
|
||||||
// - 不向新 client 送任何歷史訊息(shutdown event 必須「當下」收到才有意義,
|
|
||||||
// 補送一個過期的 shutdown 訊息會誤導前端)。
|
|
||||||
// - Read pump 只用於偵測 client 主動斷線,不處理任何 client → server 訊息。
|
|
||||||
// - 沿用 upgrader + CheckOrigin(loopback 白名單)。
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SystemEventsHandler 處理 /ws/system 的 WebSocket 升級與訂閱。
|
|
||||||
func SystemEventsHandler(hub *Hub) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
client := &Client{Conn: conn, Send: make(chan []byte, 16)}
|
|
||||||
sub := &Subscription{Client: client, Room: "system"}
|
|
||||||
hub.RegisterSync(sub)
|
|
||||||
defer hub.Unregister(sub)
|
|
||||||
|
|
||||||
// Read pump — 唯一目的:偵測 client 斷線(close frame / socket error)→ 觸發 conn.Close
|
|
||||||
// 使得 write pump 的 range client.Send 結束後能乾淨退出。
|
|
||||||
go func() {
|
|
||||||
defer conn.Close()
|
|
||||||
for {
|
|
||||||
if _, _, err := conn.ReadMessage(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Write pump — 把 Hub 塞進 client.Send 的訊息送出去。
|
|
||||||
for msg := range client.Send {
|
|
||||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
package ws
|
|
||||||
|
|
||||||
// system_ws_integration_test.go — MAJ-4 補丁:/ws/system 整合 smoke test
|
|
||||||
//
|
|
||||||
// 啟一個 httptest server 掛 SystemEventsHandler,真的用 gorilla WebSocket client
|
|
||||||
// 連進去,然後呼叫 hub.BroadcastToRoom("system", ...),驗證 client 收到訊息。
|
|
||||||
//
|
|
||||||
// 這個測試取代外部 websocat / wscat 的需求,讓 smoke test 在 CI 就能跑。
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSystemEventsHandler_ReceivesBroadcast(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
hub := NewHub()
|
|
||||||
go hub.Run()
|
|
||||||
|
|
||||||
r := gin.New()
|
|
||||||
r.GET("/ws/system", SystemEventsHandler(hub))
|
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/system"
|
|
||||||
dialer := websocket.DefaultDialer
|
|
||||||
conn, _, err := dialer.Dial(wsURL, http.Header{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("dial: %v", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
// 給 Hub.register channel 一點時間處理 client 加入 room
|
|
||||||
// (RegisterSync 會同步等完,但我們這邊是透過 HTTP upgrade 流程,
|
|
||||||
// client 先連上後才 RegisterSync — 需要等 handler 執行到那一行)
|
|
||||||
// 改用 poll:持續廣播直到收到或 timeout。
|
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
||||||
|
|
||||||
// 等 hub 吸收 Register(最多 500 ms)
|
|
||||||
deadline := time.Now().Add(500 * time.Millisecond)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
hub.mu.RLock()
|
|
||||||
n := len(hub.rooms["system"])
|
|
||||||
hub.mu.RUnlock()
|
|
||||||
if n > 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 廣播 shutdown-imminent
|
|
||||||
hub.BroadcastToRoom("system", map[string]interface{}{
|
|
||||||
"type": "server:shutdown-imminent",
|
|
||||||
"reason": "quit",
|
|
||||||
"ts": time.Now().UnixMilli(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Read 第一則訊息
|
|
||||||
_, data, err := conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got map[string]interface{}
|
|
||||||
if err := json.Unmarshal(data, &got); err != nil {
|
|
||||||
t.Fatalf("json: %v; raw=%s", err, string(data))
|
|
||||||
}
|
|
||||||
if got["type"] != "server:shutdown-imminent" {
|
|
||||||
t.Errorf("wrong type: %+v", got)
|
|
||||||
}
|
|
||||||
if got["reason"] != "quit" {
|
|
||||||
t.Errorf("wrong reason: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -14,16 +14,24 @@ type CameraInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
ffmpegCam *FFmpegCamera
|
mockMode bool
|
||||||
isOpen bool
|
mockCamera *MockCamera
|
||||||
mu sync.Mutex
|
ffmpegCam *FFmpegCamera
|
||||||
|
isOpen bool
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager() *Manager {
|
func NewManager(mockMode bool) *Manager {
|
||||||
return &Manager{}
|
return &Manager{mockMode: mockMode}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ListCameras() []CameraInfo {
|
func (m *Manager) ListCameras() []CameraInfo {
|
||||||
|
if m.mockMode {
|
||||||
|
return []CameraInfo{
|
||||||
|
{ID: "mock-cam-0", Name: "Mock Camera 0", Index: 0, Width: 640, Height: 480},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try to detect real cameras via ffmpeg (auto-detects OS)
|
// Try to detect real cameras via ffmpeg (auto-detects OS)
|
||||||
devices := ListFFmpegDevices()
|
devices := ListFFmpegDevices()
|
||||||
if len(devices) > 0 {
|
if len(devices) > 0 {
|
||||||
@ -42,6 +50,12 @@ func (m *Manager) Open(index, width, height int) error {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if m.mockMode {
|
||||||
|
m.mockCamera = NewMockCamera(width, height)
|
||||||
|
m.isOpen = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Try real camera via ffmpeg
|
// Try real camera via ffmpeg
|
||||||
if !DetectFFmpeg() {
|
if !DetectFFmpeg() {
|
||||||
return fmt.Errorf("ffmpeg not found — install with: brew install ffmpeg (macOS) or winget install ffmpeg (Windows)")
|
return fmt.Errorf("ffmpeg not found — install with: brew install ffmpeg (macOS) or winget install ffmpeg (Windows)")
|
||||||
@ -65,6 +79,7 @@ func (m *Manager) Close() error {
|
|||||||
_ = m.ffmpegCam.Close()
|
_ = m.ffmpegCam.Close()
|
||||||
m.ffmpegCam = nil
|
m.ffmpegCam = nil
|
||||||
}
|
}
|
||||||
|
m.mockCamera = nil
|
||||||
m.isOpen = false
|
m.isOpen = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -79,6 +94,9 @@ func (m *Manager) ReadFrame() ([]byte, error) {
|
|||||||
if m.ffmpegCam != nil {
|
if m.ffmpegCam != nil {
|
||||||
return m.ffmpegCam.ReadFrame()
|
return m.ffmpegCam.ReadFrame()
|
||||||
}
|
}
|
||||||
|
if m.mockCamera != nil {
|
||||||
|
return m.mockCamera.ReadFrame()
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("no camera available")
|
return nil, fmt.Errorf("no camera available")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
local-tool/server/internal/camera/mock_camera.go
Normal file
95
local-tool/server/internal/camera/mock_camera.go
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
package camera
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockCamera struct {
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
frameCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockCamera(width, height int) *MockCamera {
|
||||||
|
return &MockCamera{width: width, height: height}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mc *MockCamera) ReadFrame() ([]byte, error) {
|
||||||
|
mc.frameCount++
|
||||||
|
return mc.generateTestCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mc *MockCamera) generateTestCard() ([]byte, error) {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, mc.width, mc.height))
|
||||||
|
offset := mc.frameCount % mc.width
|
||||||
|
|
||||||
|
for y := 0; y < mc.height; y++ {
|
||||||
|
for x := 0; x < mc.width; x++ {
|
||||||
|
pos := (x + offset) % mc.width
|
||||||
|
ratio := float64(pos) / float64(mc.width)
|
||||||
|
var r, g, b uint8
|
||||||
|
if ratio < 0.33 {
|
||||||
|
r = uint8(255 * (1 - ratio/0.33))
|
||||||
|
g = uint8(255 * ratio / 0.33)
|
||||||
|
} else if ratio < 0.66 {
|
||||||
|
g = uint8(255 * (1 - (ratio-0.33)/0.33))
|
||||||
|
b = uint8(255 * (ratio - 0.33) / 0.33)
|
||||||
|
} else {
|
||||||
|
b = uint8(255 * (1 - (ratio-0.66)/0.34))
|
||||||
|
r = uint8(255 * (ratio - 0.66) / 0.34)
|
||||||
|
}
|
||||||
|
img.SetRGBA(x, y, color.RGBA{R: r, G: g, B: b, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw dark overlay bar at top for text area
|
||||||
|
for y := 0; y < 40; y++ {
|
||||||
|
for x := 0; x < mc.width; x++ {
|
||||||
|
img.SetRGBA(x, y, color.RGBA{R: 0, G: 0, B: 0, A: 180})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw "MOCK CAMERA" text block and frame counter using simple rectangles
|
||||||
|
drawTextBlock(img, 10, 10, fmt.Sprintf("MOCK CAMERA | Frame: %d | %s", mc.frameCount, time.Now().Format("15:04:05")))
|
||||||
|
|
||||||
|
// Draw center crosshair
|
||||||
|
cx, cy := mc.width/2, mc.height/2
|
||||||
|
for i := -20; i <= 20; i++ {
|
||||||
|
if cx+i >= 0 && cx+i < mc.width {
|
||||||
|
img.SetRGBA(cx+i, cy, color.RGBA{R: 255, G: 255, B: 255, A: 200})
|
||||||
|
}
|
||||||
|
if cy+i >= 0 && cy+i < mc.height {
|
||||||
|
img.SetRGBA(cx, cy+i, color.RGBA{R: 255, G: 255, B: 255, A: 200})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawTextBlock(img *image.RGBA, x, y int, text string) {
|
||||||
|
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||||
|
// Simple pixel-based text rendering: each character is a 5x7 block
|
||||||
|
for i, ch := range text {
|
||||||
|
if ch == ' ' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
px := x + i*6
|
||||||
|
// Draw a small white dot for each character position
|
||||||
|
for dy := 0; dy < 5; dy++ {
|
||||||
|
for dx := 0; dx < 4; dx++ {
|
||||||
|
if px+dx < img.Bounds().Max.X && y+dy < img.Bounds().Max.Y {
|
||||||
|
img.SetRGBA(px+dx, y+dy, white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -60,7 +60,8 @@ type VideoSource struct {
|
|||||||
done chan struct{}
|
done chan struct{}
|
||||||
finished bool
|
finished bool
|
||||||
err error
|
err error
|
||||||
filePath string // local file path
|
filePath string // local file path (empty for URL sources)
|
||||||
|
isURL bool // true when source is a URL, skip file cleanup
|
||||||
totalFrames int64 // 0 means unknown
|
totalFrames int64 // 0 means unknown
|
||||||
frameCount int64 // atomic counter incremented in readLoop
|
frameCount int64 // atomic counter incremented in readLoop
|
||||||
}
|
}
|
||||||
@ -68,15 +69,77 @@ type VideoSource struct {
|
|||||||
// NewVideoSource starts an ffmpeg process that decodes a video file
|
// NewVideoSource starts an ffmpeg process that decodes a video file
|
||||||
// and outputs MJPEG frames to stdout at the specified FPS.
|
// and outputs MJPEG frames to stdout at the specified FPS.
|
||||||
func NewVideoSource(filePath string, fps float64) (*VideoSource, error) {
|
func NewVideoSource(filePath string, fps float64) (*VideoSource, error) {
|
||||||
return newVideoSource(filePath, fps, 0)
|
return newVideoSource(filePath, fps, false, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVideoSourceWithSeek starts ffmpeg from a specific position (in seconds).
|
// NewVideoSourceWithSeek starts ffmpeg from a specific position (in seconds).
|
||||||
func NewVideoSourceWithSeek(filePath string, fps float64, seekSeconds float64) (*VideoSource, error) {
|
func NewVideoSourceWithSeek(filePath string, fps float64, seekSeconds float64) (*VideoSource, error) {
|
||||||
return newVideoSource(filePath, fps, seekSeconds)
|
return newVideoSource(filePath, fps, false, seekSeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVideoSource(filePath string, fps float64, seekSeconds float64) (*VideoSource, error) {
|
// NewVideoSourceFromURL starts an ffmpeg process that reads from a URL
|
||||||
|
// (HTTP, HTTPS, RTSP, etc.) and outputs MJPEG frames to stdout.
|
||||||
|
func NewVideoSourceFromURL(rawURL string, fps float64) (*VideoSource, error) {
|
||||||
|
return newVideoSource(rawURL, fps, true, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVideoSourceFromURLWithSeek starts ffmpeg from a URL at a specific position.
|
||||||
|
func NewVideoSourceFromURLWithSeek(rawURL string, fps float64, seekSeconds float64) (*VideoSource, error) {
|
||||||
|
return newVideoSource(rawURL, fps, true, seekSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveWithYTDLP uses yt-dlp to extract the direct video stream URL
|
||||||
|
// from platforms like YouTube, Vimeo, etc.
|
||||||
|
// Returns the resolved direct URL or an error.
|
||||||
|
func ResolveWithYTDLP(rawURL string) (string, error) {
|
||||||
|
cmd := exec.Command("yt-dlp", "-f", "best[ext=mp4]/best", "--get-url", rawURL)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
stderr := strings.TrimSpace(string(exitErr.Stderr))
|
||||||
|
return "", fmt.Errorf("%s\n%s", friendlyYTDLPError(stderr), stderr)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("yt-dlp 未安裝或無法執行: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := strings.TrimSpace(string(out))
|
||||||
|
if resolved == "" {
|
||||||
|
return "", fmt.Errorf("yt-dlp 無法取得影片下載連結(影片可能有地區限制或需要登入)")
|
||||||
|
}
|
||||||
|
if idx := strings.Index(resolved, "\n"); idx > 0 {
|
||||||
|
resolved = resolved[:idx]
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// friendlyYTDLPError 把 yt-dlp 的技術性錯誤訊息轉成使用者能理解的提示。
|
||||||
|
func friendlyYTDLPError(stderr string) string {
|
||||||
|
s := strings.ToLower(stderr)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(s, "sign in") || strings.Contains(s, "age"):
|
||||||
|
return "此影片需要登入 YouTube 帳號才能觀看(例如年齡限制),無法直接使用"
|
||||||
|
case strings.Contains(s, "private"):
|
||||||
|
return "此影片為私人影片,無法存取"
|
||||||
|
case strings.Contains(s, "unavailable") || strings.Contains(s, "not available"):
|
||||||
|
return "此影片無法取得(可能已被移除或在你的地區不可用)"
|
||||||
|
case strings.Contains(s, "copyright"):
|
||||||
|
return "此影片因版權限制無法下載"
|
||||||
|
case strings.Contains(s, "live"):
|
||||||
|
return "此影片為直播串流,目前不支援直播推論"
|
||||||
|
case strings.Contains(s, "premiere"):
|
||||||
|
return "此影片為首播(Premiere),尚未開始或格式不支援"
|
||||||
|
case strings.Contains(s, "drm") || strings.Contains(s, "protected"):
|
||||||
|
return "此影片有 DRM 保護,無法下載"
|
||||||
|
case strings.Contains(s, "rate limit") || strings.Contains(s, "429"):
|
||||||
|
return "YouTube 暫時限制了請求頻率,請稍後再試"
|
||||||
|
case strings.Contains(s, "no video formats"):
|
||||||
|
return "找不到可用的影片格式"
|
||||||
|
default:
|
||||||
|
return "無法解析此影片連結,請確認 URL 是否正確且影片為公開狀態"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newVideoSource(input string, fps float64, isURL bool, seekSeconds float64) (*VideoSource, error) {
|
||||||
if fps <= 0 {
|
if fps <= 0 {
|
||||||
fps = 15
|
fps = 15
|
||||||
}
|
}
|
||||||
@ -86,7 +149,7 @@ func newVideoSource(filePath string, fps float64, seekSeconds float64) (*VideoSo
|
|||||||
args = append(args, "-ss", fmt.Sprintf("%.3f", seekSeconds))
|
args = append(args, "-ss", fmt.Sprintf("%.3f", seekSeconds))
|
||||||
}
|
}
|
||||||
args = append(args,
|
args = append(args,
|
||||||
"-i", filePath,
|
"-i", input,
|
||||||
"-vf", fmt.Sprintf("fps=%g", fps),
|
"-vf", fmt.Sprintf("fps=%g", fps),
|
||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"-vcodec", "mjpeg",
|
"-vcodec", "mjpeg",
|
||||||
@ -106,12 +169,18 @@ func newVideoSource(filePath string, fps float64, seekSeconds float64) (*VideoSo
|
|||||||
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
|
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filePath := ""
|
||||||
|
if !isURL {
|
||||||
|
filePath = input
|
||||||
|
}
|
||||||
|
|
||||||
vs := &VideoSource{
|
vs := &VideoSource{
|
||||||
cmd: cmd,
|
cmd: cmd,
|
||||||
stdout: stdout,
|
stdout: stdout,
|
||||||
frameCh: make(chan []byte, 30), // buffer up to 30 frames
|
frameCh: make(chan []byte, 30), // buffer up to 30 frames
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
|
isURL: isURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
go vs.readLoop()
|
go vs.readLoop()
|
||||||
@ -226,8 +295,8 @@ func (v *VideoSource) Close() error {
|
|||||||
for range v.frameCh {
|
for range v.frameCh {
|
||||||
}
|
}
|
||||||
<-v.done
|
<-v.done
|
||||||
// Remove temp file if present
|
// Only remove temp files, not URL sources
|
||||||
if v.filePath != "" {
|
if !v.isURL && v.filePath != "" {
|
||||||
_ = os.Remove(v.filePath)
|
_ = os.Remove(v.filePath)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -16,14 +16,17 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int
|
Port int
|
||||||
Host string
|
Host string
|
||||||
LogLevel string
|
MockMode bool
|
||||||
DevMode bool
|
MockCamera bool
|
||||||
DataDir string
|
MockDeviceCount int
|
||||||
ModelDir string
|
LogLevel string
|
||||||
PythonMode PythonMode
|
DevMode bool
|
||||||
PythonBin string // Python 可執行檔路徑(由 Wails 殼層傳入)
|
DataDir string
|
||||||
|
ModelDir string
|
||||||
|
PythonMode PythonMode
|
||||||
|
PythonBin string // Python 可執行檔路徑(由 Wails 殼層傳入)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
@ -33,6 +36,9 @@ func Load() *Config {
|
|||||||
flag.IntVar(&cfg.Port, "port", 3721, "Server port")
|
flag.IntVar(&cfg.Port, "port", 3721, "Server port")
|
||||||
// 強制 localhost-only:即使使用者傳入其他 host,Load() 結束前會覆寫回 127.0.0.1
|
// 強制 localhost-only:即使使用者傳入其他 host,Load() 結束前會覆寫回 127.0.0.1
|
||||||
flag.StringVar(&cfg.Host, "host", "127.0.0.1", "Server host (forced to 127.0.0.1 for local-only use)")
|
flag.StringVar(&cfg.Host, "host", "127.0.0.1", "Server host (forced to 127.0.0.1 for local-only use)")
|
||||||
|
flag.BoolVar(&cfg.MockMode, "mock", false, "Enable mock device driver")
|
||||||
|
flag.BoolVar(&cfg.MockCamera, "mock-camera", false, "Enable mock camera")
|
||||||
|
flag.IntVar(&cfg.MockDeviceCount, "mock-devices", 1, "Number of mock devices")
|
||||||
flag.StringVar(&cfg.LogLevel, "log-level", "info", "Log level (debug/info/warn/error)")
|
flag.StringVar(&cfg.LogLevel, "log-level", "info", "Log level (debug/info/warn/error)")
|
||||||
flag.BoolVar(&cfg.DevMode, "dev", false, "Dev mode: disable embedded static file serving")
|
flag.BoolVar(&cfg.DevMode, "dev", false, "Dev mode: disable embedded static file serving")
|
||||||
flag.StringVar(&cfg.DataDir, "data-dir", "", "Override data directory (default: <binary>/data)")
|
flag.StringVar(&cfg.DataDir, "data-dir", "", "Override data directory (default: <binary>/data)")
|
||||||
|
|||||||
@ -27,9 +27,9 @@ func CheckAll() []Dependency {
|
|||||||
check("ffmpeg", false,
|
check("ffmpeg", false,
|
||||||
"macOS: brew install ffmpeg | Windows: winget install Gyan.FFmpeg",
|
"macOS: brew install ffmpeg | Windows: winget install Gyan.FFmpeg",
|
||||||
"-version"),
|
"-version"),
|
||||||
check("ffprobe", false,
|
check("yt-dlp", false,
|
||||||
"macOS: brew install ffmpeg | Windows: winget install Gyan.FFmpeg",
|
"macOS: brew install yt-dlp | Windows: winget install yt-dlp",
|
||||||
"-version"),
|
"--version"),
|
||||||
check("python3", false,
|
check("python3", false,
|
||||||
"Required only for Kneron KL720 hardware. macOS: brew install python3",
|
"Required only for Kneron KL720 hardware. macOS: brew install python3",
|
||||||
"--version"),
|
"--version"),
|
||||||
@ -66,8 +66,8 @@ func check(name string, required bool, hint string, args ...string) Dependency {
|
|||||||
d.Available = true
|
d.Available = true
|
||||||
d.Path = path
|
d.Path = path
|
||||||
|
|
||||||
// 效能:bundle 內的 binary 冷啟動可能較慢(尤其 PyInstaller),
|
// 效能:bundle 內的 binary(尤其是 yt-dlp PyInstaller 單檔)冷啟動可能需 20 秒,
|
||||||
// bundle binary 已知良好,跳過 version 查詢以加速啟動。
|
// 會阻塞 server startup。bundle binary 已知良好,跳過 version 查詢以加速啟動。
|
||||||
// 若之後需要版本字串,handler 可 lazy 再打一次。
|
// 若之後需要版本字串,handler 可 lazy 再打一次。
|
||||||
if strings.HasPrefix(path, strings.TrimSpace(os.Getenv("VISIONA_BUNDLE_BIN_DIR"))) &&
|
if strings.HasPrefix(path, strings.TrimSpace(os.Getenv("VISIONA_BUNDLE_BIN_DIR"))) &&
|
||||||
os.Getenv("VISIONA_BUNDLE_BIN_DIR") != "" {
|
os.Getenv("VISIONA_BUNDLE_BIN_DIR") != "" {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"visiona-local/server/internal/driver"
|
"visiona-local/server/internal/driver"
|
||||||
"visiona-local/server/internal/driver/kneron"
|
"visiona-local/server/internal/driver/kneron"
|
||||||
|
mockdriver "visiona-local/server/internal/driver/mock"
|
||||||
"visiona-local/server/pkg/logger"
|
"visiona-local/server/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -14,18 +15,28 @@ type Manager struct {
|
|||||||
registry *DriverRegistry
|
registry *DriverRegistry
|
||||||
sessions map[string]*DeviceSession
|
sessions map[string]*DeviceSession
|
||||||
eventBus chan DeviceEvent
|
eventBus chan DeviceEvent
|
||||||
|
mockMode bool
|
||||||
scriptPath string
|
scriptPath string
|
||||||
logBroadcaster *logger.Broadcaster
|
logBroadcaster *logger.Broadcaster
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(registry *DriverRegistry, scriptPath string) *Manager {
|
func NewManager(registry *DriverRegistry, mockMode bool, mockCount int, scriptPath string) *Manager {
|
||||||
return &Manager{
|
m := &Manager{
|
||||||
registry: registry,
|
registry: registry,
|
||||||
sessions: make(map[string]*DeviceSession),
|
sessions: make(map[string]*DeviceSession),
|
||||||
eventBus: make(chan DeviceEvent, 100),
|
eventBus: make(chan DeviceEvent, 100),
|
||||||
|
mockMode: mockMode,
|
||||||
scriptPath: scriptPath,
|
scriptPath: scriptPath,
|
||||||
}
|
}
|
||||||
|
if mockMode {
|
||||||
|
for i := 0; i < mockCount; i++ {
|
||||||
|
id := fmt.Sprintf("mock-device-%d", i+1)
|
||||||
|
d := mockdriver.Factory(id, i)
|
||||||
|
m.sessions[id] = NewSession(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLogBroadcaster attaches a log broadcaster so that Kneron driver
|
// SetLogBroadcaster attaches a log broadcaster so that Kneron driver
|
||||||
@ -43,6 +54,10 @@ func (m *Manager) SetLogBroadcaster(b *logger.Broadcaster) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Start() {
|
func (m *Manager) Start() {
|
||||||
|
if m.mockMode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Detect real Kneron devices (KL520, KL720, etc.) via Python bridge.
|
// Detect real Kneron devices (KL520, KL720, etc.) via Python bridge.
|
||||||
devices := kneron.DetectDevices(m.scriptPath)
|
devices := kneron.DetectDevices(m.scriptPath)
|
||||||
if len(devices) == 0 {
|
if len(devices) == 0 {
|
||||||
@ -65,6 +80,10 @@ func (m *Manager) Start() {
|
|||||||
// Rescan re-detects connected Kneron devices. New devices are registered,
|
// Rescan re-detects connected Kneron devices. New devices are registered,
|
||||||
// removed devices are cleaned up, and existing devices are left untouched.
|
// removed devices are cleaned up, and existing devices are left untouched.
|
||||||
func (m *Manager) Rescan() []driver.DeviceInfo {
|
func (m *Manager) Rescan() []driver.DeviceInfo {
|
||||||
|
if m.mockMode {
|
||||||
|
return m.ListDevices()
|
||||||
|
}
|
||||||
|
|
||||||
detected := kneron.DetectDevices(m.scriptPath)
|
detected := kneron.DetectDevices(m.scriptPath)
|
||||||
|
|
||||||
// Build a set of detected device IDs.
|
// Build a set of detected device IDs.
|
||||||
|
|||||||
@ -11,20 +11,30 @@ type testDriver struct {
|
|||||||
connected bool
|
connected bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *testDriver) Info() driver.DeviceInfo { return d.info }
|
func (d *testDriver) Info() driver.DeviceInfo { return d.info }
|
||||||
func (d *testDriver) Connect() error { d.connected = true; d.info.Status = driver.StatusConnected; return nil }
|
func (d *testDriver) Connect() error { d.connected = true; d.info.Status = driver.StatusConnected; return nil }
|
||||||
func (d *testDriver) Disconnect() error { d.connected = false; d.info.Status = driver.StatusDisconnected; return nil }
|
func (d *testDriver) Disconnect() error { d.connected = false; d.info.Status = driver.StatusDisconnected; return nil }
|
||||||
func (d *testDriver) IsConnected() bool { return d.connected }
|
func (d *testDriver) IsConnected() bool { return d.connected }
|
||||||
func (d *testDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
|
func (d *testDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
|
||||||
func (d *testDriver) StartInference() error { return nil }
|
func (d *testDriver) StartInference() error { return nil }
|
||||||
func (d *testDriver) StopInference() error { return nil }
|
func (d *testDriver) StopInference() error { return nil }
|
||||||
func (d *testDriver) ReadInference() (*driver.InferenceResult, error) { return nil, nil }
|
func (d *testDriver) ReadInference() (*driver.InferenceResult, error) { return nil, nil }
|
||||||
func (d *testDriver) RunInference(_ []byte) (*driver.InferenceResult, error) { return nil, nil }
|
func (d *testDriver) RunInference(_ []byte) (*driver.InferenceResult, error) { return nil, nil }
|
||||||
func (d *testDriver) GetModelInfo() (*driver.ModelInfo, error) { return nil, nil }
|
func (d *testDriver) GetModelInfo() (*driver.ModelInfo, error) { return nil, nil }
|
||||||
|
|
||||||
|
func TestNewManager_MockMode(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
mgr := NewManager(registry, true, 2, "")
|
||||||
|
|
||||||
|
devices := mgr.ListDevices()
|
||||||
|
if len(devices) != 2 {
|
||||||
|
t.Errorf("NewManager mock mode: got %d devices, want 2", len(devices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestManager_ListDevices(t *testing.T) {
|
func TestManager_ListDevices(t *testing.T) {
|
||||||
registry := NewRegistry()
|
registry := NewRegistry()
|
||||||
mgr := NewManager(registry, "")
|
mgr := NewManager(registry, false, 0, "")
|
||||||
|
|
||||||
mgr.sessions["test-1"] = NewSession(&testDriver{
|
mgr.sessions["test-1"] = NewSession(&testDriver{
|
||||||
info: driver.DeviceInfo{ID: "test-1", Name: "Test Device", Type: "KL720", Status: driver.StatusDetected},
|
info: driver.DeviceInfo{ID: "test-1", Name: "Test Device", Type: "KL720", Status: driver.StatusDetected},
|
||||||
@ -36,20 +46,9 @@ func TestManager_ListDevices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ListDevices_Empty(t *testing.T) {
|
|
||||||
registry := NewRegistry()
|
|
||||||
mgr := NewManager(registry, "")
|
|
||||||
|
|
||||||
// 無硬體時應回傳空 list(R5-5a:沒插硬體就空白,不給 Mock 資料)
|
|
||||||
devices := mgr.ListDevices()
|
|
||||||
if len(devices) != 0 {
|
|
||||||
t.Errorf("ListDevices() with no hardware = %d, want 0", len(devices))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_GetDevice(t *testing.T) {
|
func TestManager_GetDevice(t *testing.T) {
|
||||||
registry := NewRegistry()
|
registry := NewRegistry()
|
||||||
mgr := NewManager(registry, "")
|
mgr := NewManager(registry, false, 0, "")
|
||||||
mgr.sessions["test-1"] = NewSession(&testDriver{
|
mgr.sessions["test-1"] = NewSession(&testDriver{
|
||||||
info: driver.DeviceInfo{ID: "test-1"},
|
info: driver.DeviceInfo{ID: "test-1"},
|
||||||
})
|
})
|
||||||
@ -74,7 +73,7 @@ func TestManager_GetDevice(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_Connect(t *testing.T) {
|
func TestManager_Connect(t *testing.T) {
|
||||||
registry := NewRegistry()
|
registry := NewRegistry()
|
||||||
mgr := NewManager(registry, "")
|
mgr := NewManager(registry, false, 0, "")
|
||||||
td := &testDriver{info: driver.DeviceInfo{ID: "test-1", Status: driver.StatusDetected}}
|
td := &testDriver{info: driver.DeviceInfo{ID: "test-1", Status: driver.StatusDetected}}
|
||||||
mgr.sessions["test-1"] = NewSession(td)
|
mgr.sessions["test-1"] = NewSession(td)
|
||||||
|
|
||||||
|
|||||||
183
local-tool/server/internal/driver/mock/mock_driver.go
Normal file
183
local-tool/server/internal/driver/mock/mock_driver.go
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
package mock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"visiona-local/server/internal/driver"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mockLabels = []string{"person", "car", "bicycle", "dog", "cat", "chair", "bottle", "phone"}
|
||||||
|
|
||||||
|
type MockDriver struct {
|
||||||
|
info driver.DeviceInfo
|
||||||
|
connected bool
|
||||||
|
inferring bool
|
||||||
|
modelLoaded string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockDriver(info driver.DeviceInfo) *MockDriver {
|
||||||
|
return &MockDriver{info: info}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Factory(id string, index int) driver.DeviceDriver {
|
||||||
|
info := driver.DeviceInfo{
|
||||||
|
ID: id,
|
||||||
|
Name: fmt.Sprintf("Kneron KL720 (Mock #%d)", index+1),
|
||||||
|
Type: "kneron_kl720",
|
||||||
|
Port: fmt.Sprintf("/dev/ttyMOCK%d", index),
|
||||||
|
Status: driver.StatusDetected,
|
||||||
|
FirmwareVer: "2.2.0-mock",
|
||||||
|
}
|
||||||
|
return NewMockDriver(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *MockDriver) Info() driver.DeviceInfo {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
return d.info
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *MockDriver) Connect() error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
d.connected = true
|
||||||
|
d.info.Status = driver.StatusConnected
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *MockDriver) Disconnect() error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.connected = false
|
||||||
|
d.inferring = false
|
||||||
|
d.info.Status = driver.StatusDisconnected
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *MockDriver) IsConnected() bool {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
return d.connected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *MockDriver) Flash(modelPath string, progressCh chan<- driver.FlashProgress) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.info.Status = driver.StatusFlashing
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
type stage struct {
|
||||||
|
name string
|
||||||
|
duration time.Duration
|
||||||
|
startPct int
|
||||||
|
endPct int
|
||||||
|
}
|
||||||
|
|
||||||
|
stages := []stage{
|
||||||
|
{"preparing", 1 * time.Second, 0, 10},
|
||||||
|
{"transferring", 6 * time.Second, 10, 80},
|
||||||
|
{"verifying", 2 * time.Second, 80, 95},
|
||||||
|
{"rebooting", 1 * time.Second, 95, 99},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range stages {
|
||||||
|
steps := (s.endPct - s.startPct) / 5
|
||||||
|
if steps < 1 {
|
||||||
|
steps = 1
|
||||||
|
}
|
||||||
|
interval := s.duration / time.Duration(steps)
|
||||||
|
for i := 0; i <= steps; i++ {
|
||||||
|
pct := s.startPct + (s.endPct-s.startPct)*i/steps
|
||||||
|
progressCh <- driver.FlashProgress{
|
||||||
|
Percent: pct,
|
||||||
|
Stage: s.name,
|
||||||
|
Message: fmt.Sprintf("%s... %d%%", s.name, pct),
|
||||||
|
}
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
d.modelLoaded = modelPath
|
||||||
|
d.info.FlashedModel = modelPath
|
||||||
|
d.info.Status = driver.StatusConnected
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
progressCh <- driver.FlashProgress{Percent: 100, Stage: "done", Message: "Flash complete"}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *MockDriver) StartInference() error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.inferring = true
|
||||||
|
d.info.Status = driver.StatusInferencing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *MockDriver) StopInference() error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.inferring = false
|
||||||
|
d.info.Status = driver.StatusConnected
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *MockDriver) ReadInference() (*driver.InferenceResult, error) {
|
||||||
|
return d.RunInference(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *MockDriver) RunInference(imageData []byte) (*driver.InferenceResult, error) {
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
|
||||||
|
numDetections := rand.Intn(3) + 1
|
||||||
|
detections := make([]driver.DetectionResult, numDetections)
|
||||||
|
for i := 0; i < numDetections; i++ {
|
||||||
|
w := 0.1 + rand.Float64()*0.3
|
||||||
|
h := 0.1 + rand.Float64()*0.3
|
||||||
|
detections[i] = driver.DetectionResult{
|
||||||
|
Label: mockLabels[rand.Intn(len(mockLabels))],
|
||||||
|
Confidence: 0.3 + rand.Float64()*0.7,
|
||||||
|
BBox: driver.BBox{
|
||||||
|
X: rand.Float64() * (1 - w),
|
||||||
|
Y: rand.Float64() * (1 - h),
|
||||||
|
Width: w,
|
||||||
|
Height: h,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
classifications := []driver.ClassResult{
|
||||||
|
{Label: "person", Confidence: 0.5 + rand.Float64()*0.5},
|
||||||
|
{Label: "car", Confidence: rand.Float64() * 0.5},
|
||||||
|
{Label: "dog", Confidence: rand.Float64() * 0.3},
|
||||||
|
{Label: "cat", Confidence: rand.Float64() * 0.2},
|
||||||
|
{Label: "bicycle", Confidence: rand.Float64() * 0.15},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &driver.InferenceResult{
|
||||||
|
TaskType: "detection",
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
LatencyMs: 20 + rand.Float64()*30,
|
||||||
|
Detections: detections,
|
||||||
|
Classifications: classifications,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *MockDriver) GetModelInfo() (*driver.ModelInfo, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if d.modelLoaded == "" {
|
||||||
|
return nil, fmt.Errorf("no model loaded")
|
||||||
|
}
|
||||||
|
return &driver.ModelInfo{
|
||||||
|
ID: d.modelLoaded,
|
||||||
|
Name: d.modelLoaded,
|
||||||
|
LoadedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@ -50,103 +50,31 @@ func baseDir(devMode bool) string {
|
|||||||
return filepath.Dir(exe)
|
return filepath.Dir(exe)
|
||||||
}
|
}
|
||||||
|
|
||||||
// findFirstExisting tries each candidate directory and returns the first one
|
// resolveBridgeScript finds kneron_bridge.py across different packaging layouts.
|
||||||
// that contains `sentinel` as a regular file. Returned path is absolute.
|
|
||||||
//
|
//
|
||||||
// If no candidate hits, returns ("", tried) where `tried` is the absolute
|
// Possible locations (tried in order):
|
||||||
// form of every candidate that was checked — callers can log this for
|
// 1. <base>/scripts/kneron_bridge.py — dev mode or flat layout
|
||||||
// debugging. Callers are expected to supply their own fallback value.
|
// 2. <base>/../scripts/kneron_bridge.py — Windows/Linux installer: binary in {app}/bin, scripts in {app}/scripts
|
||||||
func findFirstExisting(candidates []string, sentinel string) (string, []string) {
|
// 3. <base>/../Resources/scripts/kneron_bridge.py — macOS app bundle: binary in Contents/MacOS, scripts in Contents/Resources
|
||||||
tried := make([]string, 0, len(candidates))
|
// 4. ./scripts/kneron_bridge.py — cwd fallback
|
||||||
|
func resolveBridgeScript(base string) string {
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(base, "scripts", "kneron_bridge.py"),
|
||||||
|
filepath.Join(base, "..", "scripts", "kneron_bridge.py"),
|
||||||
|
filepath.Join(base, "..", "Resources", "scripts", "kneron_bridge.py"),
|
||||||
|
filepath.Join(".", "scripts", "kneron_bridge.py"),
|
||||||
|
}
|
||||||
for _, c := range candidates {
|
for _, c := range candidates {
|
||||||
abs, err := filepath.Abs(c)
|
abs, err := filepath.Abs(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tried = append(tried, c)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tried = append(tried, abs)
|
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
|
||||||
if info, err := os.Stat(filepath.Join(abs, sentinel)); err == nil && !info.IsDir() {
|
return abs
|
||||||
return abs, tried
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", tried
|
// Nothing found — return the default so downstream logs a clear error
|
||||||
}
|
return filepath.Join(base, "scripts", "kneron_bridge.py")
|
||||||
|
|
||||||
// resolveBridgeScript finds the directory holding kneron_bridge.py across
|
|
||||||
// different packaging layouts, then returns the absolute path to the script.
|
|
||||||
//
|
|
||||||
// Possible locations (tried in order):
|
|
||||||
// 1. <env VISIONA_BUNDLE_LIB_DIR>/scripts — Linux AppImage (AppRun exports this)
|
|
||||||
// 2. <base>/scripts — dev mode or flat layout
|
|
||||||
// 3. <base>/../scripts — Windows/Linux installer: {app}/bin/<exe>, {app}/scripts/
|
|
||||||
// 4. <base>/../Resources/scripts — macOS app bundle: Contents/Resources/bin/<exe>, Contents/Resources/scripts/
|
|
||||||
// 5. <base>/../lib/visiona-local/scripts — Linux AppImage FHS: usr/bin/<exe>, usr/lib/visiona-local/scripts/
|
|
||||||
// 6. ./scripts — cwd fallback
|
|
||||||
func resolveBridgeScript(base string) string {
|
|
||||||
candidates := []string{}
|
|
||||||
if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" {
|
|
||||||
candidates = append(candidates, filepath.Join(libDir, "scripts"))
|
|
||||||
}
|
|
||||||
candidates = append(candidates,
|
|
||||||
filepath.Join(base, "scripts"),
|
|
||||||
filepath.Join(base, "..", "scripts"),
|
|
||||||
filepath.Join(base, "..", "Resources", "scripts"),
|
|
||||||
filepath.Join(base, "..", "lib", "visiona-local", "scripts"),
|
|
||||||
filepath.Join(".", "scripts"),
|
|
||||||
)
|
|
||||||
if dir, tried := findFirstExisting(candidates, "kneron_bridge.py"); dir != "" {
|
|
||||||
return filepath.Join(dir, "kneron_bridge.py")
|
|
||||||
} else {
|
|
||||||
log.Printf("warn: kneron_bridge.py not found. Tried: %v", tried)
|
|
||||||
}
|
|
||||||
// Fallback — return the default so downstream logs a clear error
|
|
||||||
abs, err := filepath.Abs(filepath.Join(base, "scripts", "kneron_bridge.py"))
|
|
||||||
if err != nil {
|
|
||||||
return filepath.Join(base, "scripts", "kneron_bridge.py")
|
|
||||||
}
|
|
||||||
return abs
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveBuiltInDataDir finds the bundle-internal data/ directory that ships
|
|
||||||
// with the binary. This directory is *read-only* at runtime and holds the
|
|
||||||
// built-in model catalog (models.json + nef/kl520/ + nef/kl720/).
|
|
||||||
//
|
|
||||||
// This is different from the user data directory (lock, ipc-port, logs,
|
|
||||||
// custom-models, preferences.json, sentinel file) which is writable and lives
|
|
||||||
// under the OS-specific app-data location. See main() for the split.
|
|
||||||
//
|
|
||||||
// Possible locations (tried in order):
|
|
||||||
// 1. <env VISIONA_BUNDLE_LIB_DIR>/data — Linux AppImage (AppRun exports this)
|
|
||||||
// 2. <base>/data — dev mode or flat layout (cwd == repo/server/)
|
|
||||||
// 3. <base>/../data — Windows/Linux installer: {app}/bin/<exe>, {app}/data/
|
|
||||||
// 4. <base>/../Resources/data — macOS app bundle: Contents/Resources/bin/<exe>, Contents/Resources/data/
|
|
||||||
// 5. <base>/../lib/visiona-local/data — Linux AppImage FHS: usr/bin/<exe>, usr/lib/visiona-local/data/
|
|
||||||
//
|
|
||||||
// A candidate counts as a hit only if models.json exists inside it as a
|
|
||||||
// regular file — this avoids false positives from empty `data/` directories
|
|
||||||
// that Wails sometimes leaves behind in build artifacts.
|
|
||||||
func resolveBuiltInDataDir(base string) string {
|
|
||||||
candidates := []string{}
|
|
||||||
if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" {
|
|
||||||
candidates = append(candidates, filepath.Join(libDir, "data"))
|
|
||||||
}
|
|
||||||
candidates = append(candidates,
|
|
||||||
filepath.Join(base, "data"),
|
|
||||||
filepath.Join(base, "..", "data"),
|
|
||||||
filepath.Join(base, "..", "Resources", "data"),
|
|
||||||
filepath.Join(base, "..", "lib", "visiona-local", "data"),
|
|
||||||
)
|
|
||||||
if dir, tried := findFirstExisting(candidates, "models.json"); dir != "" {
|
|
||||||
return dir
|
|
||||||
} else {
|
|
||||||
log.Printf("warn: built-in data dir (models.json) not found. Tried: %v", tried)
|
|
||||||
}
|
|
||||||
// Fallback — return the default so downstream logs a clear error
|
|
||||||
abs, err := filepath.Abs(filepath.Join(base, "data"))
|
|
||||||
if err != nil {
|
|
||||||
return filepath.Join(base, "data")
|
|
||||||
}
|
|
||||||
return abs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -155,9 +83,10 @@ func main() {
|
|||||||
logger := pkglogger.New(cfg.LogLevel)
|
logger := pkglogger.New(cfg.LogLevel)
|
||||||
|
|
||||||
logger.Info("Starting visionA-local Server %s (built: %s)", Version, BuildTime)
|
logger.Info("Starting visionA-local Server %s (built: %s)", Version, BuildTime)
|
||||||
logger.Info("Dev mode: %v, Python mode: %s", cfg.DevMode, cfg.PythonMode)
|
logger.Info("Mock mode: %v, Mock camera: %v, Dev mode: %v, Python mode: %s",
|
||||||
|
cfg.MockMode, cfg.MockCamera, cfg.DevMode, cfg.PythonMode)
|
||||||
|
|
||||||
// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH,讓 exec.Command("ffmpeg") / exec.Command("ffprobe")
|
// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH,讓 exec.Command("yt-dlp") / exec.Command("ffmpeg")
|
||||||
// 能透過 LookPath 找到 bundle 內的 binary(Go 1.19+ Windows 不再搜 cwd)。
|
// 能透過 LookPath 找到 bundle 內的 binary(Go 1.19+ Windows 不再搜 cwd)。
|
||||||
if bundleBin := os.Getenv("VISIONA_BUNDLE_BIN_DIR"); bundleBin != "" {
|
if bundleBin := os.Getenv("VISIONA_BUNDLE_BIN_DIR"); bundleBin != "" {
|
||||||
sep := string(os.PathListSeparator)
|
sep := string(os.PathListSeparator)
|
||||||
@ -168,38 +97,18 @@ func main() {
|
|||||||
// Check external dependencies
|
// Check external dependencies
|
||||||
deps.PrintStartupReport(logger)
|
deps.PrintStartupReport(logger)
|
||||||
|
|
||||||
// Resolve base directory.
|
// Resolve base directory and data directory
|
||||||
base := baseDir(cfg.DevMode)
|
base := baseDir(cfg.DevMode)
|
||||||
|
|
||||||
// Resolve built-in data directory (read-only, ships with the binary).
|
|
||||||
// Holds models.json + nef/kl520/ + nef/kl720/. Auto-detected across
|
|
||||||
// dev / installer / macOS-bundle layouts; see resolveBuiltInDataDir().
|
|
||||||
builtInDataDir := resolveBuiltInDataDir(base)
|
|
||||||
logger.Info("Built-in data dir: %s", builtInDataDir)
|
|
||||||
|
|
||||||
// Resolve user data directory (writable). Holds lock, ipc-port, logs,
|
|
||||||
// custom-models, preferences.json, sentinel. Wails passes this via
|
|
||||||
// --data-dir pointing at the OS app-data location.
|
|
||||||
//
|
|
||||||
// Standalone fallback: when no --data-dir is given we reuse builtInDataDir
|
|
||||||
// so `go run ./server` and direct binary launches keep working for local
|
|
||||||
// development. In *production*, Wails always passes --data-dir, so this
|
|
||||||
// branch never lands on a read-only bundle path. If someone does run the
|
|
||||||
// packaged binary with no --data-dir, the writable operations (sentinel,
|
|
||||||
// logs, custom-models) will fail against the read-only bundle dir and the
|
|
||||||
// affected code paths log warnings — they don't crash the server.
|
|
||||||
dataDir := cfg.DataDir
|
dataDir := cfg.DataDir
|
||||||
if dataDir == "" {
|
if dataDir == "" {
|
||||||
dataDir = builtInDataDir
|
dataDir = filepath.Join(base, "data")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize model repository (built-in models from JSON).
|
// Initialize model repository (built-in models from JSON)
|
||||||
// Always read from the built-in data dir — not the user data dir —
|
modelRepo := model.NewRepository(filepath.Join(dataDir, "models.json"))
|
||||||
// so Wails passing --data-dir doesn't accidentally blank out the catalog.
|
|
||||||
modelRepo := model.NewRepository(filepath.Join(builtInDataDir, "models.json"))
|
|
||||||
logger.Info("Loaded %d built-in models", modelRepo.Count())
|
logger.Info("Loaded %d built-in models", modelRepo.Count())
|
||||||
|
|
||||||
// Initialize model store (custom uploaded models) — writable, user dataDir.
|
// Initialize model store (custom uploaded models)
|
||||||
customModelDir := cfg.ModelDir
|
customModelDir := cfg.ModelDir
|
||||||
if customModelDir == "" {
|
if customModelDir == "" {
|
||||||
customModelDir = filepath.Join(dataDir, "custom-models")
|
customModelDir = filepath.Join(dataDir, "custom-models")
|
||||||
@ -218,11 +127,6 @@ func main() {
|
|||||||
|
|
||||||
// Initialize WebSocket hub (before device manager so log broadcaster is ready)
|
// Initialize WebSocket hub (before device manager so log broadcaster is ready)
|
||||||
wsHub := ws.NewHub()
|
wsHub := ws.NewHub()
|
||||||
// M8-4b:注入 dataDir 給 Hub,第一個 WebSocket client 連上時會在
|
|
||||||
// <dataDir>/.first-ws-connected 寫 sentinel file,讓 Wails 端的
|
|
||||||
// StartupPipeline 知道階段 6(Wait for Web UI WebSocket)已完成。
|
|
||||||
// 詳見 .autoflow/04-architecture/v2/startup-pipeline.md §3 階段 6。
|
|
||||||
wsHub.SetStartupSentinel(dataDir)
|
|
||||||
go wsHub.Run()
|
go wsHub.Run()
|
||||||
|
|
||||||
// Initialize log broadcaster for real-time log streaming
|
// Initialize log broadcaster for real-time log streaming
|
||||||
@ -235,18 +139,15 @@ func main() {
|
|||||||
registry := device.NewRegistry()
|
registry := device.NewRegistry()
|
||||||
bridgeScript := resolveBridgeScript(base)
|
bridgeScript := resolveBridgeScript(base)
|
||||||
logger.Info("Kneron bridge script: %s", bridgeScript)
|
logger.Info("Kneron bridge script: %s", bridgeScript)
|
||||||
deviceMgr := device.NewManager(registry, bridgeScript)
|
deviceMgr := device.NewManager(registry, cfg.MockMode, cfg.MockDeviceCount, bridgeScript)
|
||||||
deviceMgr.SetLogBroadcaster(logBroadcaster)
|
deviceMgr.SetLogBroadcaster(logBroadcaster)
|
||||||
deviceMgr.Start()
|
deviceMgr.Start()
|
||||||
|
|
||||||
// Initialize camera manager
|
// Initialize camera manager
|
||||||
cameraMgr := camera.NewManager()
|
cameraMgr := camera.NewManager(cfg.MockCamera)
|
||||||
|
|
||||||
// Initialize services.
|
// Initialize services
|
||||||
// flash.Service resolves relative `.nef` paths from models.json against
|
flashSvc := flash.NewService(deviceMgr, modelRepo, dataDir)
|
||||||
// builtInDataDir (not dataDir), since the .nef files ship alongside
|
|
||||||
// models.json in the read-only bundle, not in the writable user dataDir.
|
|
||||||
flashSvc := flash.NewService(deviceMgr, modelRepo, builtInDataDir)
|
|
||||||
inferenceSvc := inference.NewService(deviceMgr)
|
inferenceSvc := inference.NewService(deviceMgr)
|
||||||
|
|
||||||
// Determine static file system for embedded frontend
|
// Determine static file system for embedded frontend
|
||||||
@ -263,10 +164,7 @@ func main() {
|
|||||||
restartRequested := make(chan struct{}, 1)
|
restartRequested := make(chan struct{}, 1)
|
||||||
|
|
||||||
shutdownFn := func() {
|
shutdownFn := func() {
|
||||||
// MAJ-3 修復:timeout 必須 ≤ Wails shutdownGracePeriod (7s),留 1s buffer。
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
// TDD §8.1:Wails 端 7s grace + 1s modal;server 端 6s 內必須完成清理,
|
|
||||||
// 否則 Wails 在第 7s SIGKILL 時 server 還在 sync 檔案會被打斷。
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
inferenceSvc.StopAll()
|
inferenceSvc.StopAll()
|
||||||
cameraMgr.Close()
|
cameraMgr.Close()
|
||||||
@ -292,7 +190,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create system handler with injected version and restart function
|
// Create system handler with injected version and restart function
|
||||||
systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn, wsHub)
|
systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn)
|
||||||
|
|
||||||
// Create router
|
// Create router
|
||||||
r := api.NewRouter(modelRepo, modelStore, deviceMgr, cameraMgr, flashSvc, inferenceSvc, wsHub, staticFS, logBroadcaster, systemHandler)
|
r := api.NewRouter(modelRepo, modelStore, deviceMgr, cameraMgr, flashSvc, inferenceSvc, wsHub, staticFS, logBroadcaster, systemHandler)
|
||||||
|
|||||||
0
local-tool/vendor/.gitkeep
vendored
0
local-tool/vendor/.gitkeep
vendored
294
local-tool/vendor/ffmpeg/macos/BUILD.md
vendored
294
local-tool/vendor/ffmpeg/macos/BUILD.md
vendored
@ -1,294 +0,0 @@
|
|||||||
# macOS LGPL ffmpeg build record
|
|
||||||
|
|
||||||
此目錄存放 visionA-local 的 macOS x86_64 專用 ffmpeg + ffprobe binary。
|
|
||||||
依 v2 TDD §2(`/.autoflow/04-architecture/v2/ffmpeg-lgpl.md`)決策,macOS 沒有現成的
|
|
||||||
LGPL static build 來源,採「自 build decoder-only」策略,binary 直接 commit 到 git
|
|
||||||
(R5-6b)— 開發者 clone repo 即可使用,不必每次重 build(~15 分鐘)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reproducibility
|
|
||||||
|
|
||||||
| 項目 | 值 |
|
|
||||||
|------|---|
|
|
||||||
| ffmpeg release | `n7.1` |
|
|
||||||
| Source tarball | <https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.1.tar.gz> |
|
|
||||||
| Source sha256 | `7ddad2d992bd250a6c56053c26029f7e728bebf0f37f80cf3f8a0e6ec706431a` |
|
|
||||||
| Build host | macOS 14.7.6 (Sonoma, x86_64) |
|
|
||||||
| Toolchain | Apple clang 16.0.0 (clang-1600.0.26.6), Command Line Tools |
|
|
||||||
| Assembler | nasm 3.01(Homebrew bottle,compiled 2025-10-11) |
|
|
||||||
| Homebrew | 5.1.6 |
|
|
||||||
| Build date | 2026-04-15 |
|
|
||||||
| Build flags | 見下方 Configure flags 區塊(與 `Makefile` 的 `vendor-ffmpeg-macos-build` target 一致) |
|
|
||||||
|
|
||||||
## Binary sha256
|
|
||||||
|
|
||||||
| 檔案 | sha256 |
|
|
||||||
|------|--------|
|
|
||||||
| `ffmpeg` | `c3cb9f1dad66730267c12fca92c6344d2f8939ab227889caac33005f8947992c` |
|
|
||||||
| `ffprobe` | `bd388fb4372ed5f7e44ee331a51be6383d702fb2c067bf562cabbdfbdd8b0c5e` |
|
|
||||||
| `COPYING.LGPLv3` | `da7eabb7bafdf7d3ae5e9f223aa5bdc1eece45ac569dc21b3b037520b4464768` |
|
|
||||||
|
|
||||||
計算指令:
|
|
||||||
```bash
|
|
||||||
shasum -a 256 vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
|
|
||||||
```
|
|
||||||
|
|
||||||
## Binary 大小(實測,strip 後)
|
|
||||||
|
|
||||||
| 檔案 | Bytes | 人類可讀 |
|
|
||||||
|------|-------|---------|
|
|
||||||
| `ffmpeg` | 6,007,520 | 5.7 MB |
|
|
||||||
| `ffprobe` | 5,865,568 | 5.6 MB |
|
|
||||||
|
|
||||||
實測比 TDD 原估 10–15 MB 小一半,因為 `--disable-everything` + 白名單僅啟用必要 decoder/demuxer/filter,無 GPL 元件。
|
|
||||||
|
|
||||||
### Build 實測耗時
|
|
||||||
|
|
||||||
- **2 分 44 秒**(`make vendor-ffmpeg-macos-build` 的 `time` 量測)
|
|
||||||
- user: 559.60s,system: 56.03s,wall-clock: 164.56s
|
|
||||||
- CPU 使用率:~374%(macOS x86_64,8 核 Intel)
|
|
||||||
- 比 TDD 原估 10–20 分鐘快很多,因為 `--disable-everything` 大幅削減編譯單元數量
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
**LGPL v3**(`--enable-version3` 未加 `--enable-gpl`)。完整授權條款見同目錄的
|
|
||||||
`COPYING.LGPLv3`(build 後由 Makefile 自動從 source tarball 複製過來)。
|
|
||||||
|
|
||||||
build 不 link 以下 GPL-only 元件:
|
|
||||||
- 無 `libx264`(H.264 encoder,GPL)
|
|
||||||
- 無 `libx265`(H.265 encoder,GPL)
|
|
||||||
- 無 `libxavs` / `libxvid`(GPL)
|
|
||||||
- 無 `libfaac`(non-free)
|
|
||||||
|
|
||||||
僅使用 libavcodec 內建的 LGPL native decoder(h264 / hevc / mpeg1video / mpeg2video /
|
|
||||||
mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configure flags(完整複製)
|
|
||||||
|
|
||||||
```
|
|
||||||
./configure \
|
|
||||||
--prefix="<build_dir>/install" \
|
|
||||||
--enable-version3 \
|
|
||||||
--disable-debug \
|
|
||||||
--disable-doc \
|
|
||||||
--disable-ffplay \
|
|
||||||
--disable-network \
|
|
||||||
--disable-autodetect \
|
|
||||||
--disable-shared \
|
|
||||||
--enable-static \
|
|
||||||
--disable-everything \
|
|
||||||
--enable-small \
|
|
||||||
--enable-protocol=file,pipe \
|
|
||||||
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
|
|
||||||
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
|
|
||||||
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
|
|
||||||
--enable-filter=scale,format,fps,null,anull \
|
|
||||||
--enable-muxer=image2pipe,image2,null \
|
|
||||||
--enable-encoder=mjpeg \
|
|
||||||
--enable-swscale \
|
|
||||||
--enable-swresample \
|
|
||||||
--extra-cflags="-arch x86_64 -mmacosx-version-min=10.15" \
|
|
||||||
--extra-ldflags="-arch x86_64 -mmacosx-version-min=10.15 -Wl,-search_paths_first" \
|
|
||||||
--arch=x86_64 \
|
|
||||||
--target-os=darwin \
|
|
||||||
--cc="clang -arch x86_64"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 為什麼是這些 flag
|
|
||||||
|
|
||||||
| flag | 理由 |
|
|
||||||
|------|-----|
|
|
||||||
| `--enable-version3` | 使用 LGPL v3(非 v2.1),與 BtbN Windows/Linux build 對齊 |
|
|
||||||
| `--disable-debug` / `--disable-doc` | 縮 binary 體積 |
|
|
||||||
| `--disable-network` | 我們只處理本地檔案,不需要 http/rtsp/rtmp 協議 |
|
|
||||||
| `--disable-autodetect` | 不自動偵測系統上的外部 lib(`libopus` / `libvpx` 等),LGPL 合規稽核時更乾淨 |
|
|
||||||
| `--disable-shared --enable-static` | 產出 self-contained binary,不依賴 macOS 上任何外部 dylib |
|
|
||||||
| `--disable-everything` | 先關全部,白名單 enable,確保不額外 link 任何 GPL 元件 |
|
|
||||||
| `--enable-small` | 最佳化體積而非速度 |
|
|
||||||
| `--enable-protocol=file,pipe` | 只開 file:// 和 pipe(ffmpeg 內部 stdin/stdout) |
|
|
||||||
| `--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2` | 對齊 PRD v2 支援的上傳格式 `.mp4 / .avi / .mov / .mpeg / .mpg` |
|
|
||||||
| `--enable-decoder=h264,hevc,...` | 涵蓋常見 codec:H.264 / H.265 / MPEG1/2/4 / mjpeg / prores / vp8/9 / AAC / MP2/3 / PCM |
|
|
||||||
| `--enable-parser=...` | 必要,否則某些 decoder 會在碼流切分階段 fail |
|
|
||||||
| `--enable-muxer=image2pipe,image2,null` | 輸出單張 JPEG 或 NULL(測試用) |
|
|
||||||
| `--enable-encoder=mjpeg` | `-f image2pipe -vcodec mjpeg` 需要 mjpeg encoder(LGPL-safe) |
|
|
||||||
| `--enable-swscale` / `--enable-swresample` | pixel format / sample rate 轉換 |
|
|
||||||
| `-mmacosx-version-min=10.15` | 相容 macOS 10.15 Catalina 以上 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to rebuild
|
|
||||||
|
|
||||||
**僅在升級 ffmpeg 版本時才需要執行。平常 clone repo 後直接使用 git 內的 binary。**
|
|
||||||
|
|
||||||
### 前置系統依賴
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install pkg-config nasm # 或 yasm(擇一)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 執行 build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/local-tool
|
|
||||||
make vendor-ffmpeg-macos-build
|
|
||||||
```
|
|
||||||
|
|
||||||
target 會:
|
|
||||||
1. 從 GitHub 下載 ffmpeg source tarball(版本由 `Makefile` 的 `FFMPEG_VERSION` 變數控制)
|
|
||||||
2. 驗證 sha256(不符則 fail)
|
|
||||||
3. 解壓到 `build/ffmpeg-macos/src/`
|
|
||||||
4. `./configure`(只啟用 decoder/demuxer/filter 白名單,不 link 任何 GPL 元件)
|
|
||||||
5. `make -j$(sysctl -n hw.ncpu)`
|
|
||||||
6. `make install` 到 `build/ffmpeg-macos/install/`
|
|
||||||
7. 複製 `ffmpeg` + `ffprobe` 到 `vendor/ffmpeg/macos/`
|
|
||||||
8. `strip -S -x`(去除 debug symbol 與 local symbol)
|
|
||||||
9. ad-hoc `codesign`(無 Apple Developer ID 也能在 Gatekeeper 下跑)
|
|
||||||
10. 驗證 `ffmpeg -version` 不含 `--enable-gpl` / `libx264` / `libx265`
|
|
||||||
11. 複製 `COPYING.LGPLv3` 到同目錄
|
|
||||||
|
|
||||||
Build 完成後,請手動更新本檔的「Build date / Binary sha256 / Binary 大小 / Build 實測耗時」
|
|
||||||
區塊,然後:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add vendor/ffmpeg/macos/ffmpeg \
|
|
||||||
vendor/ffmpeg/macos/ffprobe \
|
|
||||||
vendor/ffmpeg/macos/COPYING.LGPLv3 \
|
|
||||||
vendor/ffmpeg/macos/BUILD.md
|
|
||||||
git commit -m "chore(vendor): rebuild macOS ffmpeg LGPL binary (n<version>)"
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**:不要 commit `build/` 目錄下的中間產物(已在 `.gitignore`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Build 完成後的自動驗證:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 確認 LGPL 合規(不含 GPL 元件)
|
|
||||||
vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -E -- '--enable-gpl|libx264|libx265'
|
|
||||||
# 預期:無輸出
|
|
||||||
|
|
||||||
# 2. 確認可執行
|
|
||||||
vendor/ffmpeg/macos/ffmpeg -version | head -3
|
|
||||||
vendor/ffmpeg/macos/ffprobe -version | head -3
|
|
||||||
|
|
||||||
# 3. 確認 decoder 完整
|
|
||||||
vendor/ffmpeg/macos/ffmpeg -hide_banner -decoders 2>/dev/null | grep -E ' (h264|hevc|aac|mpeg2video|mpeg4|mjpeg|prores|vp8|vp9|mp3) '
|
|
||||||
|
|
||||||
# 4. 確認 demuxer 完整
|
|
||||||
vendor/ffmpeg/macos/ffmpeg -hide_banner -formats 2>/dev/null | grep -E ' (mov|avi|mpeg|matroska)'
|
|
||||||
|
|
||||||
# 5. 確認 Gatekeeper 可過(ad-hoc signed)
|
|
||||||
codesign -v vendor/ffmpeg/macos/ffmpeg
|
|
||||||
codesign -v vendor/ffmpeg/macos/ffprobe
|
|
||||||
# 預期:無輸出(exit 0)
|
|
||||||
|
|
||||||
# 6. 實際解一支 mp4 影片
|
|
||||||
vendor/ffmpeg/macos/ffmpeg -hide_banner -i <some-sample>.mp4 -f image2pipe -vcodec mjpeg -frames:v 1 -q:v 5 /tmp/test.jpg
|
|
||||||
file /tmp/test.jpg
|
|
||||||
# 預期:JPEG image data
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 實測驗證輸出(本次 build)
|
|
||||||
|
|
||||||
### 1. LGPL 合規(ffmpeg -version 擷取)
|
|
||||||
|
|
||||||
```
|
|
||||||
ffmpeg version a6b71ea Copyright (c) 2000-2024 the FFmpeg developers
|
|
||||||
built with Apple clang version 16.0.0 (clang-1600.0.26.6)
|
|
||||||
configuration: --prefix=.../install --enable-version3 --disable-debug --disable-doc
|
|
||||||
--disable-ffplay --disable-network --disable-autodetect --disable-shared --enable-static
|
|
||||||
--disable-everything --enable-small --enable-protocol=file,pipe
|
|
||||||
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2
|
|
||||||
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be
|
|
||||||
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac
|
|
||||||
--enable-filter=scale,format,fps,null,anull
|
|
||||||
--enable-muxer=image2pipe,image2,null --enable-encoder=mjpeg
|
|
||||||
--enable-swscale --enable-swresample ...
|
|
||||||
libavutil 59. 39.100 / 59. 39.100
|
|
||||||
libavcodec 61. 19.100 / 61. 19.100
|
|
||||||
libavformat 61. 7.100 / 61. 7.100
|
|
||||||
```
|
|
||||||
|
|
||||||
- ✅ 無 `--enable-gpl`
|
|
||||||
- ✅ 無 `libx264`
|
|
||||||
- ✅ 無 `libx265`
|
|
||||||
- ✅ 有 `--enable-version3`(LGPL v3)
|
|
||||||
|
|
||||||
### 2. Decoder 驗證
|
|
||||||
|
|
||||||
```
|
|
||||||
$ vendor/ffmpeg/macos/ffmpeg -hide_banner -decoders 2>&1 \
|
|
||||||
| grep -E ' h264 | hevc | aac | mpeg2video | mpeg4 '
|
|
||||||
VFS..D h264
|
|
||||||
VFS..D hevc
|
|
||||||
V.S.BD mpeg2video
|
|
||||||
VF..BD mpeg4
|
|
||||||
A....D aac
|
|
||||||
```
|
|
||||||
|
|
||||||
五個必要 decoder 全數通過。
|
|
||||||
|
|
||||||
### 3. Demuxer / Format 驗證
|
|
||||||
|
|
||||||
```
|
|
||||||
$ vendor/ffmpeg/macos/ffmpeg -hide_banner -formats 2>&1 \
|
|
||||||
| grep -iE 'mov|mp4|avi|mpeg|matroska'
|
|
||||||
D avi
|
|
||||||
D matroska,webm
|
|
||||||
D mov,mp4,m4a,3gp,3g2,mj2
|
|
||||||
D mpeg
|
|
||||||
D mpegts
|
|
||||||
```
|
|
||||||
|
|
||||||
- `mov,mp4,m4a,3gp,3g2,mj2` — 涵蓋 mp4 / mov
|
|
||||||
- `avi` — ok
|
|
||||||
- `mpeg` — 對應 mpegps(MPEG Program Stream)
|
|
||||||
- `mpegts` — MPEG Transport Stream
|
|
||||||
- `matroska,webm` — ok
|
|
||||||
|
|
||||||
### 4. Dynamic dependencies (`otool -L`)
|
|
||||||
|
|
||||||
```
|
|
||||||
vendor/ffmpeg/macos/ffmpeg:
|
|
||||||
/usr/lib/libSystem.B.dylib
|
|
||||||
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
|
|
||||||
/System/Library/Frameworks/CoreVideo.framework/Versions/A/CoreVideo
|
|
||||||
/System/Library/Frameworks/CoreMedia.framework/Versions/A/CoreMedia
|
|
||||||
|
|
||||||
vendor/ffmpeg/macos/ffprobe:
|
|
||||||
(同上四個 macOS system framework)
|
|
||||||
```
|
|
||||||
|
|
||||||
- ✅ 只依賴 macOS 系統內建 framework(`libSystem`, `CoreFoundation`, `CoreVideo`, `CoreMedia`)
|
|
||||||
- ✅ **無任何第三方 dylib**(`libx264`, `libx265`, `libvpx`, `libopus`... 都不存在)
|
|
||||||
- ✅ 等同於 self-contained binary,搬到任一台 macOS 10.15+ x86_64 都能跑
|
|
||||||
|
|
||||||
### 5. Code signing
|
|
||||||
|
|
||||||
```
|
|
||||||
$ codesign -v vendor/ffmpeg/macos/ffmpeg # exit 0, no output
|
|
||||||
$ codesign -v vendor/ffmpeg/macos/ffprobe # exit 0, no output
|
|
||||||
```
|
|
||||||
|
|
||||||
ad-hoc simbol signing ok,Gatekeeper 可過。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Commit 清單(只允許這四個檔進 git)
|
|
||||||
|
|
||||||
為了防呆,`.gitignore` 設定成「`vendor/ffmpeg/macos/**` 全部 un-ignore」,
|
|
||||||
因此任何意外丟進此目錄的檔案都會被 git 看見。code review 時請嚴格檢查
|
|
||||||
這個目錄下**只有**以下四個檔:
|
|
||||||
|
|
||||||
- `ffmpeg`(binary)
|
|
||||||
- `ffprobe`(binary)
|
|
||||||
- `COPYING.LGPLv3`(授權條款)
|
|
||||||
- `BUILD.md`(本檔)
|
|
||||||
165
local-tool/vendor/ffmpeg/macos/COPYING.LGPLv3
vendored
165
local-tool/vendor/ffmpeg/macos/COPYING.LGPLv3
vendored
@ -1,165 +0,0 @@
|
|||||||
GNU LESSER GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 29 June 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
|
|
||||||
This version of the GNU Lesser General Public License incorporates
|
|
||||||
the terms and conditions of version 3 of the GNU General Public
|
|
||||||
License, supplemented by the additional permissions listed below.
|
|
||||||
|
|
||||||
0. Additional Definitions.
|
|
||||||
|
|
||||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
|
||||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
|
||||||
General Public License.
|
|
||||||
|
|
||||||
"The Library" refers to a covered work governed by this License,
|
|
||||||
other than an Application or a Combined Work as defined below.
|
|
||||||
|
|
||||||
An "Application" is any work that makes use of an interface provided
|
|
||||||
by the Library, but which is not otherwise based on the Library.
|
|
||||||
Defining a subclass of a class defined by the Library is deemed a mode
|
|
||||||
of using an interface provided by the Library.
|
|
||||||
|
|
||||||
A "Combined Work" is a work produced by combining or linking an
|
|
||||||
Application with the Library. The particular version of the Library
|
|
||||||
with which the Combined Work was made is also called the "Linked
|
|
||||||
Version".
|
|
||||||
|
|
||||||
The "Minimal Corresponding Source" for a Combined Work means the
|
|
||||||
Corresponding Source for the Combined Work, excluding any source code
|
|
||||||
for portions of the Combined Work that, considered in isolation, are
|
|
||||||
based on the Application, and not on the Linked Version.
|
|
||||||
|
|
||||||
The "Corresponding Application Code" for a Combined Work means the
|
|
||||||
object code and/or source code for the Application, including any data
|
|
||||||
and utility programs needed for reproducing the Combined Work from the
|
|
||||||
Application, but excluding the System Libraries of the Combined Work.
|
|
||||||
|
|
||||||
1. Exception to Section 3 of the GNU GPL.
|
|
||||||
|
|
||||||
You may convey a covered work under sections 3 and 4 of this License
|
|
||||||
without being bound by section 3 of the GNU GPL.
|
|
||||||
|
|
||||||
2. Conveying Modified Versions.
|
|
||||||
|
|
||||||
If you modify a copy of the Library, and, in your modifications, a
|
|
||||||
facility refers to a function or data to be supplied by an Application
|
|
||||||
that uses the facility (other than as an argument passed when the
|
|
||||||
facility is invoked), then you may convey a copy of the modified
|
|
||||||
version:
|
|
||||||
|
|
||||||
a) under this License, provided that you make a good faith effort to
|
|
||||||
ensure that, in the event an Application does not supply the
|
|
||||||
function or data, the facility still operates, and performs
|
|
||||||
whatever part of its purpose remains meaningful, or
|
|
||||||
|
|
||||||
b) under the GNU GPL, with none of the additional permissions of
|
|
||||||
this License applicable to that copy.
|
|
||||||
|
|
||||||
3. Object Code Incorporating Material from Library Header Files.
|
|
||||||
|
|
||||||
The object code form of an Application may incorporate material from
|
|
||||||
a header file that is part of the Library. You may convey such object
|
|
||||||
code under terms of your choice, provided that, if the incorporated
|
|
||||||
material is not limited to numerical parameters, data structure
|
|
||||||
layouts and accessors, or small macros, inline functions and templates
|
|
||||||
(ten or fewer lines in length), you do both of the following:
|
|
||||||
|
|
||||||
a) Give prominent notice with each copy of the object code that the
|
|
||||||
Library is used in it and that the Library and its use are
|
|
||||||
covered by this License.
|
|
||||||
|
|
||||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
|
||||||
document.
|
|
||||||
|
|
||||||
4. Combined Works.
|
|
||||||
|
|
||||||
You may convey a Combined Work under terms of your choice that,
|
|
||||||
taken together, effectively do not restrict modification of the
|
|
||||||
portions of the Library contained in the Combined Work and reverse
|
|
||||||
engineering for debugging such modifications, if you also do each of
|
|
||||||
the following:
|
|
||||||
|
|
||||||
a) Give prominent notice with each copy of the Combined Work that
|
|
||||||
the Library is used in it and that the Library and its use are
|
|
||||||
covered by this License.
|
|
||||||
|
|
||||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
|
||||||
document.
|
|
||||||
|
|
||||||
c) For a Combined Work that displays copyright notices during
|
|
||||||
execution, include the copyright notice for the Library among
|
|
||||||
these notices, as well as a reference directing the user to the
|
|
||||||
copies of the GNU GPL and this license document.
|
|
||||||
|
|
||||||
d) Do one of the following:
|
|
||||||
|
|
||||||
0) Convey the Minimal Corresponding Source under the terms of this
|
|
||||||
License, and the Corresponding Application Code in a form
|
|
||||||
suitable for, and under terms that permit, the user to
|
|
||||||
recombine or relink the Application with a modified version of
|
|
||||||
the Linked Version to produce a modified Combined Work, in the
|
|
||||||
manner specified by section 6 of the GNU GPL for conveying
|
|
||||||
Corresponding Source.
|
|
||||||
|
|
||||||
1) Use a suitable shared library mechanism for linking with the
|
|
||||||
Library. A suitable mechanism is one that (a) uses at run time
|
|
||||||
a copy of the Library already present on the user's computer
|
|
||||||
system, and (b) will operate properly with a modified version
|
|
||||||
of the Library that is interface-compatible with the Linked
|
|
||||||
Version.
|
|
||||||
|
|
||||||
e) Provide Installation Information, but only if you would otherwise
|
|
||||||
be required to provide such information under section 6 of the
|
|
||||||
GNU GPL, and only to the extent that such information is
|
|
||||||
necessary to install and execute a modified version of the
|
|
||||||
Combined Work produced by recombining or relinking the
|
|
||||||
Application with a modified version of the Linked Version. (If
|
|
||||||
you use option 4d0, the Installation Information must accompany
|
|
||||||
the Minimal Corresponding Source and Corresponding Application
|
|
||||||
Code. If you use option 4d1, you must provide the Installation
|
|
||||||
Information in the manner specified by section 6 of the GNU GPL
|
|
||||||
for conveying Corresponding Source.)
|
|
||||||
|
|
||||||
5. Combined Libraries.
|
|
||||||
|
|
||||||
You may place library facilities that are a work based on the
|
|
||||||
Library side by side in a single library together with other library
|
|
||||||
facilities that are not Applications and are not covered by this
|
|
||||||
License, and convey such a combined library under terms of your
|
|
||||||
choice, if you do both of the following:
|
|
||||||
|
|
||||||
a) Accompany the combined library with a copy of the same work based
|
|
||||||
on the Library, uncombined with any other library facilities,
|
|
||||||
conveyed under the terms of this License.
|
|
||||||
|
|
||||||
b) Give prominent notice with the combined library that part of it
|
|
||||||
is a work based on the Library, and explaining where to find the
|
|
||||||
accompanying uncombined form of the same work.
|
|
||||||
|
|
||||||
6. Revised Versions of the GNU Lesser General Public License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions
|
|
||||||
of the GNU Lesser General Public License from time to time. Such new
|
|
||||||
versions will be similar in spirit to the present version, but may
|
|
||||||
differ in detail to address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Library as you received it specifies that a certain numbered version
|
|
||||||
of the GNU Lesser General Public License "or any later version"
|
|
||||||
applies to it, you have the option of following the terms and
|
|
||||||
conditions either of that published version or of any later version
|
|
||||||
published by the Free Software Foundation. If the Library as you
|
|
||||||
received it does not specify a version number of the GNU Lesser
|
|
||||||
General Public License, you may choose any version of the GNU Lesser
|
|
||||||
General Public License ever published by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Library as you received it specifies that a proxy can decide
|
|
||||||
whether future versions of the GNU Lesser General Public License shall
|
|
||||||
apply, that proxy's public statement of acceptance of any version is
|
|
||||||
permanent authorization for you to choose that version for the
|
|
||||||
Library.
|
|
||||||
BIN
local-tool/vendor/ffmpeg/macos/ffmpeg
vendored
BIN
local-tool/vendor/ffmpeg/macos/ffmpeg
vendored
Binary file not shown.
BIN
local-tool/vendor/ffmpeg/macos/ffprobe
vendored
BIN
local-tool/vendor/ffmpeg/macos/ffprobe
vendored
Binary file not shown.
@ -68,13 +68,11 @@ type ServerStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ServerProcess 包裝子行程控制。
|
// ServerProcess 包裝子行程控制。
|
||||||
// v2 新增 app 反向指標,讓 ServerProcess.stopGraceful() 能 emit Wails event。
|
|
||||||
type ServerProcess struct {
|
type ServerProcess struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
port int
|
port int
|
||||||
stdoutLog *os.File
|
stdoutLog *os.File
|
||||||
stderrLog *os.File
|
stderrLog *os.File
|
||||||
app *App
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// App 是 Wails 綁定的主結構。
|
// App 是 Wails 綁定的主結構。
|
||||||
@ -82,16 +80,16 @@ type App struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
dataDir string
|
dataDir string
|
||||||
pythonMode PythonMode
|
pythonMode PythonMode
|
||||||
|
mockMode bool
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
server *ServerProcess // v1 相容保留(v2 已改用 ctrl.proc)
|
server *ServerProcess
|
||||||
pythonBin string
|
pythonBin string
|
||||||
pythonModeR PythonMode // 實際使用的 mode(auto resolved 之後)
|
pythonModeR PythonMode // 實際使用的 mode(auto resolved 之後)
|
||||||
lastError string
|
lastError string
|
||||||
releaseLock func()
|
releaseLock func()
|
||||||
|
|
||||||
// M8-4:啟動進度訊息(v1 splash 殘留,v2 已由 startup-pipeline event 取代)。
|
// 啟動進度訊息 — 供 splash page 透過 GetBootstrapStatus() binding 輪詢顯示
|
||||||
// 目前仍保留供開發 log 使用,最終在 M8-4b 整個流程改寫後會被拿掉。
|
|
||||||
bootstrapStatus string
|
bootstrapStatus string
|
||||||
|
|
||||||
// L-1:server 健康偵測 goroutine 控制
|
// L-1:server 健康偵測 goroutine 控制
|
||||||
@ -100,23 +98,6 @@ type App struct {
|
|||||||
// L-3:Wails 自己的 IPC server(收 /ipc/raise)
|
// L-3:Wails 自己的 IPC server(收 /ipc/raise)
|
||||||
ipcPort int
|
ipcPort int
|
||||||
ipcListener net.Listener
|
ipcListener net.Listener
|
||||||
|
|
||||||
// M8-4:v2 新增欄位
|
|
||||||
ctrl *ServerController // state machine 控制器
|
|
||||||
logBuf *LogBuffer // ring buffer(2000 行)
|
|
||||||
prefs Preferences // 控制台偏好(in-memory,持久化至 preferences.json)
|
|
||||||
|
|
||||||
// M8-4b:6 階段啟動進度 pipeline
|
|
||||||
// startupPipeline 由 startup() 建立,shutdown() 與 RestartStartupSequence() 重置。
|
|
||||||
// pipelineCancelFn 是 watcher goroutine 的 cancel func,存在 App 上方便 RestartStartupSequence 操作。
|
|
||||||
startupPipeline *StartupPipeline
|
|
||||||
pipelineCancelFn context.CancelFunc
|
|
||||||
|
|
||||||
// M8-4b 補丁(M-3 修復):RestartStartupSequence 的 Step 6(呼叫 ctrl.Start)
|
|
||||||
// 在單元測試裡會真的 spawn python server,無法測。提供 test hook 讓測試能替換
|
|
||||||
// 成 no-op 或 spy。生產環境 restartStartFn == nil → 走預設的 a.ctrl.Start()。
|
|
||||||
// test-only;正式環境不應設定。
|
|
||||||
restartStartFn func() error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp 建立 App 實例。
|
// NewApp 建立 App 實例。
|
||||||
@ -134,9 +115,12 @@ func NewApp() *App {
|
|||||||
mode = PythonModeAuto
|
mode = PythonModeAuto
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// R5-5a:沒插硬體就顯示空白狀態(由 UI 處理),一律真實硬體路徑
|
// M7:預設真實硬體模式(使用者決策 Q8)
|
||||||
|
// 若要強制 mock 模式(無 Kneron 裝置環境下 debug),設環境變數 VISIONA_MOCK=1
|
||||||
|
mock := os.Getenv("VISIONA_MOCK") == "1"
|
||||||
return &App{
|
return &App{
|
||||||
pythonMode: mode,
|
pythonMode: mode,
|
||||||
|
mockMode: mock,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,25 +129,10 @@ func NewApp() *App {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
// startup 由 Wails 在 app 啟動時呼叫。
|
// startup 由 Wails 在 app 啟動時呼叫。
|
||||||
//
|
|
||||||
// M8-4b:整合 6 階段啟動 pipeline。流程:
|
|
||||||
// stage 1(init Wails console)→ seedUserDataDir 完成後 CompleteStage(1)
|
|
||||||
// stage 2(Python runtime) ┐
|
|
||||||
// stage 3(spawn server) ├─ 由 startServerV2 內部 hook
|
|
||||||
// stage 4(device probe) ┘
|
|
||||||
// stage 5(open browser) → ctrl.Start() return 後處理
|
|
||||||
// stage 6(wait WebSocket) → watcher goroutine poll sentinel file
|
|
||||||
//
|
|
||||||
// pipeline 失敗時不再呼叫 reportFatal(會直接結束程式),而是進 Error state,
|
|
||||||
// 讓使用者看到 Wails 控制台的 Retry 按鈕。
|
|
||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
ensureGUIPath()
|
ensureGUIPath()
|
||||||
|
|
||||||
// M8-4:初始化 ring buffer 與 state machine controller
|
|
||||||
a.logBuf = NewLogBuffer()
|
|
||||||
a.ctrl = NewServerController(a)
|
|
||||||
|
|
||||||
dataDir := platformDataDir()
|
dataDir := platformDataDir()
|
||||||
a.dataDir = dataDir
|
a.dataDir = dataDir
|
||||||
|
|
||||||
@ -172,18 +141,6 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// M8-4:載入 preferences.json(讀取失敗 → 用 DefaultPreferences 預設)
|
|
||||||
a.prefs = LoadPreferences(dataDir)
|
|
||||||
|
|
||||||
// M8-4b:建立 startup pipeline 並啟動(emit stage=1 running)
|
|
||||||
// 必須在 prefs 載入後,因為 watcher 會讀 prefs.AutoOpenBrowser 判斷階段 5/6 是否 skip。
|
|
||||||
// 同時清掉殘留的 sentinel file(前次 crash 留下的舊檔會讓階段 6 瞬間完成造成假象)
|
|
||||||
removeSentinelFile(dataDir)
|
|
||||||
a.startupPipeline = NewStartupPipeline(a)
|
|
||||||
pipelineCtx, cancel := context.WithCancel(ctx)
|
|
||||||
a.pipelineCancelFn = cancel
|
|
||||||
a.startupPipeline.Start(pipelineCtx)
|
|
||||||
|
|
||||||
// 1. 舊資料目錄遷移(必須在 lock 之前,因為 lock 檔會寫到新路徑)
|
// 1. 舊資料目錄遷移(必須在 lock 之前,因為 lock 檔會寫到新路徑)
|
||||||
migrateOldDataDirs(dataDir)
|
migrateOldDataDirs(dataDir)
|
||||||
|
|
||||||
@ -226,63 +183,15 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
fmt.Fprintln(os.Stderr, "[visiona-local] seed user data dir failed:", err)
|
fmt.Fprintln(os.Stderr, "[visiona-local] seed user data dir failed:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// M8-4b:階段 1(初始化 Wails 控制台)完成 → 自動進入階段 2 running
|
// 4. 啟動 server 子行程
|
||||||
a.startupPipeline.CompleteStage(1)
|
if err := a.startServer(); err != nil {
|
||||||
|
a.reportFatal("server start failed", err)
|
||||||
// 4. M8-4:走 ServerController 啟動(v2 路徑)。
|
|
||||||
// 冷啟動允許 port fallback(StartWithPort(0) 即為冷啟動)。
|
|
||||||
// startServerV2 內部會 hook 階段 2/3/4 的 pipeline.CompleteStage()。
|
|
||||||
if err := a.ctrl.Start(); err != nil {
|
|
||||||
// pipeline.FailStage 已經由 startServerV2 → ctrl.startInternal 觸發失敗時 emit error +
|
|
||||||
// 切到 Error state,這裡不需要呼叫 reportFatal(讓使用者看到 Retry 按鈕)
|
|
||||||
fmt.Fprintln(os.Stderr, "[visiona-local] startup pipeline: server start failed:", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 階段 5:開瀏覽器(或 skip)
|
|
||||||
a.runStartupStage5()
|
|
||||||
// 階段 6 由 watcher goroutine poll sentinel file 觸發 → CompleteStage(6) → markReady
|
|
||||||
}
|
|
||||||
|
|
||||||
// runStartupStage5 處理 R5-E 階段 5:開瀏覽器。
|
|
||||||
// AutoOpenBrowser=false → SkipStage 進入階段 6(也會被 skip-timeout 規則處理)
|
|
||||||
// AutoOpenBrowser=true → 呼叫 openBrowser 並 CompleteStage(5)
|
|
||||||
func (a *App) runStartupStage5() {
|
|
||||||
if a.startupPipeline == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !a.prefs.AutoOpenBrowser {
|
|
||||||
a.startupPipeline.SkipStage(5)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 取得 server URL
|
|
||||||
url := ""
|
|
||||||
if a.ctrl != nil {
|
|
||||||
a.ctrl.mu.Lock()
|
|
||||||
if a.ctrl.proc != nil && a.ctrl.proc.port > 0 {
|
|
||||||
url = fmt.Sprintf("http://127.0.0.1:%d", a.ctrl.proc.port)
|
|
||||||
}
|
|
||||||
a.ctrl.mu.Unlock()
|
|
||||||
}
|
|
||||||
if url != "" {
|
|
||||||
// 不等瀏覽器真的開(只等命令 return),失敗記 log 不擋流程
|
|
||||||
if err := openBrowser(url); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "[visiona-local] startup stage 5: open browser failed: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.startupPipeline.CompleteStage(5)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// shutdown 由 Wails 在 app 結束時呼叫。
|
// shutdown 由 Wails 在 app 結束時呼叫。
|
||||||
//
|
|
||||||
// M8-4:改為呼叫 ctrl.Stop() 走 7 秒 grace + 1 秒 modal 流程。
|
|
||||||
// M8-4b:停 startup pipeline watcher + 清 sentinel file。
|
|
||||||
// 細節對應 TDD v2/server-lifecycle.md §8 + v2/startup-pipeline.md §3。
|
|
||||||
func (a *App) shutdown(ctx context.Context) {
|
func (a *App) shutdown(ctx context.Context) {
|
||||||
// M8-4b:停 startup pipeline watcher(避免 Stop 期間 watcher 還在 poll sentinel)
|
|
||||||
if a.pipelineCancelFn != nil {
|
|
||||||
a.pipelineCancelFn()
|
|
||||||
a.pipelineCancelFn = nil
|
|
||||||
}
|
|
||||||
// 停 watch goroutine
|
// 停 watch goroutine
|
||||||
if a.watchCancel != nil {
|
if a.watchCancel != nil {
|
||||||
a.watchCancel()
|
a.watchCancel()
|
||||||
@ -293,28 +202,7 @@ func (a *App) shutdown(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
removeWailsIPCPort(a.dataDir)
|
removeWailsIPCPort(a.dataDir)
|
||||||
|
|
||||||
// MAJ-4 補丁:先通知瀏覽器 tab「server 要關了」→ 立即顯示 Offline Overlay
|
a.stopServer()
|
||||||
// 這一步必須在 ctrl.Stop() 之前,因為 Stop 會馬上 SIGTERM server,之後瀏覽器
|
|
||||||
// 只會看到 ECONNREFUSED,要等 15 s polling 失敗才顯示 overlay。
|
|
||||||
//
|
|
||||||
// best-effort:失敗(server 沒起來、endpoint 掛了、1 s timeout)全部忽略,
|
|
||||||
// 不阻塞 shutdown 流程。
|
|
||||||
if a.ctrl != nil {
|
|
||||||
port := a.snapshotStatus().Port
|
|
||||||
notifyShutdownImminent(ctx, port, "quit")
|
|
||||||
}
|
|
||||||
|
|
||||||
// M8-4:由 ServerController 執行 Stop(含 7 秒 grace + 1 秒 modal)
|
|
||||||
if a.ctrl != nil {
|
|
||||||
_ = a.ctrl.Stop()
|
|
||||||
} else {
|
|
||||||
// v1 fallback(理論上 M8-4 後不會走到)
|
|
||||||
a.stopServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// M8-4b:清 sentinel file(正常關機的清理;下次啟動才不會誤判階段 6 已完成)
|
|
||||||
removeSentinelFile(a.dataDir)
|
|
||||||
|
|
||||||
if a.releaseLock != nil {
|
if a.releaseLock != nil {
|
||||||
a.releaseLock()
|
a.releaseLock()
|
||||||
}
|
}
|
||||||
@ -538,7 +426,8 @@ func (a *App) startServer() error {
|
|||||||
// 1. 決定 python runtime
|
// 1. 決定 python runtime
|
||||||
a.setBootstrapStatus("正在初始化 Python 環境...")
|
a.setBootstrapStatus("正在初始化 Python 環境...")
|
||||||
pyBin, pyMode, err := a.ensurePythonRuntime(a.pythonMode)
|
pyBin, pyMode, err := a.ensurePythonRuntime(a.pythonMode)
|
||||||
if err != nil {
|
if err != nil && !a.mockMode {
|
||||||
|
// Mock 模式下沒有 python 仍可啟動(server 不 spawn sidecar)
|
||||||
return fmt.Errorf("python runtime unavailable: %w", err)
|
return fmt.Errorf("python runtime unavailable: %w", err)
|
||||||
}
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
@ -549,7 +438,7 @@ func (a *App) startServer() error {
|
|||||||
// 1.5. 首次啟動自動安裝 Kneron WinUSB driver(Windows only,macOS/Linux no-op)
|
// 1.5. 首次啟動自動安裝 Kneron WinUSB driver(Windows only,macOS/Linux no-op)
|
||||||
// 失敗不擋 server 啟動 —— 使用者之後可手動點「安裝 USB Driver」按鈕重試。
|
// 失敗不擋 server 啟動 —— 使用者之後可手動點「安裝 USB Driver」按鈕重試。
|
||||||
// 用 .driver-installed 記號檔避免每次都跑。
|
// 用 .driver-installed 記號檔避免每次都跑。
|
||||||
if pyBin != "" {
|
if !a.mockMode && pyBin != "" {
|
||||||
if err := a.ensureDriverInstalled(pyBin); err != nil {
|
if err := a.ensureDriverInstalled(pyBin); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "[visiona-local] driver auto-install failed (非致命,可於 UI 手動重試):", err)
|
fmt.Fprintln(os.Stderr, "[visiona-local] driver auto-install failed (非致命,可於 UI 手動重試):", err)
|
||||||
}
|
}
|
||||||
@ -570,14 +459,23 @@ func (a *App) startServer() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 組參數
|
// 4. 組參數
|
||||||
|
//
|
||||||
|
// Mock 模式下 server 根本不需要 python sidecar,因此:
|
||||||
|
// - 不傳 --python-mode(讓 server 用預設 auto)
|
||||||
|
// - 不傳 --python
|
||||||
|
// 這樣可避免在沒有對應 flag 的舊版 server 上誤殺,也避免誤導。
|
||||||
args := []string{
|
args := []string{
|
||||||
"--host", "127.0.0.1",
|
"--host", "127.0.0.1",
|
||||||
"--port", strconv.Itoa(port),
|
"--port", strconv.Itoa(port),
|
||||||
"--data-dir", a.dataDir,
|
"--data-dir", a.dataDir,
|
||||||
"--python-mode", string(pyMode),
|
|
||||||
}
|
}
|
||||||
if pyBin != "" {
|
if a.mockMode {
|
||||||
args = append(args, "--python", pyBin)
|
args = append(args, "--mock")
|
||||||
|
} else {
|
||||||
|
args = append(args, "--python-mode", string(pyMode))
|
||||||
|
if pyBin != "" {
|
||||||
|
args = append(args, "--python", pyBin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 開 log 檔
|
// 5. 開 log 檔
|
||||||
@ -593,7 +491,7 @@ func (a *App) startServer() error {
|
|||||||
cmd.Dir = filepath.Dir(binPath)
|
cmd.Dir = filepath.Dir(binPath)
|
||||||
configureSysProcAttr(cmd) // Windows: CREATE_NO_WINDOW 藏掉 server 小黑窗
|
configureSysProcAttr(cmd) // Windows: CREATE_NO_WINDOW 藏掉 server 小黑窗
|
||||||
|
|
||||||
// 注入 bundle bin dir 給 server 偵測 ffmpeg / ffprobe(M6)
|
// 注入 bundle bin dir 給 server 偵測 ffmpeg / yt-dlp(M6)
|
||||||
env := os.Environ()
|
env := os.Environ()
|
||||||
if binDir, err := locateBundleBinDir(); err == nil {
|
if binDir, err := locateBundleBinDir(); err == nil {
|
||||||
env = append(env, "VISIONA_BUNDLE_BIN_DIR="+binDir)
|
env = append(env, "VISIONA_BUNDLE_BIN_DIR="+binDir)
|
||||||
@ -601,7 +499,7 @@ func (a *App) startServer() error {
|
|||||||
}
|
}
|
||||||
// 注入 python interpreter 路徑給 server(kneron detector 會讀 VISIONA_PYTHON)
|
// 注入 python interpreter 路徑給 server(kneron detector 會讀 VISIONA_PYTHON)
|
||||||
// 避免 detector 自己去 resolve 又走不同的路徑邏輯造成不一致。
|
// 避免 detector 自己去 resolve 又走不同的路徑邏輯造成不一致。
|
||||||
if pyBin != "" {
|
if !a.mockMode && pyBin != "" {
|
||||||
env = append(env, "VISIONA_PYTHON="+pyBin)
|
env = append(env, "VISIONA_PYTHON="+pyBin)
|
||||||
fmt.Fprintln(os.Stderr, "[visiona-local] python interpreter:", pyBin)
|
fmt.Fprintln(os.Stderr, "[visiona-local] python interpreter:", pyBin)
|
||||||
}
|
}
|
||||||
@ -785,7 +683,7 @@ func (p *ServerProcess) stop() {
|
|||||||
// - PythonModeAuto:先試 system,失敗才走 bundled
|
// - PythonModeAuto:先試 system,失敗才走 bundled
|
||||||
// - PythonModeBundled:placeholder,回錯誤(M2 才實作)
|
// - PythonModeBundled:placeholder,回錯誤(M2 才實作)
|
||||||
//
|
//
|
||||||
// R5-5a 之後:python 失敗直接擋啟動(沒有模擬回退)。
|
// M1 預設 mock 模式,所以 python 失敗不會擋啟動(由 caller 決定)。
|
||||||
func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) {
|
func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) {
|
||||||
switch mode {
|
switch mode {
|
||||||
case PythonModeAuto:
|
case PythonModeAuto:
|
||||||
@ -961,7 +859,7 @@ func locateBundledPythonAssets() (tarball, wheelsDir string, err error) {
|
|||||||
return "", "", fmt.Errorf("bundled python assets not found (tried .app Resources + same-dir + payload/%s)", runtime.GOOS)
|
return "", "", fmt.Errorf("bundled python assets not found (tried .app Resources + same-dir + payload/%s)", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
||||||
// locateBundleBinDir 找 bundle 內的 bin 目錄(含 ffmpeg / ffprobe / visiona-local-server)。
|
// locateBundleBinDir 找 bundle 內的 bin 目錄(含 ffmpeg / yt-dlp / visiona-local-server)。
|
||||||
//
|
//
|
||||||
// 順序:
|
// 順序:
|
||||||
// 1. macOS .app bundle:Contents/Resources/bin
|
// 1. macOS .app bundle:Contents/Resources/bin
|
||||||
@ -984,7 +882,7 @@ func locateBundleBinDir() (string, error) {
|
|||||||
return abs, nil
|
return abs, nil
|
||||||
}
|
}
|
||||||
// 或與執行檔同目錄(server binary 本身就在這裡時)
|
// 或與執行檔同目錄(server binary 本身就在這裡時)
|
||||||
if fileExists(filepath.Join(exeDir, "ffmpeg")) || fileExists(filepath.Join(exeDir, "ffprobe")) {
|
if fileExists(filepath.Join(exeDir, "ffmpeg")) || fileExists(filepath.Join(exeDir, "yt-dlp")) {
|
||||||
return exeDir, nil
|
return exeDir, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1674,14 +1572,7 @@ func ensureGUIPath() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// openBrowser 用系統預設瀏覽器開啟 URL。
|
// openBrowser 用系統預設瀏覽器開啟 URL。
|
||||||
//
|
func openBrowser(url string) error {
|
||||||
// 設計為 package-level var 是為了方便單元測試攔截呼叫次數
|
|
||||||
// (M8-4b 補丁 M-2 的 regression test 需要驗證冷啟動時 openBrowser 只被呼叫一次)。
|
|
||||||
// 正式執行環境由 init(預設值 openBrowserExec)提供實作。
|
|
||||||
var openBrowser = openBrowserExec
|
|
||||||
|
|
||||||
// openBrowserExec 是 openBrowser 的實際系統呼叫實作。
|
|
||||||
func openBrowserExec(url string) error {
|
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return exec.Command("open", url).Start()
|
return exec.Command("open", url).Start()
|
||||||
|
|||||||
@ -1,353 +1,74 @@
|
|||||||
// visionA-local 控制台 main entry
|
// visionA Local — splash / bootstrap
|
||||||
// - 負責 bindings / events 註冊、初始化 UI
|
// 職責:顯示 app 啟動進度 → server 就緒後跳轉到 Next.js 主 UI
|
||||||
// - M8-5 Wails 控制台 UI(對齊 Design Spec v2.1)
|
|
||||||
|
|
||||||
import {
|
import { GetServerStatus, GetServerURL, GetBootstrapStatus } from './wailsjs/go/main/App.js';
|
||||||
StartServer,
|
|
||||||
StopServer,
|
|
||||||
RestartServer,
|
|
||||||
ForceKillServer,
|
|
||||||
GetServerStatusV2,
|
|
||||||
GetRecentLogs,
|
|
||||||
ClearLogs,
|
|
||||||
GetSystemInfo,
|
|
||||||
OpenInBrowser,
|
|
||||||
RevealLogsFolder,
|
|
||||||
ExportLog,
|
|
||||||
GetPreferences,
|
|
||||||
SetPreferences,
|
|
||||||
RestartStartupSequence,
|
|
||||||
} from './wailsjs/go/main/App.js';
|
|
||||||
|
|
||||||
import { EventsOn } from './wailsjs/runtime/runtime.js';
|
const statusEl = document.getElementById('status');
|
||||||
import { t, applyI18n, setLocale, getLocale, detectLocale, LOCALES } from './i18n.js';
|
const errorEl = document.getElementById('error');
|
||||||
import {
|
|
||||||
setServerState,
|
|
||||||
updateServerMeta,
|
|
||||||
updatePrimaryControls,
|
|
||||||
showErrorBanner,
|
|
||||||
hideErrorBanner,
|
|
||||||
showToast,
|
|
||||||
initHeaderClock,
|
|
||||||
setPrimaryCTAPulse,
|
|
||||||
STATE_ERROR,
|
|
||||||
} from './control-panel.js';
|
|
||||||
import {
|
|
||||||
showStartupPanel,
|
|
||||||
hideStartupPanel,
|
|
||||||
updateStage,
|
|
||||||
markStageTimeout,
|
|
||||||
showStartupError,
|
|
||||||
renderStages,
|
|
||||||
onManualModeChange,
|
|
||||||
} from './startup-panel.js';
|
|
||||||
import {
|
|
||||||
initLogPanel,
|
|
||||||
appendLogs,
|
|
||||||
clearLog,
|
|
||||||
flashLastError,
|
|
||||||
applyLogFilter,
|
|
||||||
} from './log-panel.js';
|
|
||||||
import { initSettingsPanel, openSettings } from './settings-panel.js';
|
|
||||||
|
|
||||||
// ---------- 全域狀態 ----------
|
const POLL_INTERVAL_MS = 400;
|
||||||
const state = {
|
// 首次啟動最長容忍時間:venv 解壓(10s) + 建 venv(5s) + pip install wheels(30-60s) +
|
||||||
server: null, // ServerStatusV2
|
// libwdi driver install with UAC(15-30s) + server spawn(3s) + health check(2s) ≈ 60-110s
|
||||||
prefs: null, // Preferences
|
// 給到 240 秒以涵蓋慢速硬碟 / UAC 被使用者拖延的情況
|
||||||
sysInfo: null, // SystemInfo
|
const MAX_WAIT_MS = 240_000;
|
||||||
starting: false, // 啟動進度面板是否顯示
|
const startTime = Date.now();
|
||||||
};
|
|
||||||
|
let lastStatus = '';
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
if (elapsed > MAX_WAIT_MS) {
|
||||||
|
showError(
|
||||||
|
`啟動逾時(${Math.round(MAX_WAIT_MS / 1000)} 秒)。\n` +
|
||||||
|
'請關閉此視窗並重新啟動應用程式,或查看 log:\n' +
|
||||||
|
'%APPDATA%\\visiona-local\\logs\\wails.log'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 初始化 ----------
|
|
||||||
async function init() {
|
|
||||||
// 1. 讀 preferences → 決定 locale
|
|
||||||
try {
|
try {
|
||||||
state.prefs = await GetPreferences();
|
// 先更新 bootstrap 進度文字(venv / pip / driver / server...)
|
||||||
|
const bootstrapMsg = await GetBootstrapStatus();
|
||||||
|
if (bootstrapMsg && bootstrapMsg !== lastStatus) {
|
||||||
|
statusEl.textContent = bootstrapMsg;
|
||||||
|
lastStatus = bootstrapMsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 檢查 server 是否已就緒
|
||||||
|
const status = await GetServerStatus();
|
||||||
|
if (status && status.lastError) {
|
||||||
|
showError('伺服器啟動失敗:' + status.lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status && status.running && status.url) {
|
||||||
|
statusEl.textContent = '載入主介面...';
|
||||||
|
window.location.replace(status.url + '/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 備用:直接問 URL
|
||||||
|
const url = await GetServerURL();
|
||||||
|
if (url) {
|
||||||
|
statusEl.textContent = '載入主介面...';
|
||||||
|
window.location.replace(url + '/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('GetPreferences failed:', e);
|
// binding 尚未就緒時會 throw,繼續輪詢
|
||||||
state.prefs = { autoOpenBrowser: true, locale: '' };
|
|
||||||
}
|
|
||||||
const locale = detectLocale(state.prefs && state.prefs.locale);
|
|
||||||
setLocale(locale);
|
|
||||||
|
|
||||||
// 2. 讀系統資訊
|
|
||||||
try {
|
|
||||||
state.sysInfo = await GetSystemInfo();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('GetSystemInfo failed:', e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 套 i18n 到 DOM
|
setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
applyI18n();
|
|
||||||
document.title = t('control.title');
|
|
||||||
if (state.sysInfo && state.sysInfo.appVersion) {
|
|
||||||
document.getElementById('app-version').textContent = state.sysInfo.appVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. render 6 階段 UI skeleton
|
|
||||||
renderStages();
|
|
||||||
|
|
||||||
// 5. 初始化 log panel(拉既有 buffer)
|
|
||||||
try {
|
|
||||||
const existingLogs = await GetRecentLogs(2000);
|
|
||||||
initLogPanel(existingLogs || []);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('GetRecentLogs failed:', e);
|
|
||||||
initLogPanel([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 綁按鈕 handlers
|
|
||||||
bindHandlers();
|
|
||||||
|
|
||||||
// 7. 訂閱 Wails events
|
|
||||||
subscribeEvents();
|
|
||||||
|
|
||||||
// 8. 初始 state query
|
|
||||||
try {
|
|
||||||
const status = await GetServerStatusV2();
|
|
||||||
handleServerStatus(status);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('GetServerStatusV2 failed:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. 初始化 settings panel
|
|
||||||
initSettingsPanel({
|
|
||||||
prefs: state.prefs,
|
|
||||||
sysInfo: state.sysInfo,
|
|
||||||
getPreferences: GetPreferences,
|
|
||||||
setPreferences: SetPreferences,
|
|
||||||
onLocaleChange: (newLoc) => {
|
|
||||||
setLocale(newLoc || detectLocale(''));
|
|
||||||
applyI18n();
|
|
||||||
document.title = t('control.title');
|
|
||||||
renderStages();
|
|
||||||
// re-render current state text
|
|
||||||
if (state.server) setServerState(state.server);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 10. header uptime 時鐘
|
|
||||||
initHeaderClock(() => state.server);
|
|
||||||
|
|
||||||
// 11. Manual mode 監聽:stage 5 skipped 時引導使用者點 Open in Browser
|
|
||||||
// 對齊 Design Spec §4.1
|
|
||||||
onManualModeChange((enabled) => {
|
|
||||||
setPrimaryCTAPulse(enabled);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 處理 server status ----------
|
function showError(msg) {
|
||||||
function handleServerStatus(status) {
|
statusEl.hidden = true;
|
||||||
if (!status) return;
|
errorEl.textContent = msg;
|
||||||
state.server = status;
|
errorEl.hidden = false;
|
||||||
setServerState(status);
|
|
||||||
updateServerMeta(status);
|
|
||||||
updatePrimaryControls(status);
|
|
||||||
|
|
||||||
// Error state runtime banner(非 startup error)
|
|
||||||
if (status.state === STATE_ERROR && status.lastError && !state.starting) {
|
|
||||||
showErrorBanner(status.lastError);
|
|
||||||
} else if (status.state !== STATE_ERROR) {
|
|
||||||
hideErrorBanner();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 按鈕 handlers ----------
|
// 等 Wails runtime 就緒再開始輪詢
|
||||||
function bindHandlers() {
|
if (window.runtime) {
|
||||||
const $ = (id) => document.getElementById(id);
|
poll();
|
||||||
|
|
||||||
// Open in Browser
|
|
||||||
$('btn-open-browser').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await OpenInBrowser('');
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Failed to open browser: ' + e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start
|
|
||||||
$('btn-start').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await StartServer();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Start failed: ' + e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Manage menu
|
|
||||||
const manageBtn = $('btn-manage');
|
|
||||||
const manageMenu = $('manage-menu');
|
|
||||||
manageBtn.addEventListener('click', (ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
const hidden = manageMenu.hasAttribute('hidden');
|
|
||||||
if (hidden) {
|
|
||||||
manageMenu.removeAttribute('hidden');
|
|
||||||
manageBtn.setAttribute('aria-expanded', 'true');
|
|
||||||
} else {
|
|
||||||
manageMenu.setAttribute('hidden', '');
|
|
||||||
manageBtn.setAttribute('aria-expanded', 'false');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.addEventListener('click', () => {
|
|
||||||
if (!manageMenu.hasAttribute('hidden')) {
|
|
||||||
manageMenu.setAttribute('hidden', '');
|
|
||||||
manageBtn.setAttribute('aria-expanded', 'false');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$('mi-stop').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await StopServer();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Stop failed: ' + e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('mi-restart').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await RestartServer();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Restart failed: ' + e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('mi-open-folder').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await RevealLogsFolder();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Open folder failed: ' + e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log actions
|
|
||||||
$('btn-clear-log').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await ClearLogs();
|
|
||||||
clearLog();
|
|
||||||
showToast(t('control.log.clearToast'));
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Clear failed: ' + e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('btn-copy-log').addEventListener('click', async () => {
|
|
||||||
const output = document.getElementById('log-output');
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(output.innerText || '');
|
|
||||||
showToast(t('control.log.copied'));
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Copy failed: ' + e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('btn-export-log').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
const path = await ExportLog();
|
|
||||||
showToast(t('control.log.exported', { path }));
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Export failed: ' + e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('btn-open-folder').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await RevealLogsFolder();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Open folder failed: ' + e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter
|
|
||||||
$('filter-input').addEventListener('input', (e) => applyLogFilter({ text: e.target.value }));
|
|
||||||
$('level-filter').addEventListener('change', (e) => applyLogFilter({ level: e.target.value }));
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
$('btn-settings').addEventListener('click', openSettings);
|
|
||||||
|
|
||||||
// Startup error actions
|
|
||||||
$('btn-retry').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await RestartStartupSequence();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Retry failed: ' + e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('btn-view-log').addEventListener('click', () => {
|
|
||||||
hideStartupPanel();
|
|
||||||
flashLastError();
|
|
||||||
});
|
|
||||||
// Report 按鈕 disabled(coming soon)
|
|
||||||
|
|
||||||
// Error banner
|
|
||||||
$('banner-restart').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await RestartServer();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Restart failed: ' + e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('banner-view').addEventListener('click', () => {
|
|
||||||
flashLastError();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 鍵盤快捷鍵:⌘F / Ctrl+F focus filter
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
|
|
||||||
e.preventDefault();
|
|
||||||
$('filter-input').focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 訂閱 Wails events ----------
|
|
||||||
function subscribeEvents() {
|
|
||||||
// server state / error / recovered
|
|
||||||
EventsOn('server:state-change', (payload) => handleServerStatus(payload));
|
|
||||||
EventsOn('server:error', (payload) => {
|
|
||||||
// Go 端 server_control.go L184/L361 emit payload key 為 "reason"(不是 "error")
|
|
||||||
if (payload && payload.reason) showErrorBanner(payload.reason);
|
|
||||||
});
|
|
||||||
EventsOn('server:recovered', () => {
|
|
||||||
hideErrorBanner();
|
|
||||||
showToast('Server recovered');
|
|
||||||
});
|
|
||||||
|
|
||||||
// log stream
|
|
||||||
EventsOn('log:append', (entries) => {
|
|
||||||
if (Array.isArray(entries)) appendLogs(entries);
|
|
||||||
else if (entries) appendLogs([entries]);
|
|
||||||
});
|
|
||||||
EventsOn('log:clear', () => clearLog());
|
|
||||||
|
|
||||||
// startup pipeline
|
|
||||||
EventsOn('startup:progress', (ev) => {
|
|
||||||
if (!state.starting) {
|
|
||||||
state.starting = true;
|
|
||||||
showStartupPanel();
|
|
||||||
}
|
|
||||||
updateStage(ev);
|
|
||||||
});
|
|
||||||
EventsOn('startup:stage-timeout', (ev) => {
|
|
||||||
markStageTimeout(ev);
|
|
||||||
});
|
|
||||||
EventsOn('startup:error', (ev) => {
|
|
||||||
showStartupError(ev);
|
|
||||||
});
|
|
||||||
EventsOn('startup:ready', () => {
|
|
||||||
state.starting = false;
|
|
||||||
hideStartupPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
// shutdown modal(M8-4 1 秒後顯示)
|
|
||||||
EventsOn('shutdown:modal-show', () => {
|
|
||||||
const m = document.getElementById('shutdown-modal');
|
|
||||||
if (m) m.removeAttribute('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
// app level fatal(保留相容)
|
|
||||||
EventsOn('app:error', (msg) => {
|
|
||||||
showErrorBanner(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 啟動 ----------
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
} else {
|
||||||
init();
|
window.addEventListener('load', () => setTimeout(poll, 200));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,126 +0,0 @@
|
|||||||
// control-panel.js — Header / Status / Primary controls / Error banner / Toast
|
|
||||||
import { t } from './i18n.js';
|
|
||||||
|
|
||||||
// ---------- Server state 常數 ----------
|
|
||||||
// 必須與 Go 端 server_control.go §ServerState 常數對齊(全部 lowercase)。
|
|
||||||
// 千萬不要改成 PascalCase,會讓 state 比對全面失敗。
|
|
||||||
export const STATE_IDLE = 'idle';
|
|
||||||
export const STATE_STARTING = 'starting';
|
|
||||||
export const STATE_RUNNING = 'running';
|
|
||||||
export const STATE_STOPPING = 'stopping';
|
|
||||||
export const STATE_STOPPED = 'stopped';
|
|
||||||
export const STATE_ERROR = 'error';
|
|
||||||
|
|
||||||
// ---------- Server state ----------
|
|
||||||
export function setServerState(status) {
|
|
||||||
const root = document.getElementById('app');
|
|
||||||
const dot = document.getElementById('status-dot');
|
|
||||||
const textEl = document.getElementById('status-text');
|
|
||||||
if (!status) return;
|
|
||||||
const s = status.state || STATE_IDLE;
|
|
||||||
root.setAttribute('data-state', s);
|
|
||||||
dot.className = 'status-dot state-' + s;
|
|
||||||
|
|
||||||
let text;
|
|
||||||
switch (s) {
|
|
||||||
case STATE_IDLE: text = t('control.status.idle'); break;
|
|
||||||
case STATE_STARTING: text = t('control.status.starting'); break;
|
|
||||||
case STATE_RUNNING:
|
|
||||||
text = t('control.status.running');
|
|
||||||
if (status.port) text += ' · :' + status.port;
|
|
||||||
break;
|
|
||||||
case STATE_STOPPING: text = t('control.status.stopping'); break;
|
|
||||||
case STATE_STOPPED: text = t('control.status.stopped'); break;
|
|
||||||
case STATE_ERROR: text = t('control.status.error', { reason: (status.lastError || '').slice(0, 80) }); break;
|
|
||||||
default: text = s;
|
|
||||||
}
|
|
||||||
textEl.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Server meta ----------
|
|
||||||
let headerClockTimer = null;
|
|
||||||
export function updateServerMeta(status) {
|
|
||||||
const portEl = document.getElementById('meta-port');
|
|
||||||
const pidEl = document.getElementById('meta-pid');
|
|
||||||
portEl.textContent = status && status.port ? String(status.port) : '—';
|
|
||||||
pidEl.textContent = status && status.pid ? String(status.pid) : '—';
|
|
||||||
// uptime 由 initHeaderClock 定時刷新
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initHeaderClock(getServer) {
|
|
||||||
if (headerClockTimer) clearInterval(headerClockTimer);
|
|
||||||
const uptimeEl = document.getElementById('meta-uptime');
|
|
||||||
headerClockTimer = setInterval(() => {
|
|
||||||
const s = getServer();
|
|
||||||
if (!s || !s.startedAt || s.state !== STATE_RUNNING) {
|
|
||||||
uptimeEl.textContent = '—';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ms = Date.now() - s.startedAt;
|
|
||||||
uptimeEl.textContent = formatUptime(ms);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUptime(ms) {
|
|
||||||
if (ms < 0) ms = 0;
|
|
||||||
const s = Math.floor(ms / 1000);
|
|
||||||
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
|
|
||||||
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
|
||||||
const ss = String(s % 60).padStart(2, '0');
|
|
||||||
return `${hh}:${mm}:${ss}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Primary controls enable/disable ----------
|
|
||||||
export function updatePrimaryControls(status) {
|
|
||||||
const s = status && status.state;
|
|
||||||
const openBtn = document.getElementById('btn-open-browser');
|
|
||||||
const startBtn = document.getElementById('btn-start');
|
|
||||||
const manageBtn = document.getElementById('btn-manage');
|
|
||||||
const miStop = document.getElementById('mi-stop');
|
|
||||||
const miRestart = document.getElementById('mi-restart');
|
|
||||||
|
|
||||||
openBtn.disabled = s !== STATE_RUNNING;
|
|
||||||
startBtn.disabled = !(s === STATE_STOPPED || s === STATE_IDLE || s === STATE_ERROR);
|
|
||||||
manageBtn.disabled = !(s === STATE_RUNNING || s === STATE_ERROR);
|
|
||||||
miStop.disabled = s !== STATE_RUNNING;
|
|
||||||
miRestart.disabled = !(s === STATE_RUNNING || s === STATE_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Error banner ----------
|
|
||||||
export function showErrorBanner(errorMsg) {
|
|
||||||
const banner = document.getElementById('error-banner');
|
|
||||||
const desc = document.getElementById('banner-desc');
|
|
||||||
desc.textContent = errorMsg || '';
|
|
||||||
banner.removeAttribute('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hideErrorBanner() {
|
|
||||||
const banner = document.getElementById('error-banner');
|
|
||||||
banner.setAttribute('hidden', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Primary CTA pulse(引導使用者點擊 Open in Browser) ----------
|
|
||||||
// 對齊 Design Spec v2.1 startup-progress.md §4.1(stage 6 manual hint mode)
|
|
||||||
// 於 stage 5 skipped 時由 app.js 主動開啟,成功建立 WS 連線或 panel 關閉時取消
|
|
||||||
export function setPrimaryCTAPulse(enabled) {
|
|
||||||
const openBtn = document.getElementById('btn-open-browser');
|
|
||||||
if (!openBtn) return;
|
|
||||||
if (enabled) {
|
|
||||||
openBtn.classList.add('pulse-cta');
|
|
||||||
} else {
|
|
||||||
openBtn.classList.remove('pulse-cta');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Toast ----------
|
|
||||||
let toastTimer = null;
|
|
||||||
export function showToast(message, duration = 3000) {
|
|
||||||
const el = document.getElementById('toast');
|
|
||||||
if (!el) return;
|
|
||||||
el.textContent = message;
|
|
||||||
el.removeAttribute('hidden');
|
|
||||||
if (toastTimer) clearTimeout(toastTimer);
|
|
||||||
toastTimer = setTimeout(() => {
|
|
||||||
el.setAttribute('hidden', '');
|
|
||||||
}, duration);
|
|
||||||
}
|
|
||||||
@ -1,222 +0,0 @@
|
|||||||
// visionA-local 控制台 i18n
|
|
||||||
// namespace: desktop-control
|
|
||||||
// 對齊 Design Spec v2.1 control-panel.md §9 + startup-progress.md §7
|
|
||||||
|
|
||||||
const dict = {
|
|
||||||
'zh-TW': {
|
|
||||||
'control.title': 'visionA-local · 伺服器控制台',
|
|
||||||
'control.status.idle': '閒置',
|
|
||||||
'control.status.starting': '啟動中...',
|
|
||||||
'control.status.running': '執行中',
|
|
||||||
'control.status.runningBrowserOpened': '執行中 · 已開啟瀏覽器',
|
|
||||||
'control.status.stopping': '停止中...',
|
|
||||||
'control.status.stopped': '已停止',
|
|
||||||
'control.status.error': '錯誤:{reason}',
|
|
||||||
'control.meta.port': '連接埠',
|
|
||||||
'control.meta.uptime': '執行時間',
|
|
||||||
'control.meta.pid': '程序 ID',
|
|
||||||
'control.meta.version': '版本',
|
|
||||||
'control.action.openBrowser': '在瀏覽器開啟',
|
|
||||||
'control.action.start': '啟動',
|
|
||||||
'control.action.stop': '停止',
|
|
||||||
'control.action.restart': '重新啟動',
|
|
||||||
'control.action.manage': '管理',
|
|
||||||
'control.action.stopServer': '停止伺服器',
|
|
||||||
'control.action.restartServer': '重新啟動伺服器',
|
|
||||||
'control.log.followTail': '自動跟隨最新',
|
|
||||||
'control.log.showTimestamps': '顯示時間戳',
|
|
||||||
'control.log.filterPlaceholder': '過濾 log...',
|
|
||||||
'control.log.jumpToLatest': '跳到最新',
|
|
||||||
'control.log.clear': '清空',
|
|
||||||
'control.log.clearToast': '已清空 log',
|
|
||||||
'control.log.copy': '複製',
|
|
||||||
'control.log.copied': '已複製到剪貼簿',
|
|
||||||
'control.log.copyPrivacyHint': 'Log 可能包含檔名與裝置資訊,請注意分享對象',
|
|
||||||
'control.log.export': '匯出 log',
|
|
||||||
'control.log.exported': '已匯出到 {path}',
|
|
||||||
'control.log.openFolder': '開啟 log 資料夾',
|
|
||||||
'control.log.lines': '行數:{current} / {max}',
|
|
||||||
'control.footer.closeWarning': '⚠ 關閉此視窗會停止伺服器',
|
|
||||||
'control.error.title': '伺服器無法啟動',
|
|
||||||
'control.error.description': '{reason}',
|
|
||||||
'control.error.restartButton': '重新啟動伺服器',
|
|
||||||
'control.error.viewLogDetails': '檢視 log 詳情',
|
|
||||||
'control.error.reportButton': '回報問題...',
|
|
||||||
'control.shutdown.stopping': '正在停止伺服器…',
|
|
||||||
// startup progress
|
|
||||||
'startup.panel.title': '正在啟動 visionA-local',
|
|
||||||
'startup.panel.ariaLabel': '啟動進度:階段 {current} / {max}',
|
|
||||||
'startup.progressLabel': '進度 {current} / {max}',
|
|
||||||
'startup.progressWithElapsed': '進度 {current} / {max} · 已等待 {elapsed} 秒',
|
|
||||||
'startup.stage.1.label': '初始化控制台',
|
|
||||||
'startup.stage.1.description': '準備 visionA-local 桌面環境',
|
|
||||||
'startup.stage.2.label': '檢查 Python 執行環境',
|
|
||||||
'startup.stage.2.description': '首次啟動可能需要較長時間',
|
|
||||||
'startup.stage.3.label': '啟動本機伺服器',
|
|
||||||
'startup.stage.3.description': '在 127.0.0.1:3721 啟動服務',
|
|
||||||
'startup.stage.4.label': '偵測 Kneron 裝置',
|
|
||||||
'startup.stage.4.description': '掃描已連接的硬體',
|
|
||||||
'startup.stage.5.label': '開啟瀏覽器',
|
|
||||||
'startup.stage.5.description': '在預設瀏覽器開啟 Web UI',
|
|
||||||
'startup.stage.5.skipped.label': '跳過(依偏好設定)',
|
|
||||||
'startup.stage.6.label': '等待 Web UI 連線',
|
|
||||||
'startup.stage.6.description': '正在與瀏覽器建立即時連線',
|
|
||||||
'startup.stage.6.manualHint': '請點擊控制台的「在瀏覽器開啟」按鈕',
|
|
||||||
'startup.status.pending': '等待中',
|
|
||||||
'startup.status.running': '進行中',
|
|
||||||
'startup.status.done': '完成',
|
|
||||||
'startup.status.failed': '失敗',
|
|
||||||
'startup.status.skipped': '跳過(依偏好設定)',
|
|
||||||
'startup.timeout.message': '這個步驟花的時間比預期久,正在重試...',
|
|
||||||
'startup.error.title': '啟動失敗',
|
|
||||||
'startup.error.description.timeout': '啟動時間超過 60 秒,可能是系統環境異常或網路中斷。',
|
|
||||||
'startup.error.description.stageFailed': '階段「{stageLabel}」執行失敗。',
|
|
||||||
'startup.error.failedStage': '失敗階段:{n} · {label}',
|
|
||||||
'startup.error.retry': '重試',
|
|
||||||
'startup.error.viewLog': '檢視 log',
|
|
||||||
'startup.error.report': '回報問題',
|
|
||||||
// settings
|
|
||||||
'settings.title': '設定',
|
|
||||||
'settings.autoOpenBrowser.label': '啟動時自動開啟瀏覽器',
|
|
||||||
'settings.autoOpenBrowser.hintLinux': 'Linux 桌面環境差異大,預設關閉',
|
|
||||||
'settings.language.label': '語言',
|
|
||||||
'settings.about.title': '關於',
|
|
||||||
},
|
|
||||||
'en': {
|
|
||||||
'control.title': 'visionA-local · Server Control',
|
|
||||||
'control.status.idle': 'Idle',
|
|
||||||
'control.status.starting': 'Starting...',
|
|
||||||
'control.status.running': 'Running',
|
|
||||||
'control.status.runningBrowserOpened': 'Running · Browser opened',
|
|
||||||
'control.status.stopping': 'Stopping...',
|
|
||||||
'control.status.stopped': 'Stopped',
|
|
||||||
'control.status.error': 'Error: {reason}',
|
|
||||||
'control.meta.port': 'Port',
|
|
||||||
'control.meta.uptime': 'Uptime',
|
|
||||||
'control.meta.pid': 'PID',
|
|
||||||
'control.meta.version': 'Version',
|
|
||||||
'control.action.openBrowser': 'Open in Browser',
|
|
||||||
'control.action.start': 'Start',
|
|
||||||
'control.action.stop': 'Stop',
|
|
||||||
'control.action.restart': 'Restart',
|
|
||||||
'control.action.manage': 'Manage',
|
|
||||||
'control.action.stopServer': 'Stop server',
|
|
||||||
'control.action.restartServer': 'Restart server',
|
|
||||||
'control.log.followTail': 'Follow tail',
|
|
||||||
'control.log.showTimestamps': 'Show timestamps',
|
|
||||||
'control.log.filterPlaceholder': 'Filter...',
|
|
||||||
'control.log.jumpToLatest': 'Jump to latest',
|
|
||||||
'control.log.clear': 'Clear',
|
|
||||||
'control.log.clearToast': 'Log cleared',
|
|
||||||
'control.log.copy': 'Copy',
|
|
||||||
'control.log.copied': 'Copied to clipboard',
|
|
||||||
'control.log.copyPrivacyHint': 'Log may contain filenames and device info. Share with care.',
|
|
||||||
'control.log.export': 'Export log',
|
|
||||||
'control.log.exported': 'Exported to {path}',
|
|
||||||
'control.log.openFolder': 'Open log folder',
|
|
||||||
'control.log.lines': 'Lines: {current} / {max}',
|
|
||||||
'control.footer.closeWarning': '⚠ Closing this window will stop the server',
|
|
||||||
'control.error.title': 'Server failed to start',
|
|
||||||
'control.error.description': '{reason}',
|
|
||||||
'control.error.restartButton': 'Restart Server',
|
|
||||||
'control.error.viewLogDetails': 'View log details',
|
|
||||||
'control.error.reportButton': 'Report...',
|
|
||||||
'control.shutdown.stopping': 'Stopping server…',
|
|
||||||
// startup progress
|
|
||||||
'startup.panel.title': 'Starting visionA-local',
|
|
||||||
'startup.panel.ariaLabel': 'Startup progress: stage {current} / {max}',
|
|
||||||
'startup.progressLabel': 'Progress {current} / {max}',
|
|
||||||
'startup.progressWithElapsed': 'Progress {current} / {max} · {elapsed}s elapsed',
|
|
||||||
'startup.stage.1.label': 'Initializing control panel',
|
|
||||||
'startup.stage.1.description': 'Preparing visionA-local desktop',
|
|
||||||
'startup.stage.2.label': 'Checking Python runtime',
|
|
||||||
'startup.stage.2.description': 'First launch may take longer',
|
|
||||||
'startup.stage.3.label': 'Starting local server',
|
|
||||||
'startup.stage.3.description': 'Starting service on 127.0.0.1:3721',
|
|
||||||
'startup.stage.4.label': 'Detecting Kneron devices',
|
|
||||||
'startup.stage.4.description': 'Scanning connected hardware',
|
|
||||||
'startup.stage.5.label': 'Opening browser',
|
|
||||||
'startup.stage.5.description': 'Opening the Web UI in your default browser',
|
|
||||||
'startup.stage.5.skipped.label': 'Skipped (per preference)',
|
|
||||||
'startup.stage.6.label': 'Waiting for Web UI to connect',
|
|
||||||
'startup.stage.6.description': 'Establishing realtime connection with the browser',
|
|
||||||
'startup.stage.6.manualHint': 'Please click "Open in Browser" in the Control Panel',
|
|
||||||
'startup.status.pending': 'Waiting',
|
|
||||||
'startup.status.running': 'Running',
|
|
||||||
'startup.status.done': 'Done',
|
|
||||||
'startup.status.failed': 'Failed',
|
|
||||||
'startup.status.skipped': 'Skipped (per preference)',
|
|
||||||
'startup.timeout.message': 'This step is taking longer than expected, retrying...',
|
|
||||||
'startup.error.title': 'Startup failed',
|
|
||||||
'startup.error.description.timeout': 'Startup exceeded 60 seconds. Your environment may have issues or the network is interrupted.',
|
|
||||||
'startup.error.description.stageFailed': 'Stage "{stageLabel}" failed.',
|
|
||||||
'startup.error.failedStage': 'Failed stage: {n} · {label}',
|
|
||||||
'startup.error.retry': 'Retry',
|
|
||||||
'startup.error.viewLog': 'View Log',
|
|
||||||
'startup.error.report': 'Report Issue',
|
|
||||||
// settings
|
|
||||||
'settings.title': 'Settings',
|
|
||||||
'settings.autoOpenBrowser.label': 'Auto-open browser on startup',
|
|
||||||
'settings.autoOpenBrowser.hintLinux': 'Linux desktop environments vary, disabled by default',
|
|
||||||
'settings.language.label': 'Language',
|
|
||||||
'settings.about.title': 'About',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentLocale = 'zh-TW';
|
|
||||||
|
|
||||||
// 根據 navigator.language 或儲存值決定 locale
|
|
||||||
export function detectLocale(preferredLocale) {
|
|
||||||
// 1. 使用者明確指定(preferences)
|
|
||||||
if (preferredLocale && dict[preferredLocale]) return preferredLocale;
|
|
||||||
// 2. localStorage
|
|
||||||
const stored = localStorage.getItem('vl-locale');
|
|
||||||
if (stored && dict[stored]) return stored;
|
|
||||||
// 3. navigator.language
|
|
||||||
const lang = (navigator.language || navigator.userLanguage || '').toLowerCase();
|
|
||||||
if (lang.startsWith('zh')) return 'zh-TW';
|
|
||||||
if (lang.startsWith('en')) return 'en';
|
|
||||||
// 4. fallback
|
|
||||||
return 'zh-TW';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setLocale(locale) {
|
|
||||||
if (dict[locale]) {
|
|
||||||
currentLocale = locale;
|
|
||||||
localStorage.setItem('vl-locale', locale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLocale() {
|
|
||||||
return currentLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
// t('key', {param: value}) → 翻譯字串並替換 {param}
|
|
||||||
export function t(key, params) {
|
|
||||||
let s = (dict[currentLocale] && dict[currentLocale][key]);
|
|
||||||
if (s === undefined) {
|
|
||||||
// fallback en
|
|
||||||
s = (dict.en && dict.en[key]) || key;
|
|
||||||
}
|
|
||||||
if (params) {
|
|
||||||
for (const k in params) {
|
|
||||||
s = s.replace(new RegExp('\\{' + k + '\\}', 'g'), params[k]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 對 DOM 中所有 [data-i18n] / [data-i18n-placeholder] 執行翻譯
|
|
||||||
export function applyI18n(root) {
|
|
||||||
const scope = root || document;
|
|
||||||
scope.querySelectorAll('[data-i18n]').forEach(el => {
|
|
||||||
const key = el.getAttribute('data-i18n');
|
|
||||||
el.textContent = t(key);
|
|
||||||
});
|
|
||||||
scope.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
|
||||||
const key = el.getAttribute('data-i18n-placeholder');
|
|
||||||
el.setAttribute('placeholder', t(key));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LOCALES = Object.keys(dict);
|
|
||||||
@ -1,191 +1,25 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-TW">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>visionA-local · Server Control</title>
|
<title>visionA Local</title>
|
||||||
<link rel="icon" type="image/png" href="icon.png">
|
<link rel="icon" type="image/png" href="icon.png">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" class="control-panel" data-state="idle">
|
<div id="app">
|
||||||
<!-- Header -->
|
<div class="splash">
|
||||||
<header class="header">
|
<img class="logo-icon" src="icon.png" alt="visionA Local">
|
||||||
<img class="brand-logo" src="icon.png" alt="visionA-local">
|
<div class="brand">
|
||||||
<div class="brand-info">
|
<div class="brand-name">visionA <span class="brand-accent">Local</span></div>
|
||||||
<h1 class="brand-name">visionA-local</h1>
|
<div class="brand-tagline">Edge AI Workspace</div>
|
||||||
<div class="status-line">
|
|
||||||
<span class="status-dot" id="status-dot" aria-hidden="true"></span>
|
|
||||||
<span class="status-text" id="status-text" role="status" aria-live="polite">Idle</span>
|
|
||||||
</div>
|
|
||||||
<dl class="server-meta" id="server-meta">
|
|
||||||
<div class="meta-item"><dt data-i18n="control.meta.port">Port</dt><dd id="meta-port">—</dd></div>
|
|
||||||
<div class="meta-item"><dt data-i18n="control.meta.uptime">Uptime</dt><dd id="meta-uptime">—</dd></div>
|
|
||||||
<div class="meta-item"><dt data-i18n="control.meta.pid">PID</dt><dd id="meta-pid">—</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<div class="brand-version">
|
|
||||||
<span id="app-version">v0.1.0</span>
|
|
||||||
<button class="icon-btn" id="btn-settings" type="button" title="Settings" aria-label="Settings">⚙</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Primary Controls -->
|
|
||||||
<section class="primary-controls" aria-label="Server controls">
|
|
||||||
<button class="btn btn-primary" id="btn-open-browser" type="button" disabled>
|
|
||||||
<span aria-hidden="true">🌐</span>
|
|
||||||
<span data-i18n="control.action.openBrowser">Open in Browser</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline" id="btn-start" type="button">
|
|
||||||
<span aria-hidden="true">▶</span>
|
|
||||||
<span data-i18n="control.action.start">Start</span>
|
|
||||||
</button>
|
|
||||||
<div class="manage-wrapper">
|
|
||||||
<button class="btn btn-outline" id="btn-manage" type="button" disabled aria-haspopup="menu" aria-expanded="false">
|
|
||||||
<span data-i18n="control.action.manage">Manage</span> <span aria-hidden="true">▾</span>
|
|
||||||
</button>
|
|
||||||
<div class="manage-menu" id="manage-menu" role="menu" hidden>
|
|
||||||
<button role="menuitem" id="mi-stop" class="menu-item menu-item-danger">
|
|
||||||
<span data-i18n="control.action.stopServer">Stop server</span>
|
|
||||||
</button>
|
|
||||||
<button role="menuitem" id="mi-restart" class="menu-item">
|
|
||||||
<span data-i18n="control.action.restartServer">Restart server</span>
|
|
||||||
</button>
|
|
||||||
<div class="menu-divider"></div>
|
|
||||||
<button role="menuitem" id="mi-open-folder" class="menu-item">
|
|
||||||
<span data-i18n="control.log.openFolder">Open log folder</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Log controls -->
|
|
||||||
<section class="log-controls" aria-label="Log controls">
|
|
||||||
<label class="checkbox"><input type="checkbox" id="cb-follow-tail" checked> <span data-i18n="control.log.followTail">Follow tail</span></label>
|
|
||||||
<label class="checkbox"><input type="checkbox" id="cb-show-ts" checked> <span data-i18n="control.log.showTimestamps">Show timestamps</span></label>
|
|
||||||
<label class="filter-wrapper">
|
|
||||||
<span aria-hidden="true">🔍</span>
|
|
||||||
<input type="search" id="filter-input" data-i18n-placeholder="control.log.filterPlaceholder" placeholder="Filter..." aria-label="Filter logs">
|
|
||||||
</label>
|
|
||||||
<select id="level-filter" aria-label="Level filter">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="debug">DEBUG</option>
|
|
||||||
<option value="info">INFO</option>
|
|
||||||
<option value="warn">WARN</option>
|
|
||||||
<option value="error">ERROR</option>
|
|
||||||
</select>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Startup progress panel (顯示於 Starting state) -->
|
|
||||||
<section class="startup-panel" id="startup-panel" role="progressbar" aria-valuemin="0" aria-valuemax="6" aria-valuenow="0" hidden>
|
|
||||||
<h2 class="startup-title" id="startup-title" data-i18n="startup.panel.title">Starting visionA-local</h2>
|
|
||||||
<div class="stages" id="stages"></div>
|
|
||||||
<div class="progress-row">
|
|
||||||
<div class="progress-bar" id="progress-bar" aria-hidden="true"></div>
|
|
||||||
<span class="progress-text" id="progress-text"></span>
|
|
||||||
</div>
|
|
||||||
<div class="sr-only" id="startup-live" aria-live="polite" aria-atomic="true"></div>
|
|
||||||
|
|
||||||
<!-- Error mode -->
|
|
||||||
<div class="startup-error" id="startup-error" hidden>
|
|
||||||
<h3 class="error-title" data-i18n="startup.error.title">Startup failed</h3>
|
|
||||||
<p class="error-desc" id="error-desc"></p>
|
|
||||||
<p class="error-stage" id="error-stage"></p>
|
|
||||||
<div class="error-actions">
|
|
||||||
<button class="btn btn-primary" id="btn-retry" type="button">
|
|
||||||
<span aria-hidden="true">🔄</span>
|
|
||||||
<span data-i18n="startup.error.retry">Retry</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost" id="btn-view-log" type="button">
|
|
||||||
<span aria-hidden="true">📋</span>
|
|
||||||
<span data-i18n="startup.error.viewLog">View Log</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost" id="btn-report" type="button" disabled title="Coming soon">
|
|
||||||
<span aria-hidden="true">🐞</span>
|
|
||||||
<span data-i18n="startup.error.report">Report Issue</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Error banner (for runtime server errors) -->
|
|
||||||
<section class="error-banner" id="error-banner" role="alert" hidden>
|
|
||||||
<span class="banner-icon" aria-hidden="true">⚠</span>
|
|
||||||
<div class="banner-content">
|
|
||||||
<strong class="banner-title" data-i18n="control.error.title">Server failed to start</strong>
|
|
||||||
<p class="banner-desc" id="banner-desc"></p>
|
|
||||||
<div class="banner-actions">
|
|
||||||
<button class="btn btn-primary btn-sm" id="banner-restart" data-i18n="control.error.restartButton">Restart Server</button>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="banner-view" data-i18n="control.error.viewLogDetails">View log details</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Log panel -->
|
|
||||||
<section class="log-panel" id="log-panel" aria-label="Log output">
|
|
||||||
<output id="log-output" aria-live="polite" aria-atomic="false"></output>
|
|
||||||
<button class="jump-latest" id="btn-jump-latest" type="button" hidden data-i18n="control.log.jumpToLatest">Jump to latest</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Log actions -->
|
|
||||||
<section class="log-actions">
|
|
||||||
<button class="btn btn-ghost btn-sm" id="btn-clear-log" data-i18n="control.log.clear">Clear</button>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="btn-copy-log" data-i18n="control.log.copy">Copy</button>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="btn-export-log" data-i18n="control.log.export">Export log</button>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="btn-open-folder" data-i18n="control.log.openFolder">Open log folder</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="footer">
|
|
||||||
<span class="footer-left" id="footer-lines">Lines: 0 / 2000</span>
|
|
||||||
<span class="footer-right" id="footer-warning" data-i18n="control.footer.closeWarning">⚠ Closing this window will stop the server</span>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Settings modal -->
|
|
||||||
<div class="modal-backdrop" id="settings-modal" hidden>
|
|
||||||
<div class="modal" role="dialog" aria-labelledby="settings-title" aria-modal="true">
|
|
||||||
<header class="modal-header">
|
|
||||||
<h2 id="settings-title">Settings</h2>
|
|
||||||
<button class="icon-btn" id="btn-close-settings" aria-label="Close">✕</button>
|
|
||||||
</header>
|
|
||||||
<div class="modal-body">
|
|
||||||
<label class="setting-row">
|
|
||||||
<div class="setting-text">
|
|
||||||
<div class="setting-label" data-i18n="settings.autoOpenBrowser.label">Auto-open browser on startup</div>
|
|
||||||
<div class="setting-hint" id="auto-open-hint"></div>
|
|
||||||
</div>
|
|
||||||
<input type="checkbox" id="pref-auto-open">
|
|
||||||
</label>
|
|
||||||
<label class="setting-row">
|
|
||||||
<div class="setting-text">
|
|
||||||
<div class="setting-label" data-i18n="settings.language.label">Language</div>
|
|
||||||
</div>
|
|
||||||
<select id="pref-locale">
|
|
||||||
<option value="">Auto</option>
|
|
||||||
<option value="zh-TW">繁體中文</option>
|
|
||||||
<option value="en">English</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div class="about-section">
|
|
||||||
<h3 data-i18n="settings.about.title">About</h3>
|
|
||||||
<dl class="about-list" id="about-list"></dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="status" id="status">正在啟動伺服器...</div>
|
||||||
|
<div class="error" id="error" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shutdown modal -->
|
|
||||||
<div class="modal-backdrop shutdown-modal" id="shutdown-modal" hidden>
|
|
||||||
<div class="modal shutdown-dialog" role="alert">
|
|
||||||
<div class="spinner-lg" aria-hidden="true"></div>
|
|
||||||
<p data-i18n="control.shutdown.stopping">Stopping server…</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toast -->
|
|
||||||
<div class="toast" id="toast" hidden></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,175 +0,0 @@
|
|||||||
// log-panel.js — Log ring buffer DOM rendering, filter, auto-scroll
|
|
||||||
// 對齊 Design Spec v2.1 control-panel.md §4.4-4.5
|
|
||||||
|
|
||||||
const MAX_LINES = 2000;
|
|
||||||
const buffer = []; // [{ts, level, stream, line}]
|
|
||||||
let autoScroll = true;
|
|
||||||
let filterText = '';
|
|
||||||
let filterLevel = '';
|
|
||||||
|
|
||||||
// ---------- init ----------
|
|
||||||
export function initLogPanel(existing) {
|
|
||||||
buffer.length = 0;
|
|
||||||
if (Array.isArray(existing)) {
|
|
||||||
for (const line of existing) buffer.push(line);
|
|
||||||
}
|
|
||||||
renderAll();
|
|
||||||
updateLineCount();
|
|
||||||
|
|
||||||
const output = document.getElementById('log-output');
|
|
||||||
if (output) {
|
|
||||||
output.addEventListener('scroll', () => {
|
|
||||||
const nearBottom = output.scrollTop + output.clientHeight >= output.scrollHeight - 30;
|
|
||||||
if (!nearBottom) {
|
|
||||||
autoScroll = false;
|
|
||||||
const jumpBtn = document.getElementById('btn-jump-latest');
|
|
||||||
if (jumpBtn) jumpBtn.removeAttribute('hidden');
|
|
||||||
} else {
|
|
||||||
autoScroll = true;
|
|
||||||
const jumpBtn = document.getElementById('btn-jump-latest');
|
|
||||||
if (jumpBtn) jumpBtn.setAttribute('hidden', '');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const jumpBtn = document.getElementById('btn-jump-latest');
|
|
||||||
if (jumpBtn) {
|
|
||||||
jumpBtn.addEventListener('click', () => {
|
|
||||||
autoScroll = true;
|
|
||||||
const el = document.getElementById('log-output');
|
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
jumpBtn.setAttribute('hidden', '');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const followCb = document.getElementById('cb-follow-tail');
|
|
||||||
if (followCb) {
|
|
||||||
followCb.addEventListener('change', (e) => {
|
|
||||||
autoScroll = e.target.checked;
|
|
||||||
if (autoScroll) scrollToBottom();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const tsCb = document.getElementById('cb-show-ts');
|
|
||||||
if (tsCb) {
|
|
||||||
tsCb.addEventListener('change', () => renderAll());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- append from event ----------
|
|
||||||
export function appendLogs(entries) {
|
|
||||||
for (const e of entries) {
|
|
||||||
buffer.push(e);
|
|
||||||
}
|
|
||||||
// ring buffer 裁切
|
|
||||||
if (buffer.length > MAX_LINES) {
|
|
||||||
buffer.splice(0, buffer.length - MAX_LINES);
|
|
||||||
}
|
|
||||||
renderAppend(entries);
|
|
||||||
updateLineCount();
|
|
||||||
if (autoScroll) scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearLog() {
|
|
||||||
buffer.length = 0;
|
|
||||||
const output = document.getElementById('log-output');
|
|
||||||
if (output) output.innerHTML = '';
|
|
||||||
updateLineCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- filter ----------
|
|
||||||
export function applyLogFilter(opts) {
|
|
||||||
if (opts.text !== undefined) filterText = opts.text.toLowerCase();
|
|
||||||
if (opts.level !== undefined) filterLevel = opts.level.toLowerCase();
|
|
||||||
renderAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
function matches(entry) {
|
|
||||||
if (filterLevel && (entry.level || '').toLowerCase() !== filterLevel) return false;
|
|
||||||
if (filterText && !((entry.line || '').toLowerCase().includes(filterText))) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- render ----------
|
|
||||||
function renderAll() {
|
|
||||||
const output = document.getElementById('log-output');
|
|
||||||
if (!output) return;
|
|
||||||
output.innerHTML = '';
|
|
||||||
const showTs = document.getElementById('cb-show-ts')?.checked !== false;
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const e of buffer) {
|
|
||||||
if (!matches(e)) continue;
|
|
||||||
frag.appendChild(buildLine(e, showTs));
|
|
||||||
}
|
|
||||||
output.appendChild(frag);
|
|
||||||
if (autoScroll) scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAppend(entries) {
|
|
||||||
const output = document.getElementById('log-output');
|
|
||||||
if (!output) return;
|
|
||||||
const showTs = document.getElementById('cb-show-ts')?.checked !== false;
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const e of entries) {
|
|
||||||
if (!matches(e)) continue;
|
|
||||||
frag.appendChild(buildLine(e, showTs));
|
|
||||||
}
|
|
||||||
output.appendChild(frag);
|
|
||||||
|
|
||||||
// DOM 行數裁切(跟 ring buffer 同步)
|
|
||||||
while (output.childElementCount > MAX_LINES) {
|
|
||||||
output.removeChild(output.firstChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLine(entry, showTs) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
const level = (entry.level || '').toLowerCase();
|
|
||||||
row.className = 'log-line level-' + (level || 'plain');
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
if (showTs && entry.ts) {
|
|
||||||
const d = new Date(entry.ts);
|
|
||||||
const hh = String(d.getHours()).padStart(2, '0');
|
|
||||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
||||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
||||||
html += `<span class="log-ts">${hh}:${mm}:${ss}</span> `;
|
|
||||||
}
|
|
||||||
if (entry.level) {
|
|
||||||
html += `<span class="log-level">${entry.level.toUpperCase().padEnd(5)}</span> `;
|
|
||||||
}
|
|
||||||
html += `<span class="log-msg"></span>`;
|
|
||||||
row.innerHTML = html;
|
|
||||||
row.querySelector('.log-msg').textContent = entry.line || '';
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
|
||||||
const el = document.getElementById('log-output');
|
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLineCount() {
|
|
||||||
const el = document.getElementById('footer-lines');
|
|
||||||
if (!el) return;
|
|
||||||
el.textContent = `Lines: ${buffer.length} / ${MAX_LINES}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 閃爍最後一條 ERROR ----------
|
|
||||||
export function flashLastError() {
|
|
||||||
const output = document.getElementById('log-output');
|
|
||||||
if (!output) return;
|
|
||||||
// 找最後一條 ERROR row
|
|
||||||
const errors = output.querySelectorAll('.log-line.level-error');
|
|
||||||
if (errors.length === 0) {
|
|
||||||
// fallback: 捲到最底
|
|
||||||
scrollToBottom();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = errors[errors.length - 1];
|
|
||||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
// flash 2 次
|
|
||||||
target.classList.remove('flash');
|
|
||||||
void target.offsetWidth;
|
|
||||||
target.classList.add('flash');
|
|
||||||
setTimeout(() => target.classList.remove('flash'), 1500);
|
|
||||||
}
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
// settings-panel.js — Settings modal (Auto-open browser / Language / About)
|
|
||||||
import { t, getLocale } from './i18n.js';
|
|
||||||
|
|
||||||
let ctx = null;
|
|
||||||
|
|
||||||
export function initSettingsPanel(context) {
|
|
||||||
ctx = context;
|
|
||||||
const backdrop = document.getElementById('settings-modal');
|
|
||||||
const closeBtn = document.getElementById('btn-close-settings');
|
|
||||||
closeBtn.addEventListener('click', closeSettings);
|
|
||||||
backdrop.addEventListener('click', (e) => {
|
|
||||||
if (e.target === backdrop) closeSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-open browser toggle
|
|
||||||
const autoOpenCb = document.getElementById('pref-auto-open');
|
|
||||||
autoOpenCb.addEventListener('change', async () => {
|
|
||||||
try {
|
|
||||||
const newPrefs = {
|
|
||||||
...(ctx.prefs || {}),
|
|
||||||
autoOpenBrowser: autoOpenCb.checked,
|
|
||||||
};
|
|
||||||
await ctx.setPreferences(newPrefs);
|
|
||||||
ctx.prefs = newPrefs;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('SetPreferences failed:', e);
|
|
||||||
// revert
|
|
||||||
autoOpenCb.checked = !autoOpenCb.checked;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Locale
|
|
||||||
const localeSel = document.getElementById('pref-locale');
|
|
||||||
localeSel.addEventListener('change', async () => {
|
|
||||||
const val = localeSel.value;
|
|
||||||
try {
|
|
||||||
const newPrefs = {
|
|
||||||
...(ctx.prefs || {}),
|
|
||||||
locale: val,
|
|
||||||
};
|
|
||||||
await ctx.setPreferences(newPrefs);
|
|
||||||
ctx.prefs = newPrefs;
|
|
||||||
if (ctx.onLocaleChange) ctx.onLocaleChange(val);
|
|
||||||
paintAboutList();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('SetPreferences locale failed:', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ESC 關
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && !backdrop.hasAttribute('hidden')) {
|
|
||||||
closeSettings();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openSettings() {
|
|
||||||
if (!ctx) return;
|
|
||||||
const backdrop = document.getElementById('settings-modal');
|
|
||||||
const autoOpenCb = document.getElementById('pref-auto-open');
|
|
||||||
const localeSel = document.getElementById('pref-locale');
|
|
||||||
const hintEl = document.getElementById('auto-open-hint');
|
|
||||||
|
|
||||||
// 同步 prefs 到 UI
|
|
||||||
const prefs = ctx.prefs || {};
|
|
||||||
autoOpenCb.checked = !!prefs.autoOpenBrowser;
|
|
||||||
localeSel.value = prefs.locale || '';
|
|
||||||
|
|
||||||
// Linux 提示
|
|
||||||
const plat = (ctx.sysInfo && ctx.sysInfo.platform) || '';
|
|
||||||
if (plat.startsWith('linux')) {
|
|
||||||
hintEl.textContent = t('settings.autoOpenBrowser.hintLinux');
|
|
||||||
} else {
|
|
||||||
hintEl.textContent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
paintAboutList();
|
|
||||||
|
|
||||||
// 標題 i18n
|
|
||||||
document.getElementById('settings-title').textContent = t('settings.title');
|
|
||||||
|
|
||||||
backdrop.removeAttribute('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSettings() {
|
|
||||||
const backdrop = document.getElementById('settings-modal');
|
|
||||||
if (backdrop) backdrop.setAttribute('hidden', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function paintAboutList() {
|
|
||||||
const about = document.getElementById('about-list');
|
|
||||||
if (!about || !ctx || !ctx.sysInfo) return;
|
|
||||||
const s = ctx.sysInfo;
|
|
||||||
about.innerHTML = '';
|
|
||||||
const rows = [
|
|
||||||
[t('control.meta.version'), s.appVersion || '—'],
|
|
||||||
['Build', s.buildTime || '—'],
|
|
||||||
['Platform', s.platform || '—'],
|
|
||||||
['Data dir', s.dataDir || '—'],
|
|
||||||
['Logs dir', s.logsDir || '—'],
|
|
||||||
];
|
|
||||||
for (const [k, v] of rows) {
|
|
||||||
const dt = document.createElement('dt');
|
|
||||||
dt.textContent = k;
|
|
||||||
const dd = document.createElement('dd');
|
|
||||||
dd.textContent = v;
|
|
||||||
about.appendChild(dt);
|
|
||||||
about.appendChild(dd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,281 +0,0 @@
|
|||||||
// startup-panel.js — 6 階段啟動進度面板
|
|
||||||
// 對齊 Design Spec v2.1 startup-progress.md
|
|
||||||
|
|
||||||
import { t } from './i18n.js';
|
|
||||||
|
|
||||||
const TOTAL_STAGES = 6;
|
|
||||||
|
|
||||||
// 本地狀態:stages[1..6] = {status, startedAt, slow, manualHint}
|
|
||||||
const stages = {};
|
|
||||||
for (let i = 1; i <= TOTAL_STAGES; i++) {
|
|
||||||
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 啟動流程旗標:stage 5 skipped → stage 6 進入「manual hint」模式
|
|
||||||
// 對齊 Design Spec §4.1「階段 6 Settings OFF 情境」
|
|
||||||
let manualMode = false;
|
|
||||||
// 觀察者:當進入 manual mode 時通知外層(app.js)加 pulse / enable primary CTA
|
|
||||||
const manualModeListeners = new Set();
|
|
||||||
export function onManualModeChange(fn) {
|
|
||||||
manualModeListeners.add(fn);
|
|
||||||
return () => manualModeListeners.delete(fn);
|
|
||||||
}
|
|
||||||
function emitManualMode(enabled) {
|
|
||||||
manualModeListeners.forEach((fn) => {
|
|
||||||
try { fn(enabled); } catch (e) { console.warn('manualMode listener error:', e); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 渲染 stage 列 skeleton ----------
|
|
||||||
export function renderStages() {
|
|
||||||
const container = document.getElementById('stages');
|
|
||||||
if (!container) return;
|
|
||||||
container.innerHTML = '';
|
|
||||||
for (let i = 1; i <= TOTAL_STAGES; i++) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'stage-item';
|
|
||||||
row.dataset.stage = String(i);
|
|
||||||
row.dataset.state = stages[i].status;
|
|
||||||
row.innerHTML = `
|
|
||||||
<span class="stage-icon" aria-hidden="true"></span>
|
|
||||||
<span class="stage-number">${i} ·</span>
|
|
||||||
<div class="stage-label">
|
|
||||||
<div class="stage-label-primary"></div>
|
|
||||||
<div class="stage-label-secondary"></div>
|
|
||||||
</div>
|
|
||||||
<span class="stage-status"></span>
|
|
||||||
<div class="stage-hint" hidden></div>
|
|
||||||
`;
|
|
||||||
container.appendChild(row);
|
|
||||||
paintStageRow(i);
|
|
||||||
}
|
|
||||||
// 更新標題
|
|
||||||
const titleEl = document.getElementById('startup-title');
|
|
||||||
if (titleEl) titleEl.textContent = t('startup.panel.title');
|
|
||||||
paintProgressBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function paintStageRow(stage) {
|
|
||||||
const row = document.querySelector(`.stage-item[data-stage="${stage}"]`);
|
|
||||||
if (!row) return;
|
|
||||||
const st = stages[stage];
|
|
||||||
row.dataset.state = st.slow && st.status === 'running' ? 'running-slow' : st.status;
|
|
||||||
|
|
||||||
const iconEl = row.querySelector('.stage-icon');
|
|
||||||
const labelPrimary = row.querySelector('.stage-label-primary');
|
|
||||||
const labelSecondary = row.querySelector('.stage-label-secondary');
|
|
||||||
const statusEl = row.querySelector('.stage-status');
|
|
||||||
const hintEl = row.querySelector('.stage-hint');
|
|
||||||
|
|
||||||
// icon
|
|
||||||
let icon = '○';
|
|
||||||
switch (st.status) {
|
|
||||||
case 'pending': icon = '○'; break;
|
|
||||||
case 'running': icon = '<span class="spinner-sm"></span>'; break;
|
|
||||||
case 'completed':
|
|
||||||
case 'done': icon = '✓'; break;
|
|
||||||
case 'failed': icon = '✕'; break;
|
|
||||||
case 'skipped': icon = '⏭'; break;
|
|
||||||
}
|
|
||||||
iconEl.innerHTML = icon;
|
|
||||||
|
|
||||||
// label
|
|
||||||
labelPrimary.textContent = t(`startup.stage.${stage}.label`);
|
|
||||||
// Stage 5 skipped → label secondary 顯示 skipped 原因
|
|
||||||
// Stage 6 manual mode → description 改顯示 manual hint
|
|
||||||
if (stage === 5 && st.status === 'skipped') {
|
|
||||||
labelSecondary.textContent = t('startup.stage.5.skipped.label');
|
|
||||||
} else if (stage === 6 && st.manualHint) {
|
|
||||||
labelSecondary.textContent = t('startup.stage.6.manualHint');
|
|
||||||
} else {
|
|
||||||
labelSecondary.textContent = t(`startup.stage.${stage}.description`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// status text
|
|
||||||
const statusMap = {
|
|
||||||
pending: 'startup.status.pending',
|
|
||||||
running: 'startup.status.running',
|
|
||||||
completed: 'startup.status.done',
|
|
||||||
done: 'startup.status.done',
|
|
||||||
failed: 'startup.status.failed',
|
|
||||||
skipped: 'startup.status.skipped',
|
|
||||||
};
|
|
||||||
statusEl.textContent = t(statusMap[st.status] || 'startup.status.pending');
|
|
||||||
|
|
||||||
// slow hint line
|
|
||||||
// Design Spec §4.1:stage 6 manual mode 不套 20 秒 retry hint(等待人為動作)
|
|
||||||
if (st.slow && st.status === 'running' && !st.manualHint) {
|
|
||||||
hintEl.textContent = t('startup.timeout.message');
|
|
||||||
hintEl.removeAttribute('hidden');
|
|
||||||
} else {
|
|
||||||
hintEl.setAttribute('hidden', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function paintProgressBar() {
|
|
||||||
const bar = document.getElementById('progress-bar');
|
|
||||||
const textEl = document.getElementById('progress-text');
|
|
||||||
if (!bar || !textEl) return;
|
|
||||||
bar.innerHTML = '';
|
|
||||||
let completed = 0;
|
|
||||||
let current = 0;
|
|
||||||
for (let i = 1; i <= TOTAL_STAGES; i++) {
|
|
||||||
const cell = document.createElement('span');
|
|
||||||
cell.className = 'progress-cell state-' + stages[i].status;
|
|
||||||
bar.appendChild(cell);
|
|
||||||
if (stages[i].status === 'completed' || stages[i].status === 'done' || stages[i].status === 'skipped') completed++;
|
|
||||||
if (stages[i].status === 'running') current = i;
|
|
||||||
}
|
|
||||||
const progressNum = current || completed;
|
|
||||||
// 若有 slow 狀態,顯示 elapsed
|
|
||||||
let elapsedText = '';
|
|
||||||
for (let i = 1; i <= TOTAL_STAGES; i++) {
|
|
||||||
if (stages[i].slow && stages[i].status === 'running' && stages[i].startedAt) {
|
|
||||||
const elapsed = Math.floor((Date.now() - stages[i].startedAt) / 1000);
|
|
||||||
elapsedText = t('startup.progressWithElapsed', { current: progressNum, max: TOTAL_STAGES, elapsed });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
textEl.textContent = elapsedText || t('startup.progressLabel', { current: progressNum, max: TOTAL_STAGES });
|
|
||||||
|
|
||||||
// aria
|
|
||||||
const panel = document.getElementById('startup-panel');
|
|
||||||
if (panel) panel.setAttribute('aria-valuenow', String(progressNum));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Panel 顯示 / 隱藏 ----------
|
|
||||||
export function showStartupPanel() {
|
|
||||||
const panel = document.getElementById('startup-panel');
|
|
||||||
if (!panel) return;
|
|
||||||
panel.removeAttribute('hidden');
|
|
||||||
// Error mode 預設隱藏
|
|
||||||
document.getElementById('startup-error').setAttribute('hidden', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hideStartupPanel() {
|
|
||||||
const panel = document.getElementById('startup-panel');
|
|
||||||
if (!panel) return;
|
|
||||||
panel.setAttribute('hidden', '');
|
|
||||||
// reset
|
|
||||||
for (let i = 1; i <= TOTAL_STAGES; i++) {
|
|
||||||
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false };
|
|
||||||
}
|
|
||||||
if (manualMode) {
|
|
||||||
manualMode = false;
|
|
||||||
emitManualMode(false);
|
|
||||||
}
|
|
||||||
renderStages();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 更新階段(收到 startup:progress)----------
|
|
||||||
export function updateStage(ev) {
|
|
||||||
if (!ev || !ev.stage) return;
|
|
||||||
const n = ev.stage;
|
|
||||||
if (n < 1 || n > TOTAL_STAGES) return;
|
|
||||||
stages[n].status = ev.status || 'pending';
|
|
||||||
if (ev.startedAt) stages[n].startedAt = ev.startedAt;
|
|
||||||
if (stages[n].status !== 'running') stages[n].slow = false;
|
|
||||||
|
|
||||||
// Design Spec §4.1:stage 5 skipped → 立即推 stage 6 進入 manual hint mode
|
|
||||||
// (此時 Go 層也會送 stage 6 running,但這裡先行設定 manualHint 旗標,
|
|
||||||
// 以便收到後續 stage 6 progress 時仍能維持 hint 文案。)
|
|
||||||
if (n === 5 && stages[5].status === 'skipped') {
|
|
||||||
enterManualMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// stage 6 收到 done → 離開 manual mode(使用者已成功點擊並建立連線)
|
|
||||||
if (n === 6 && (stages[6].status === 'completed' || stages[6].status === 'done')) {
|
|
||||||
if (manualMode) {
|
|
||||||
manualMode = false;
|
|
||||||
stages[6].manualHint = false;
|
|
||||||
emitManualMode(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// running 狀態下,把其他仍 pending 的顯示維持
|
|
||||||
paintStageRow(n);
|
|
||||||
paintProgressBar();
|
|
||||||
|
|
||||||
// 無障礙 live region
|
|
||||||
const live = document.getElementById('startup-live');
|
|
||||||
if (live) {
|
|
||||||
const statusKey = {
|
|
||||||
pending: 'startup.status.pending',
|
|
||||||
running: 'startup.status.running',
|
|
||||||
completed: 'startup.status.done',
|
|
||||||
failed: 'startup.status.failed',
|
|
||||||
skipped: 'startup.status.skipped',
|
|
||||||
}[stages[n].status] || 'startup.status.pending';
|
|
||||||
live.textContent = `${n} · ${t(`startup.stage.${n}.label`)} · ${t(statusKey)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 進入 Manual Hint 模式(stage 5 skipped → stage 6 等待人為動作)----------
|
|
||||||
// 對齊 Design Spec §4.1「階段 6 Settings OFF 情境」
|
|
||||||
function enterManualMode() {
|
|
||||||
if (manualMode) return;
|
|
||||||
manualMode = true;
|
|
||||||
// 把 stage 6 標成 running + manualHint(若 Go 層還沒送 stage 6 running,這裡主動 paint)
|
|
||||||
if (stages[6].status === 'pending') {
|
|
||||||
stages[6].status = 'running';
|
|
||||||
stages[6].startedAt = Date.now();
|
|
||||||
}
|
|
||||||
stages[6].manualHint = true;
|
|
||||||
// 不套 soft timeout(因為是等人為動作)
|
|
||||||
stages[6].slow = false;
|
|
||||||
paintStageRow(6);
|
|
||||||
paintProgressBar();
|
|
||||||
emitManualMode(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 外部呼叫:查詢是否處於 manual mode
|
|
||||||
export function isManualMode() {
|
|
||||||
return manualMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 階段 soft timeout ----------
|
|
||||||
export function markStageTimeout(ev) {
|
|
||||||
if (!ev || !ev.stage) return;
|
|
||||||
const n = ev.stage;
|
|
||||||
if (!stages[n]) return;
|
|
||||||
// Design Spec §4.1:stage 6 manual mode 不套 20 秒 retry hint
|
|
||||||
if (n === 6 && stages[6].manualHint) return;
|
|
||||||
stages[n].slow = true;
|
|
||||||
if (!stages[n].startedAt) stages[n].startedAt = Date.now();
|
|
||||||
paintStageRow(n);
|
|
||||||
paintProgressBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Error mode ----------
|
|
||||||
export function showStartupError(ev) {
|
|
||||||
const errorBlock = document.getElementById('startup-error');
|
|
||||||
const descEl = document.getElementById('error-desc');
|
|
||||||
const stageEl = document.getElementById('error-stage');
|
|
||||||
if (!errorBlock) return;
|
|
||||||
|
|
||||||
const cause = ev && ev.cause || 'stage-failure';
|
|
||||||
const failedStage = ev && ev.stage || 0;
|
|
||||||
|
|
||||||
if (cause === 'total-timeout') {
|
|
||||||
descEl.textContent = t('startup.error.description.timeout');
|
|
||||||
} else {
|
|
||||||
const stageLabel = failedStage ? t(`startup.stage.${failedStage}.label`) : '';
|
|
||||||
descEl.textContent = t('startup.error.description.stageFailed', { stageLabel });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedStage) {
|
|
||||||
stageEl.textContent = t('startup.error.failedStage', {
|
|
||||||
n: failedStage,
|
|
||||||
label: t(`startup.stage.${failedStage}.label`),
|
|
||||||
});
|
|
||||||
// 標記該階段為 failed
|
|
||||||
stages[failedStage].status = 'failed';
|
|
||||||
paintStageRow(failedStage);
|
|
||||||
paintProgressBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorBlock.removeAttribute('hidden');
|
|
||||||
// 確保 panel 顯示
|
|
||||||
const panel = document.getElementById('startup-panel');
|
|
||||||
if (panel) panel.removeAttribute('hidden');
|
|
||||||
}
|
|
||||||
@ -1,683 +1,112 @@
|
|||||||
/* visionA-local 控制台 — Design Spec v2.1 對齊
|
/* visionA Local — splash screen */
|
||||||
* 設計 tokens 參考 shadcn oklch tokens(近似) */
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
|
||||||
html, body { margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
/* ---------- Design Tokens ---------- */
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #ffffff;
|
--brand-bg-top: #1A1F36;
|
||||||
--surface-1: #fafafa;
|
--brand-bg-bottom: #0E1222;
|
||||||
--surface-2: #f4f4f5;
|
--brand-primary: #4F7EFF;
|
||||||
--fg: #111827;
|
--brand-primary-light: #6EA8FF;
|
||||||
--fg-muted: #6b7280;
|
--brand-mint: #6EF3C5;
|
||||||
--border: #e5e7eb;
|
--brand-white: #FFFFFF;
|
||||||
--border-strong: #d1d5db;
|
--brand-muted: #8890B0;
|
||||||
--primary: #2563eb;
|
--brand-danger: #FF6B6B;
|
||||||
--primary-fg: #ffffff;
|
|
||||||
--primary-hover: #1d4ed8;
|
|
||||||
--success: #16a34a;
|
|
||||||
--warning: #b45309;
|
|
||||||
--destructive: #b91c1c;
|
|
||||||
--destructive-soft:#fef2f2;
|
|
||||||
--focus-ring: rgba(37, 99, 235, 0.35);
|
|
||||||
--radius-sm: 4px;
|
|
||||||
--radius-md: 8px;
|
|
||||||
--radius-lg: 12px;
|
|
||||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei',
|
|
||||||
'PingFang TC', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
--font-mono: 'SF Mono', 'Menlo', 'Consolas', 'Roboto Mono', monospace;
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
|
||||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--bg: #0f0f10;
|
|
||||||
--surface-1: #17181a;
|
|
||||||
--surface-2: #1f2022;
|
|
||||||
--fg: #e5e7eb;
|
|
||||||
--fg-muted: #9ca3af;
|
|
||||||
--border: #2a2b2e;
|
|
||||||
--border-strong: #3a3b3e;
|
|
||||||
--primary: #3b82f6;
|
|
||||||
--primary-fg: #ffffff;
|
|
||||||
--primary-hover: #2563eb;
|
|
||||||
--success: #22c55e;
|
|
||||||
--warning: #fbbf24;
|
|
||||||
--destructive: #f87171;
|
|
||||||
--destructive-soft:#2a1414;
|
|
||||||
--focus-ring: rgba(59, 130, 246, 0.45);
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
|
|
||||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.55);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
background: var(--bg);
|
height: 100%;
|
||||||
color: var(--fg);
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei',
|
||||||
font-family: var(--font-sans);
|
'PingFang TC', 'Helvetica Neue', Arial, sans-serif;
|
||||||
font-size: 14px;
|
background: linear-gradient(180deg, var(--brand-bg-top) 0%, var(--brand-bg-bottom) 100%);
|
||||||
|
color: #E6E8F0;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sr-only {
|
#app {
|
||||||
position: absolute;
|
display: flex;
|
||||||
width: 1px;
|
align-items: center;
|
||||||
height: 1px;
|
justify-content: center;
|
||||||
padding: 0;
|
min-height: 100vh;
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0,0,0,0);
|
|
||||||
border: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Layout ---------- */
|
.splash {
|
||||||
.control-panel {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
align-items: center;
|
||||||
min-width: 560px;
|
gap: 20px;
|
||||||
|
padding: 48px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Header ---------- */
|
@keyframes fadeIn {
|
||||||
.header {
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 16px 64px rgba(79, 126, 255, 0.25),
|
||||||
|
0 0 0 1px rgba(110, 168, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
gap: 12px;
|
align-items: center;
|
||||||
padding: 14px 16px;
|
gap: 4px;
|
||||||
border-bottom: 1px solid var(--border);
|
margin-top: 4px;
|
||||||
background: var(--bg);
|
|
||||||
}
|
}
|
||||||
.brand-logo {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
.brand-info { flex: 1; min-width: 0; }
|
|
||||||
.brand-name {
|
.brand-name {
|
||||||
margin: 0;
|
font-size: 28px;
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--brand-white);
|
||||||
}
|
}
|
||||||
.status-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.status-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--fg-muted);
|
|
||||||
transition: background 300ms ease-out;
|
|
||||||
}
|
|
||||||
.status-dot.state-starting { background: var(--warning); animation: pulse 1s ease-in-out infinite; }
|
|
||||||
.status-dot.state-running { background: var(--success); }
|
|
||||||
.status-dot.state-stopping { background: var(--warning); animation: pulse 1s ease-in-out infinite; }
|
|
||||||
.status-dot.state-stopped { background: var(--fg-muted); }
|
|
||||||
.status-dot.state-idle { background: var(--fg-muted); }
|
|
||||||
.status-dot.state-error { background: var(--destructive); }
|
|
||||||
|
|
||||||
.status-text {
|
.brand-accent {
|
||||||
|
color: var(--brand-primary-light);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tagline {
|
||||||
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fg);
|
letter-spacing: 0.14em;
|
||||||
}
|
text-transform: uppercase;
|
||||||
.server-meta {
|
color: var(--brand-muted);
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin: 6px 0 0;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
}
|
|
||||||
.server-meta .meta-item { display: flex; gap: 4px; }
|
|
||||||
.server-meta dt { display: inline; }
|
|
||||||
.server-meta dt::after { content: ':'; margin-right: 2px; }
|
|
||||||
.server-meta dd { display: inline; margin: 0; font-variant-numeric: tabular-nums; }
|
|
||||||
|
|
||||||
.brand-version {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
}
|
|
||||||
.icon-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
.icon-btn:hover { background: var(--surface-2); color: var(--fg); }
|
|
||||||
|
|
||||||
/* ---------- Primary controls ---------- */
|
|
||||||
.primary-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.spinner {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
line-height: 1;
|
|
||||||
min-height: 36px;
|
|
||||||
transition: background 120ms, border-color 120ms, color 120ms;
|
|
||||||
}
|
|
||||||
.btn:focus-visible {
|
|
||||||
outline: 2px solid var(--focus-ring);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
.btn[disabled] { cursor: not-allowed; opacity: 0.45; }
|
|
||||||
.btn-sm { padding: 4px 10px; font-size: 12px; min-height: 28px; }
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-fg);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
.btn-primary:hover:not([disabled]) { background: var(--primary-hover); border-color: var(--primary-hover); }
|
|
||||||
|
|
||||||
/* Stage 6 manual hint → 引導使用者點擊 Open in Browser
|
|
||||||
* 對齊 Design Spec v2.1 startup-progress.md §4.1 */
|
|
||||||
.btn.pulse-cta:not([disabled]) {
|
|
||||||
animation: ctaPulse 1.8s ease-in-out infinite;
|
|
||||||
box-shadow: 0 0 0 0 var(--focus-ring);
|
|
||||||
}
|
|
||||||
@keyframes ctaPulse {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.55);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 0 0 8px rgba(37, 99, 235, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
@keyframes ctaPulse {
|
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.65); }
|
|
||||||
50% { box-shadow: 0 0 0 8px rgba(59, 130, 246, 0); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* Reduced motion:改為靜態高亮外框(不做動畫) */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.btn.pulse-cta:not([disabled]) {
|
|
||||||
animation: none;
|
|
||||||
outline: 2px solid var(--focus-ring);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--fg);
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
}
|
|
||||||
.btn-outline:hover:not([disabled]) { background: var(--surface-2); }
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--fg);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
.btn-ghost:hover:not([disabled]) { background: var(--surface-2); }
|
|
||||||
|
|
||||||
/* Manage dropdown */
|
|
||||||
.manage-wrapper { position: relative; }
|
|
||||||
.manage-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 4px);
|
|
||||||
right: 0;
|
|
||||||
min-width: 200px;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 4px;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.menu-item {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--fg);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.menu-item:hover:not([disabled]) { background: var(--surface-2); }
|
|
||||||
.menu-item-danger { color: var(--destructive); }
|
|
||||||
.menu-item[disabled] { opacity: 0.4; cursor: not-allowed; }
|
|
||||||
.menu-divider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--border);
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Log controls ---------- */
|
|
||||||
.log-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
}
|
|
||||||
.checkbox {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.filter-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
background: var(--surface-1);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
|
||||||
.filter-wrapper input {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
#level-filter {
|
|
||||||
background: var(--surface-1);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 4px 8px;
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Startup panel ---------- */
|
|
||||||
.startup-panel {
|
|
||||||
margin: 12px 16px 0;
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--surface-1);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
animation: fadeIn 200ms ease-out;
|
|
||||||
}
|
|
||||||
.startup-title {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.stages {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.stage-item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 24px 24px 1fr auto;
|
|
||||||
column-gap: 8px;
|
|
||||||
row-gap: 2px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 4px 0;
|
|
||||||
transition: opacity 200ms;
|
|
||||||
}
|
|
||||||
.stage-item[data-state="pending"] { opacity: 0.6; }
|
|
||||||
.stage-item[data-state="completed"] { opacity: 0.75; }
|
|
||||||
.stage-item[data-state="failed"] { background: rgba(185, 28, 28, 0.06); border-radius: var(--radius-sm); padding: 6px 8px; }
|
|
||||||
|
|
||||||
.stage-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
}
|
|
||||||
.stage-item[data-state="running"] .stage-icon,
|
|
||||||
.stage-item[data-state="running-slow"] .stage-icon { color: var(--primary); }
|
|
||||||
.stage-item[data-state="completed"] .stage-icon { color: var(--success); }
|
|
||||||
.stage-item[data-state="failed"] .stage-icon { color: var(--destructive); }
|
|
||||||
|
|
||||||
.stage-number {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
}
|
|
||||||
.stage-label-primary {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.stage-item[data-state="completed"] .stage-label-primary { color: var(--fg-muted); }
|
|
||||||
.stage-item[data-state="failed"] .stage-label-primary { color: var(--destructive); font-weight: 600; }
|
|
||||||
.stage-label-secondary {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
}
|
|
||||||
.stage-status {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
.stage-item[data-state="running"] .stage-status { color: var(--primary); }
|
|
||||||
.stage-item[data-state="completed"] .stage-status { color: var(--success); }
|
|
||||||
.stage-item[data-state="failed"] .stage-status { color: var(--destructive); }
|
|
||||||
.stage-hint {
|
|
||||||
grid-column: 3 / 5;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--warning);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.stage-hint::before { content: '⚠ '; }
|
|
||||||
|
|
||||||
/* Spinner */
|
|
||||||
.spinner-sm {
|
|
||||||
display: inline-block;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid rgba(0,0,0,0.1);
|
|
||||||
border-top-color: var(--primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
.spinner-lg {
|
|
||||||
display: inline-block;
|
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: 3px solid rgba(0,0,0,0.1);
|
border: 2.5px solid rgba(255, 255, 255, 0.08);
|
||||||
border-top-color: var(--primary);
|
border-top-color: var(--brand-primary-light);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.9s linear infinite;
|
animation: spin 0.9s linear infinite;
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.spinner-sm, .spinner-lg { border-color: rgba(255,255,255,0.12); border-top-color: var(--primary); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
.progress-bar {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
.progress-cell {
|
|
||||||
flex: 1;
|
|
||||||
height: 6px;
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: background 250ms;
|
|
||||||
}
|
|
||||||
.progress-cell.state-completed, .progress-cell.state-done, .progress-cell.state-skipped {
|
|
||||||
background: var(--success);
|
|
||||||
}
|
|
||||||
.progress-cell.state-running {
|
|
||||||
background: var(--primary);
|
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.progress-cell.state-failed { background: var(--destructive); }
|
|
||||||
.progress-text {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Startup error mode */
|
|
||||||
.startup-error {
|
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.error-title {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--destructive);
|
|
||||||
}
|
|
||||||
.error-desc, .error-stage {
|
|
||||||
margin: 6px 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.error-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Runtime Error banner ---------- */
|
@keyframes spin {
|
||||||
.error-banner {
|
to { transform: rotate(360deg); }
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 12px 16px 0;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: var(--destructive-soft);
|
|
||||||
border: 1px solid var(--destructive);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
animation: fadeIn 200ms ease-out;
|
|
||||||
}
|
|
||||||
.banner-icon { font-size: 20px; color: var(--destructive); }
|
|
||||||
.banner-content { flex: 1; }
|
|
||||||
.banner-title { display: block; font-size: 14px; color: var(--destructive); }
|
|
||||||
.banner-desc { margin: 4px 0 8px; font-size: 13px; color: var(--fg); }
|
|
||||||
.banner-actions { display: flex; gap: 8px; }
|
|
||||||
|
|
||||||
/* ---------- Log panel ---------- */
|
|
||||||
.log-panel {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 100px;
|
|
||||||
margin: 12px 16px 0;
|
|
||||||
position: relative;
|
|
||||||
background: var(--surface-1);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#log-output {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--fg);
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
.log-line {
|
|
||||||
display: block;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
padding: 1px 0;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: background 200ms;
|
|
||||||
}
|
|
||||||
.log-line.flash { background: rgba(185, 28, 28, 0.2); }
|
|
||||||
.log-ts {
|
|
||||||
color: var(--fg-muted);
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
.log-level {
|
|
||||||
display: inline-block;
|
|
||||||
font-weight: 600;
|
|
||||||
width: 48px;
|
|
||||||
}
|
|
||||||
.log-line.level-debug .log-level, .log-line.level-debug .log-msg { color: #60a5fa; }
|
|
||||||
.log-line.level-info .log-level { color: var(--fg-muted); }
|
|
||||||
.log-line.level-warn .log-level, .log-line.level-warn .log-msg { color: var(--warning); }
|
|
||||||
.log-line.level-error .log-level, .log-line.level-error .log-msg { color: var(--destructive); }
|
|
||||||
.log-line.level-plain .log-level { display: none; }
|
|
||||||
|
|
||||||
.jump-latest {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 12px;
|
|
||||||
right: 12px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-fg);
|
|
||||||
border: none;
|
|
||||||
border-radius: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Log actions ---------- */
|
.status {
|
||||||
.log-actions {
|
font-size: 13px;
|
||||||
display: flex;
|
color: var(--brand-muted);
|
||||||
gap: 4px;
|
letter-spacing: 0.02em;
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Footer ---------- */
|
.error {
|
||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 16px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.footer-right { color: var(--warning); }
|
|
||||||
|
|
||||||
/* ---------- Modal ---------- */
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.35);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 100;
|
|
||||||
animation: fadeIn 150ms ease-out;
|
|
||||||
}
|
|
||||||
.modal {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
min-width: 380px;
|
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
box-shadow: var(--shadow-md);
|
padding: 14px 18px;
|
||||||
}
|
background: rgba(255, 107, 107, 0.1);
|
||||||
.modal-header {
|
border: 1px solid rgba(255, 107, 107, 0.35);
|
||||||
display: flex;
|
border-radius: 10px;
|
||||||
align-items: center;
|
color: #FFB5B5;
|
||||||
justify-content: space-between;
|
font-size: 13px;
|
||||||
padding: 14px 16px;
|
line-height: 1.55;
|
||||||
border-bottom: 1px solid var(--border);
|
text-align: center;
|
||||||
}
|
|
||||||
.modal-header h2 { margin: 0; font-size: 15px; font-weight: 600; }
|
|
||||||
.modal-body { padding: 16px; }
|
|
||||||
.setting-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.setting-row:last-of-type { border-bottom: none; }
|
|
||||||
.setting-label { font-size: 13px; font-weight: 500; }
|
|
||||||
.setting-hint { font-size: 11px; color: var(--fg-muted); margin-top: 2px; }
|
|
||||||
.setting-row input[type="checkbox"], .setting-row select {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.about-section {
|
|
||||||
margin-top: 16px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.about-section h3 { margin: 0 0 8px; font-size: 12px; font-weight: 600; color: var(--fg-muted); }
|
|
||||||
.about-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 4px 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.about-list dt { color: var(--fg-muted); }
|
|
||||||
.about-list dd { margin: 0; color: var(--fg); word-break: break-all; }
|
|
||||||
|
|
||||||
/* Shutdown modal */
|
|
||||||
.shutdown-modal .modal.shutdown-dialog {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 24px 40px;
|
|
||||||
min-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Toast ---------- */
|
|
||||||
.toast {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 56px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: rgba(17, 24, 39, 0.92);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 12px;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
z-index: 200;
|
|
||||||
max-width: 80%;
|
|
||||||
word-break: break-all;
|
|
||||||
animation: fadeIn 150ms ease-out;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.toast { background: rgba(240, 240, 240, 0.95); color: #111; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Animations ---------- */
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
|
|
||||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*, *::before, *::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
.spinner-sm, .spinner-lg { border: 2px solid var(--primary); border-radius: 50%; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,40 +2,8 @@
|
|||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
|
|
||||||
export function ClearLogs():Promise<void>;
|
|
||||||
|
|
||||||
export function ExportLog():Promise<string>;
|
|
||||||
|
|
||||||
export function ForceKillServer():Promise<void>;
|
|
||||||
|
|
||||||
export function GetBootstrapStatus():Promise<string>;
|
|
||||||
|
|
||||||
export function GetPreferences():Promise<main.Preferences>;
|
|
||||||
|
|
||||||
export function GetRecentLogs(arg1:number):Promise<Array<main.LogLine>>;
|
|
||||||
|
|
||||||
export function GetServerStatus():Promise<main.ServerStatus>;
|
export function GetServerStatus():Promise<main.ServerStatus>;
|
||||||
|
|
||||||
export function GetServerStatusV2():Promise<main.ServerStatusV2>;
|
|
||||||
|
|
||||||
export function GetServerURL():Promise<string>;
|
export function GetServerURL():Promise<string>;
|
||||||
|
|
||||||
export function GetSystemInfo():Promise<main.SystemInfo>;
|
|
||||||
|
|
||||||
export function InstallKneronDriver():Promise<void>;
|
|
||||||
|
|
||||||
export function OpenBrowser(arg1:string):Promise<void>;
|
export function OpenBrowser(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function OpenInBrowser(arg1:string):Promise<void>;
|
|
||||||
|
|
||||||
export function RestartServer():Promise<void>;
|
|
||||||
|
|
||||||
export function RestartStartupSequence():Promise<void>;
|
|
||||||
|
|
||||||
export function RevealLogsFolder():Promise<void>;
|
|
||||||
|
|
||||||
export function SetPreferences(arg1:main.Preferences):Promise<void>;
|
|
||||||
|
|
||||||
export function StartServer():Promise<void>;
|
|
||||||
|
|
||||||
export function StopServer():Promise<void>;
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user