Files
arcrun-collector/cmd/arcrun-app/supervise.go
T
Leo 4d3a6a09a6 v0.18.9:collector 併進同一支執行檔——磁碟上不再攤出第二支 exe
leo 08-06 裁決:「不要兩支,寫成一支檔案」。

## 為什麼
v0.18.7-8 的「單一 exe」其實是**一支包著另一支**:collector.exe 被 go:embed
進 Arcrun.exe,執行時攤到 ~/.arcrun-rag/bin/ 再跑。
那正是防毒軟體眼中的 dropper 特徵 —— 封測者實撞
`Trojan:Win32/Sabsik.FL.A!ml`,檔案當場被隔離、自動刪除。

⚠️ 誠實界定:`!ml` 結尾=**機器學習判定**,Sabsik 是最常見的通用誤判家族,
   主因是「未簽章+下載次數少」,**不是**特別指向 dropper 行為。
   所以本次改動**不保證**解除誤判——真正的解是上架 MS Store(微軟簽章)。
   但「執行時把第二支 PE 寫到磁碟再執行」本來就該拿掉,這是對的方向且順手變小。

## 怎麼做
- `collector/` 39 個檔 `package main` → `package collector`,`main()` → 匯出的 `Run(args) int`
- 新增 `collector/cmd/collector/`(薄殼 CLI,讓單獨跑 collector 這條路仍可用)
- App 直接 import 該套件;`main()` 第一件事就判 `--collector`,是的話走 `collector.Run` 不碰 GUI
- `supervisor` 加 `ArgPrefix`,App 把 `BinPath` 指向 `os.Executable()` 自己
- 刪掉 `bundled_collector_{windows,other}.go`(embed + 攤檔那套)
- 三支打包腳本不再編/複製第二支;版本注入同時打到兩個 package
- build-win.sh 的機械閘改成**直接問它**:`--collector --version` 回得出版本才放行
  (舊閘是比大小,只能證明「有 embed」,證明不了「分派是對的」)

## 驗(真機實跑)
· `.app/Contents/MacOS/` 只有 **一個** 執行檔(原本兩個)
· 跑起來兩個行程是**同一個 exe**:
    …/MacOS/arcrun-app
    …/MacOS/arcrun-app --collector direct --config …
· `~/.arcrun-rag/bin` **不存在**(沒有任何東西被攤出來)
· 端到端:丟檔進看守資料夾 → collector.log `"status":"ingested","http_status":200`
· `lsappinfo` 仍是 `type="UIElement"`、`Version="0.18.9"`
· collector 39 檔測試全過;app 測試過;go vet 全綠;mac + windows 交叉編譯皆過
· 單檔 26MB → 22MB(不再夾帶第二份完整程式)
2026-08-06 16:00:47 +08:00

