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>
14 KiB
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 三重確認:
// 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:
// 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
// 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 邏輯)
// 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* 之前執行一次性檢查:
// 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-frombreadcrumb 供 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 |