Windows 三修:不再無限多開/首頁不再謊報「引擎沒在跑」/視窗改螢幕 50%

leo 08-06 封測回報①④+圖三標註。三個都是**只在 Windows 現形**的病。

## ④ 托盤一次開好幾個 icon
leo:「點一次開一個新的,測試者原本給我看顯示到 4 個」。
真兇:main.go 的 options.App 沒設 SingleInstanceLock(Wails v2.13 有這選項)。
每個實例還各自拉一份 collector 子行程。
macOS 由 LaunchServices 天然單實例 ⇒ Mac 上驗不出來。
修:加 SingleInstanceLock,第二次啟動叫醒既有視窗後自己退出。

## ① 根本不運行(首頁恆顯示「同步引擎沒有在跑」)
真兇:collectorAlive() 的憑據是 `pgrep -f <bin>`,**Windows 沒有 pgrep**
⇒ 恆回 false ⇒ app.go:356 一定走「同步引擎沒有在跑」,與實際狀態無關。
修:憑據換成 supervisor 自己的狀態機(親手拉起的子行程、runOnce 以 cmd.Wait 收尾)
    ——比掃行程表更有憑據且跨平台,順帶修掉 pgrep 會匹配到別的實例的偽陽性。
⚠️ 不是發明第三種判斷法:db17f28(08-05)已為「同步中」立下同一條路
   「改讀 sup.Status().State ⇒ 接回既有機制,不發明第三種判斷法」,
   當時只改了 collectorSyncing 那半邊,這裡把漏掉的 collectorAlive 補上。
順帶移除 waitCollectorGone/pkill:翻 supervisor 原始碼確認 Stop() 回來時
子行程已被 cmd.Wait 收屍完畢,補刀多餘;且那兩支指令 Windows 根本沒有。

## 視窗高度擠出畫面
leo 圖三:「視窗尺寸應該是 default 50% 螢幕長寬,高度太高快擠到外面了」。
修:OnStartup 依 ScreenGetAll 算 50% 並置中;保底值 1000x700 → 960x600
    (拿不到螢幕資訊時不瞎猜,沿用保底值)。

## 驗(實測輸出)
· go vet 全綠(darwin + GOOS=windows 各一次)
· mac build OK;GOOS=windows CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc 交叉編譯 OK
· 位元組比對交叉編譯出的 .exe:
    新 CIS trayicon.ico 在 exe 裡 = True
    舊 Wails「W」圖在 exe 裡    = False