173 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// supervise.go — 看守 collector 子行程(t194
//
// 🔴 沒有這一段,這個 App 就只是個「會顯示設定的空殼」——
// daemon 的本體是 `collector direct`,它才在監看資料夾、萃卡、上傳。
// (寫 Wails 版時我一度漏了它,等於做出一個裝了也不會同步的東西。)
//
// 邊界:**複用既有的 supervisor 套件**(重起退避、狀態機、round 解析都在裡面,
// 含 t191 的 phase:start/done ⇒ 「同步中…」)。這裡只做「找到執行檔、拉起來」。
import (
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"arcrun-rag/collector/supervisor"
)
var sup *supervisor.Supervisor
// collectorBinPath 回傳「要跑哪一支執行檔當同步引擎」——答案是**我們自己**。
//
// 🔴 2026-08-06 leo 裁決:「不要兩支,寫成一支檔案」。
//
// 先前兩代做法都有病:
// ① zip 裡放兩支 exe 同層 ⇒ 使用者少拿一支就永遠不同步(leo 封測回報②)
// ② 內嵌 collector.exe、執行時攤到磁碟再跑 ⇒ **Windows Defender 眼中的 dropper**
// (封測者實撞:檔案「包含病毒或潛在的垃圾軟體」當場被刪)
// 現在:collector 已改成可被引用的套件(arcrun-rag/collector.Run),
// App 直接把它編進同一個執行檔,靠 `--collector` 參數換身分。
// ⇒ **磁碟上永遠只有一個 exe,沒有任何東西被攤出來。**
func collectorBinPath() string {
exe, err := os.Executable()
if err != nil {
return ""
}
if p, err := filepath.EvalSymlinks(exe); err == nil {
exe = p
}
return exe
}
// collectorArgPrefix 是「把自己當 collector 跑」的參數(見 main.go 開頭的分派)。
func collectorArgPrefix() []string { return []string{collectorModeFlag} }
// startSupervisor 在 App 啟動時拉起 collector;沒有 config 就先不啟動
// (使用者還沒連知識庫,啟動只會一直失敗刷 log)。
func startSupervisor() {
bin := collectorBinPath()
cfg := configPath()
// 🔴 t195:這裡以前只 Stat(config) 就靜默 return,出問題完全查不到。
// leo 實撞:App 活著、collector 從沒啟動、畫面卻顯示「正在整理知識卡」。
// ⇒ 每個 return 點都要留下痕跡(寫進 collector.log 旁邊的 app.log)。
if bin == "" {
appLog("啟動同步引擎失敗:取不到自己的執行檔路徑")
return
}
if _, err := os.Stat(cfg); err != nil {
appLog("尚未連上知識庫(沒有 %s),同步引擎先不啟動", cfg)
return
}
sup = supervisor.New(bin, cfg)
sup.ArgPrefix = collectorArgPrefix() // 同一支執行檔,換身分跑
sup.Start()
appLog("同步引擎已啟動:%s", bin)
}
// appLog 把 App 層的關鍵事件寫進 ~/.arcrun-rag/app.log。
// 沒有這個,「為什麼沒同步」就只能用猜的(leo 這次就卡在這)。
func appLog(format string, args ...any) {
f, err := os.OpenFile(filepath.Join(appDir(), "app.log"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "%s %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...))
}
// restartWatch 在設定變更後重起看守,讓新設定立刻生效。
func restartWatch() {
if sup == nil {
return
}
sup.Stop()
if _, err := os.Stat(configPath()); err == nil {
sup.Start()
}
}
// stopSupervisor 結束前停掉 collector 子行程。
// 沒有這一步,按「結束 Arcrun」後 collector 會變孤兒繼續跑
// ⇒ 使用者以為關了、其實還在同步,而且下次啟動會有兩個。
func stopSupervisor() {
if sup != nil {
sup.Stop()
}
}
// installSignalHandler 讓 App 對 SIGTERMSIGINT 有反應。
//
// 🔴 leo 實測④:「**用強制結束把它關掉才能測試**,一般托盤程式不會顯示在強制結束列表中」。
//
// 真兇:Wails 的 loop 不理會 TERM,而 collector 是我們自己 Start 的子行程
// ⇒ 主程式被殺時**子行程變孤兒繼續跑**(實測:kill -TERM 後 collector 還在)。
// ⇒ 收到訊號就先停子行程再退出,避免「關了卻還在同步」與「下次啟動有兩個」。
func installSignalHandler() {
ch := make(chan os.Signal, 1)
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()
os.Exit(0)
}()
}
// collectorAlive 回報「同步引擎是不是真的在跑」。
//
// 🔴 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
}
switch sup.Status().State {
case supervisor.StateStopped, supervisor.StateError:
return false
default: // StartingWatchingSyncing
return true
}
}
// collectorSyncing 回報 collector 是不是**正在跑一輪**。
//
// 🔴 2026-08-05:資料來源是 t191 就做好、上面這支 supervisor 一直在維護的狀態機——
// collector 開工印 phase:"start"、跑完印 "done"supervisor 據此進出 StateSyncing。
// 換 Wails 時 App 沒接這條線、改看 sync-now 訊號檔,於是「拖檔進資料夾」這種
// 自動觸發的同步整輪都不會亮燈(訊號檔只有手動按鈕才產生,而且 collector 是先刪再跑)。
func collectorSyncing() bool {
if sup == nil {
return false
}
return sup.Status().State == supervisor.StateSyncing
}