local-tool/: visionA-local desktop app
- M1: Wails shell + Go server + Next.js UI + Mock mode (macOS dmg ready)
- M2: i18n (zh-TW/en) + Settings 4-tab refactor
- M3: Embedded Python 3.12 runtime (python-build-standalone) + KneronPLUS wheels
- M4: Windows Inno Setup script (build on Windows runner)
- M5: Linux AppImage script + udev rule (build on Linux runner)
- M6: ffmpeg (GPL, pending legal review) + yt-dlp bundled
- Lifecycle: watchServer health check, fatal native dialog,
Wails IPC raise endpoint, stale process cleanup
.autoflow/: full PRD / Design Spec / Architecture / Testing docs
(4 rounds tri-party discussion + cross review)
.github/workflows/: macOS / Windows / Linux build CI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
400 lines
14 KiB
Markdown
400 lines
14 KiB
Markdown
# Lifecycle — visionA-local
|
||
|
||
> 程序生命週期、single-instance lock、port 衝突、程序啟停、錯誤恢復
|
||
>
|
||
> ⚠️ **Tray 已砍**(第三輪使用者決策 Q-A=A3)。原本這份文件含 Tray 實作章節,現已全數移除。
|
||
> 檔名暫時保留為 `tray-and-lifecycle.md` 以避免大量交叉引用改動,內容已不含 tray。
|
||
> 相關 i18n key(`tray.*`)、圖資產(`tray-*.png`)、原 `edge-ai-platform/server/tray/` 皆不再沿用。
|
||
|
||
---
|
||
|
||
## 1. 生命週期總覽
|
||
|
||
```
|
||
[使用者雙擊 app]
|
||
↓
|
||
[Wails 啟動] → [single-instance check] → [已有 instance 在跑?]
|
||
│ │
|
||
│ no │ yes → 啟用已存在 instance 的視窗 → exit
|
||
▼
|
||
[檢查 .installed 標記] → [尚未安裝?]
|
||
│ │
|
||
│ no │ yes → [進入安裝精靈](見 dependency-bundling.md)
|
||
▼
|
||
[spawn Go server 子行程]
|
||
↓
|
||
[等待 /api/system/health 200,最多 10 秒]
|
||
↓
|
||
[WebView loads http://127.0.0.1:{port}]
|
||
↓
|
||
[Dashboard 顯示]
|
||
↓
|
||
... 使用中 ...
|
||
↓
|
||
[使用者關閉主視窗(= Quit,見 Q7=B)]
|
||
↓
|
||
[cleanupAndExit]
|
||
├─ 送 SIGTERM 給 Go server
|
||
├─ 等 3 秒 graceful shutdown
|
||
├─ 仍存活 → SIGKILL
|
||
├─ 移除 single-instance lock
|
||
└─ Wails exit
|
||
```
|
||
|
||
## 2. Single-Instance Lock
|
||
|
||
### 2.1 需求
|
||
|
||
同一個使用者不應該同時跑兩個 visionA-local 視窗(會搶 3721 port、搶 Python sidecar 造成衝突)。
|
||
|
||
### 2.2 實作
|
||
|
||
**用檔案鎖 + PID + port 三重確認:**
|
||
|
||
```go
|
||
// visiona-local/lifecycle.go
|
||
func acquireSingleInstance(dataDir string) (release func(), err error) {
|
||
lockPath := filepath.Join(dataDir, "visiona-local.lock")
|
||
|
||
// 1. 嘗試建立 lock 檔(O_EXCL)
|
||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
||
if err != nil && os.IsExist(err) {
|
||
// 2. 已存在 → 讀取裡面的 PID
|
||
data, _ := os.ReadFile(lockPath)
|
||
pid, _ := strconv.Atoi(strings.TrimSpace(string(data)))
|
||
|
||
// 3. 檢查該 PID 是否還活著
|
||
if pid > 0 && processAlive(pid) {
|
||
// 活著 → 嘗試透過 IPC 叫醒該 instance(見 2.3)
|
||
if raiseExistingInstance() {
|
||
return nil, ErrAlreadyRunning
|
||
}
|
||
}
|
||
|
||
// 4. stale lock → 覆寫
|
||
os.Remove(lockPath)
|
||
f, err = os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
// 5. 寫入自己的 PID
|
||
fmt.Fprintf(f, "%d", os.Getpid())
|
||
f.Close()
|
||
|
||
// 6. 回傳 cleanup function
|
||
return func() { os.Remove(lockPath) }, nil
|
||
}
|
||
```
|
||
|
||
### 2.3 喚起已存在 instance
|
||
|
||
用 localhost HTTP 呼叫一個特殊 endpoint:
|
||
|
||
```go
|
||
// Wails app 監聽 127.0.0.1:{random_port}/ipc/raise
|
||
func raiseExistingInstance() bool {
|
||
// 從 lock 檔附近的 .ipc-port 讀 port
|
||
portFile := filepath.Join(dataDir, "visiona-local.ipc-port")
|
||
data, err := os.ReadFile(portFile)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
port := strings.TrimSpace(string(data))
|
||
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%s/ipc/raise", port))
|
||
if err != nil {
|
||
return false
|
||
}
|
||
defer resp.Body.Close()
|
||
return resp.StatusCode == 200
|
||
}
|
||
```
|
||
|
||
被喚起的 instance 收到 `/ipc/raise` 後執行 `runtime.WindowShow(ctx)` 讓主視窗浮到前面。
|
||
|
||
### 2.3.1 第二次雙擊的完整序列
|
||
|
||
使用者在 app 已啟動時再次雙擊圖示 / 從 Spotlight / 從命令列啟動:
|
||
|
||
```
|
||
新 instance 啟動(process B)
|
||
↓
|
||
acquireSingleInstance() → lock 檔存在、PID 存活
|
||
↓
|
||
呼叫 raiseExistingInstance() → 讀 .ipc-port → GET http://127.0.0.1:{ipc-port}/ipc/raise
|
||
↓
|
||
既有 instance(process A)收到 → runtime.WindowShow(ctx) + runtime.WindowUnminimise(ctx) + runtime.WindowSetAlwaysOnTop(ctx,true) → 立刻 setAlwaysOnTop(false)(短暫提前到最上層後釋放)
|
||
↓
|
||
process A 回應 200
|
||
↓
|
||
process B 看到 200 → 立即 os.Exit(0)(不執行任何 cleanup,因為沒動任何狀態)
|
||
↓
|
||
使用者看到的效果:沒有新視窗,既有視窗被提到前景並閃一下
|
||
```
|
||
|
||
**失敗分支**:若 `/ipc/raise` 無回應(既有 instance 卡住),process B 判定既有 instance 不健康 → 嘗試 SIGTERM PID → 等 2 秒 → 若仍活著顯示錯誤對話框「偵測到另一個已停止回應的 instance,請手動結束後重試」,process B 退出但不奪取 lock(避免雙活)。
|
||
|
||
### 2.4 各平台差異
|
||
|
||
- **macOS**:Wails / Cocoa 本來就處理 "open another instance" 為 activate existing,但此行為只適用「從 Dock/Finder 啟動第二次」。從命令列或 Spotlight 仍可能啟動第二個程序,所以還是需要 app 層的鎖。
|
||
- **Windows**:必須自己實作。沒有原生機制會合併多個 exe 進入同一個 instance。
|
||
- **Linux**:同 Windows,但 AppImage 每次都是新程序,鎖檔更重要。
|
||
|
||
## 3. Port 衝突處理
|
||
|
||
### 3.1 問題
|
||
|
||
- 預設用 3721,但使用者可能有其他東西佔用
|
||
- 之前跑的 visionA-local 異常退出沒釋放 port
|
||
|
||
### 3.2 解法:動態 port + kill stale
|
||
|
||
```go
|
||
// server/main.go 的 killExistingProcess 邏輯保留並擴充
|
||
// visiona-local/server_launcher.go 新增
|
||
|
||
func pickPort(preferred int) (int, error) {
|
||
// 1. 先嘗試 preferred port
|
||
if available(preferred) {
|
||
return preferred, nil
|
||
}
|
||
|
||
// 2. 檢查 preferred port 上的程序是否是「上次沒清乾淨的 visiona-local-server」
|
||
if isOurStaleServer(preferred) {
|
||
killByPort(preferred)
|
||
time.Sleep(500 * time.Millisecond)
|
||
if available(preferred) {
|
||
return preferred, nil
|
||
}
|
||
}
|
||
|
||
// 3. 找下一個可用 port(3722, 3723, ...)
|
||
for p := preferred + 1; p < preferred+20; p++ {
|
||
if available(p) {
|
||
return p, nil
|
||
}
|
||
}
|
||
return 0, errors.New("no free port in range")
|
||
}
|
||
|
||
func isOurStaleServer(port int) bool {
|
||
// 檢查 process name 是否是 visiona-local-server
|
||
// Linux/macOS: lsof -i :port + ps -p PID
|
||
// Windows: netstat -ano + tasklist /FI "PID eq ..."
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### 3.3 UI 呈現
|
||
|
||
如果最終選到的 port 不是 3721,在 Settings > Server 狀態區塊顯示實際 port,並在 server logs 印一行 warning。不彈視窗打擾使用者。
|
||
|
||
## 4. 程序啟停細節
|
||
|
||
### 4.1 啟動 Go server(spawn 邏輯)
|
||
|
||
```go
|
||
// visiona-local/server_launcher.go
|
||
type ServerProcess struct {
|
||
cmd *exec.Cmd
|
||
cancel context.CancelFunc
|
||
port int
|
||
dataDir string
|
||
}
|
||
|
||
func (inst *Installer) launchServer() (*ServerProcess, error) {
|
||
port, err := pickPort(3721)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
|
||
// 從 .installed 讀出 python.mode
|
||
meta := readInstallMeta(inst.dataDir)
|
||
|
||
binPath := filepath.Join(inst.dataDir, "bin", "visiona-local-server")
|
||
if runtime.GOOS == "windows" {
|
||
binPath += ".exe"
|
||
}
|
||
|
||
pythonBin := filepath.Join(inst.dataDir, "venv", "bin", "python3")
|
||
if runtime.GOOS == "windows" {
|
||
pythonBin = filepath.Join(inst.dataDir, "venv", "Scripts", "python.exe")
|
||
}
|
||
|
||
scriptsDir := filepath.Join(inst.dataDir, "scripts")
|
||
dataSubdir := filepath.Join(inst.dataDir, "data")
|
||
|
||
args := []string{
|
||
"--port", strconv.Itoa(port),
|
||
"--host", "127.0.0.1",
|
||
"--python", pythonBin,
|
||
"--scripts-dir", scriptsDir,
|
||
"--data-dir", dataSubdir,
|
||
"--log-level", "info",
|
||
}
|
||
|
||
cmd := exec.CommandContext(ctx, binPath, args...)
|
||
cmd.Stdout = inst.serverLogWriter("stdout")
|
||
cmd.Stderr = inst.serverLogWriter("stderr")
|
||
|
||
if err := cmd.Start(); err != nil {
|
||
cancel()
|
||
return nil, err
|
||
}
|
||
|
||
// 等健康檢查
|
||
if err := waitHealthy(port, 10*time.Second); err != nil {
|
||
cmd.Process.Kill()
|
||
cancel()
|
||
return nil, fmt.Errorf("server did not become healthy: %w", err)
|
||
}
|
||
|
||
return &ServerProcess{cmd: cmd, cancel: cancel, port: port, dataDir: inst.dataDir}, nil
|
||
}
|
||
|
||
func (sp *ServerProcess) Stop() {
|
||
// 1. graceful: SIGTERM
|
||
sp.cmd.Process.Signal(syscall.SIGTERM)
|
||
|
||
// 2. 等 3 秒
|
||
done := make(chan error, 1)
|
||
go func() { done <- sp.cmd.Wait() }()
|
||
select {
|
||
case <-done:
|
||
// ok
|
||
case <-time.After(3 * time.Second):
|
||
// 3. 強制殺
|
||
sp.cmd.Process.Kill()
|
||
<-done
|
||
}
|
||
|
||
sp.cancel()
|
||
}
|
||
```
|
||
|
||
### 4.2 Server 意外崩潰偵測
|
||
|
||
Wails app 開一個 goroutine `watchServer()`:
|
||
- 每 10 秒對 `/api/system/health` 做 healthcheck
|
||
- 失敗 3 次 → 認定 server 掛了
|
||
- **不自動重啟**(第一版)→ 顯示「Server 無回應」錯誤視窗,提供「重啟 server / 重新啟動 app / 查看 log」三個選項
|
||
- 第二版可加自動重啟(最多 3 次,避免 crash loop)
|
||
|
||
### 4.3 Python sidecar 生命週期
|
||
|
||
Python sidecar 由 Go server 自行管理(不由 Wails 控),沿用 `edge-ai-platform/server/internal/device/` 的 bridge 模式:
|
||
- 需要時 spawn(第一次 scan devices)
|
||
- 空閒 N 分鐘後自動 kill(省記憶體)
|
||
- Go server 關閉時一定要 reap(避免殭屍程序)
|
||
- **Mock 模式下完全不 spawn**(見 `architecture-overview §7`)
|
||
|
||
### 4.4 Python sidecar crash auto-restart(第四輪補件)
|
||
|
||
Python sidecar 可能因為 KneronPLUS 驅動異常、libusb 斷線、OOM 等原因崩潰。Go server 偵測 sidecar 崩潰後執行 auto-restart 策略:
|
||
|
||
```
|
||
device.Manager goroutine 監聽 python sidecar stdout/stderr pipe
|
||
↓
|
||
pipe EOF 或 process exit → 判定 crash
|
||
↓
|
||
restart_count < 3?
|
||
├─ Yes → sleep(backoff) → spawn 新 sidecar
|
||
│ backoff: 1s → 3s → 10s(指數退避)
|
||
│ spawn 成功 → 重置 restart_count、重新 scan devices
|
||
│ spawn 失敗 → restart_count++ → 下一輪
|
||
└─ No(連續 3 次失敗)→ 放棄 auto-restart
|
||
↓
|
||
WebSocket /ws/devices/events push
|
||
{"type":"sidecar_crashed","restart_attempts":3,
|
||
"last_error":"..."}
|
||
↓
|
||
前端顯示錯誤 modal:「硬體連線失敗,請檢查 USB 裝置或切換到 Mock 模式」
|
||
```
|
||
|
||
**restart_count 重置時機**:任何一次成功跑滿 5 分鐘不崩,就重置為 0(避免累積記住舊錯誤)。
|
||
|
||
**同一時段 Go server 也透過 WebSocket broadcast 給前端**,讓使用者立即知道問題,不用等下次操作才發現。
|
||
|
||
### 4.5 啟動時的資料目錄遷移
|
||
|
||
為了兼容早期開發階段可能殘留的舊路徑,Wails app 在 `acquireSingleInstance` **之後、`stepSetupPython*` 之前**執行一次性檢查:
|
||
|
||
```go
|
||
// visiona-local/data_migration.go
|
||
func MigrateOldDataDirs(newDir string, logger *log.Logger) {
|
||
candidates := oldDataDirCandidates() // 平台相依
|
||
for _, old := range candidates {
|
||
if _, err := os.Stat(old); err != nil {
|
||
continue
|
||
}
|
||
if _, err := os.Stat(newDir); err == nil {
|
||
logger.Printf("⚠️ 偵測到舊資料目錄 %s,但新路徑 %s 已存在,請手動清理舊路徑", old, newDir)
|
||
continue
|
||
}
|
||
if err := os.Rename(old, newDir); err != nil {
|
||
logger.Printf("⚠️ 遷移 %s → %s 失敗:%v", old, newDir, err)
|
||
continue
|
||
}
|
||
breadcrumb := filepath.Join(newDir, ".migrated-from")
|
||
os.WriteFile(breadcrumb, []byte(old+"\n"+time.Now().Format(time.RFC3339)), 0644)
|
||
logger.Printf("✅ 已將 %s 遷移到 %s", old, newDir)
|
||
}
|
||
}
|
||
|
||
func oldDataDirCandidates() []string {
|
||
home, _ := os.UserHomeDir()
|
||
switch runtime.GOOS {
|
||
case "darwin":
|
||
return []string{
|
||
filepath.Join(home, ".visiona-local"), // 隱藏資料夾
|
||
filepath.Join(home, "Library", "Application Support", "visionA-local"), // 大寫舊名
|
||
}
|
||
case "windows":
|
||
appdata := os.Getenv("APPDATA")
|
||
return []string{
|
||
filepath.Join(home, ".visiona-local"),
|
||
filepath.Join(appdata, "visionA-local"),
|
||
}
|
||
case "linux":
|
||
return []string{
|
||
filepath.Join(home, ".visiona-local"),
|
||
filepath.Join(home, ".local", "share", "visionA-local"),
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
**原則**:
|
||
- 遷移失敗不擋啟動
|
||
- 新路徑已存在時不覆蓋
|
||
- `.migrated-from` breadcrumb 供 troubleshooting 與未來版本確認是否已遷移過
|
||
- 只遷移一次(之後舊路徑已不存在,迴圈自然跳過)
|
||
|
||
---
|
||
|
||
## 5. 錯誤恢復矩陣
|
||
|
||
| 錯誤情境 | 偵測方式 | 處理 |
|
||
|---------|---------|-----|
|
||
| Python runtime 壞掉(bundled 被防毒刪) | venv 執行失敗 | 自動 fallback 到 system python;仍壞則提示重裝 |
|
||
| 3721 port 被佔用 | `pickPort` 找不到 | 顯示錯誤對話框 + 建議手動指定 port |
|
||
| Go server 無法啟動 | health check timeout | 顯示錯誤 + 附上最後 20 行 log |
|
||
| KneronPLUS driver 未安裝(Windows) | `pnputil /enum-drivers` 找不到 | 首次啟動時詢問 UAC;拒絕 → 降級到 Mock 模式 |
|
||
| Python sidecar 崩潰 | Go server 偵測 pipe 斷開 | 指數退避 auto-restart(1s/3s/10s),最多 3 次;超過則 WebSocket push 錯誤事件給前端顯示 modal(見 §4.4)|
|
||
| Lock 檔 stale(前一次沒清乾淨) | PID 不存在 | 自動清除並取得 lock |
|
||
| WebView 載入失敗 | `/api/system/health` 200 但 WebView 白屏 | 顯示「重新載入」按鈕 |
|
||
|
||
## 6. 平台差異總表
|
||
|
||
| 項目 | macOS | Windows | Linux |
|
||
|------|-------|---------|-------|
|
||
| Single-instance 機制 | 檔案鎖 + Cocoa 協助 | 檔案鎖 + Named Mutex | 檔案鎖 |
|
||
| 關閉視窗行為 | 關閉 = 結束(非 mac 慣例,但使用者決策 Q7=B) | 關閉 = 結束 | 關閉 = 結束 |
|
||
| Signal handling | SIGTERM / SIGKILL | TerminateProcess + graceful Ctrl+Break | SIGTERM / SIGKILL |
|
||
| Process tree 清理 | launchd 幫忙 reap | job object | prctl PR_SET_PDEATHSIG |
|
||
| 檔案鎖位置 | `~/Library/Application Support/visiona-local/visiona-local.lock` | `%APPDATA%\visiona-local\visiona-local.lock` | `~/.local/share/visiona-local/visiona-local.lock` |
|