· 未驗:Windows 真機行為(需重打包後由封測者確認)⇒ 狀態 ◐,不是 
This commit is contained in:
2026-08-06 12:20:25 +08:00
parent 051c865085
commit ee2fc2a803
2 changed files with 91 additions and 30 deletions
+58 -2
View File
@@ -40,12 +40,28 @@ func main() {
err := wails.Run(&options.App{
Title: "Arcrun",
Width: 1000, Height: 700,
// 這裡的 Width/Height 只是**開得起來的保底值**;真正的尺寸在 OnStartup
// 依螢幕算成 50%(見下方 resizeToHalfScreen)。保底值不能大於常見筆電
// 的可視高度,否則 ScreenGetAll 失敗時會沿用它而擠出畫面外
// leo 08-06 實撞:1000x700 在他的 Windows 上「高度太高快擠到外面了」)。
Width: 960, Height: 600,
MinWidth: 820, MinHeight: 560,
AssetServer: &assetserver.Options{Assets: assets},
// 🔴 2026-08-06:沒有這段,Arcrun.exe **點幾次就開幾個實例**,
// 每個實例各掛一個系統匣 icon(leo 的封測者開到 4 個,分不清該點哪個),
// 而且每個實例還各自拉一份 collector 子行程。
// macOS 由 LaunchServices 天然單實例 ⇒ 這個病**只在 Windows 現形**。
// 第二次啟動=叫醒既有視窗後自己退出(Wails 內部 os.Exit(0))。
SingleInstanceLock: &options.SingleInstanceLock{
UniqueId: "dev.arcrun.rag.daemon",
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
app.ShowWindow()
},
},
OnStartup: func(ctx wailsCtx) {
app.startup(ctx)
startSupervisor() // 🔴 daemon 本體:不啟動就不會同步
resizeToHalfScreen(ctx) // 視窗=螢幕的一半,置中(leo 08-06)
startSupervisor() // 🔴 daemon 本體:不啟動就不會同步
// 🔴 托盤必須在**主執行緒**建立(NSStatusItem 會 new NSWindow)。
// OnStartup 不是主執行緒 ⇒ 直接呼叫會炸
// 「NSWindow should only be instantiated on the main thread!」(實測)。
@@ -68,6 +84,46 @@ func main() {
}
}
// resizeToHalfScreen 把視窗調成螢幕長寬的一半並置中。
//
// 🔴 leo 2026-08-06Windows 封測,圖三標註):
//
// 「視窗尺寸應該是 default 50% 螢幕長寬,**高度太高快擠到外面了**」
//
// 寫死 1000x700 在 1366x768 這種常見筆電上,扣掉工作列就幾乎頂到底。
// 依螢幕算才不會在別人機器上爆版——我們沒有全世界的螢幕可以試。
//
// 拿不到螢幕資訊時**什麼都不做**(沿用 options 的保底值),不要瞎猜一個數字:
// 猜錯的後果就是現在這個病。
func resizeToHalfScreen(ctx wailsCtx) {
screens, err := wruntime.ScreenGetAll(ctx)
if err != nil || len(screens) == 0 {
return
}
// 優先用「視窗現在所在的那面」;沒有就退回主螢幕,再退回第一面。
s := screens[0]
for _, c := range screens {
if c.IsCurrent {
s = c
break
}
if c.IsPrimary {
s = c
}
}
// Size 是邏輯像素(Wails 設定尺寸用的單位);舊欄位 Width/Height 已 deprecated
// 但某些平台只填舊欄位 ⇒ 兩個都試,取得到值的那個。
w, h := s.Size.Width, s.Size.Height
if w == 0 || h == 0 {
w, h = s.Width, s.Height
}
if w == 0 || h == 0 {
return
}
wruntime.WindowSetSize(ctx, w/2, h/2)
wruntime.WindowCenter(ctx)
}
// setupTray 建立選單列 icon。
//
// 🔴 leo 2026-08-05:「依照 google drive,托盤裡**只剩下按右鍵會結束**,
+33 -28
View File
@@ -11,11 +11,9 @@ package main
import (
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
@@ -106,43 +104,50 @@ func installSignalHandler() {
signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-ch
// 🔴 2026-08-06:這裡以前 stopSupervisor() 之後還跑一輪 waitCollectorGone()
// pgrep 輪詢 + pkill 補刀),因為當時擔心「Stop() 回來時子行程還在收屍」。
// 翻 supervisor 原始碼確認**那個擔心不成立**:
// Stop() → cancel ctx → 阻塞等 loop goroutine 關閉
// → loop 等 runOnce 回來 → runOnce 最後一行是 `return cmd.Wait()`
// ⇒ Stop() 回來時子行程**已經被 Wait 收屍完畢**,不需要補刀。
// 而那兩支指令 Windows 根本沒有 ⇒ 在 Windows 上本來就只是空轉 3 秒。
// (leo 撞到的孤兒是「強制結束」=SIGKILL 的情境,訊號處理器根本不會執行,
// 補刀也救不了;那條要靠 Windows job objectmacOS 的 App 生命週期解,另案。)
stopSupervisor()
// CommandContext 的 kill 是非同步的:Stop() 回來時子行程可能還在收屍。
// 實測若直接 os.Exit(0)collector 會變孤兒繼續跑(leo 撞到的④)。
// ⇒ 等它真的不見,最多 3 秒;逾時就自己補一刀。
waitCollectorGone(3 * time.Second)
os.Exit(0)
}()
}
// waitCollectorGone 等同綑的 collector 子行程真的結束。
// 逾時就直接殺——寧可強制,也不要留一個「使用者以為關了卻還在同步」的孤兒。
func waitCollectorGone(max time.Duration) {
bin := collectorBinPath()
deadline := time.Now().Add(max)
for time.Now().Before(deadline) {
if !processRunning(bin) {
return
}
time.Sleep(120 * time.Millisecond)
}
_ = exec.Command("pkill", "-TERM", "-f", bin).Run()
}
// processRunning 用 pgrep 查有沒有這支執行檔在跑(純查詢,不動別人的行程)。
func processRunning(bin string) bool {
out, _ := exec.Command("pgrep", "-f", bin).Output()
return len(strings.TrimSpace(string(out))) > 0
}
// collectorAlive 回報「同步引擎是不是真的在跑」。
// 🔴 t195:燈號不可以只憑「訊號檔存在」就說在同步——leo 實撞過
//
// 🔴 t195 立的原則不變:**燈號要有憑有據,不准說謊**——leo 實撞過
// collector 已死、畫面卻一直顯示「正在整理知識卡」13 分鐘。
//
// 🔴 2026-08-06 換憑據來源(leo Windows 封測回報「根本不運行」,首頁恆顯示
// 「同步引擎沒有在跑」):
//
// 舊憑據是 `pgrep -f <bin>`。**Windows 根本沒有 pgrep** ⇒ 這裡恆回 false
// ⇒ app.go 的狀態一定走「同步引擎沒有在跑」,與 collector 實際狀態無關。
// Mac 有 pgrep 所以驗不出來——又一個只在 Windows 現形的病。
//
// 新憑據=**supervisor 自己的狀態機**(我們親手 exec.CommandContext 拉起子行程、
// runOnce 以 cmd.Wait() 收尾,行程一死立刻進 StateErrorStateStopped
// ⇒ 比掃行程表**更有憑據**,而且跨平台。
// 順帶修掉 pgrep 的偽陽性:`pgrep -f` 會匹配到**別的實例**拉起的 collector。
//
// 這不是第三種判斷法——db17f28(08-05)已為「同步中」立下同一條路:
// 「改讀 sup.Status().State ⇒ **接回既有機制,不發明第三種判斷法**」。
// 當時只改了 collectorSyncing 那半邊,這裡是把漏掉的另一半補上。
func collectorAlive() bool {
if sup == nil {
return false
}
return processRunning(collectorBinPath())
switch sup.Status().State {
case supervisor.StateStopped, supervisor.StateError:
return false
default: // StartingWatchingSyncing
return true
}
}
// collectorSyncing 回報 collector 是不是**正在跑一輪**。