4d3a6a09a6
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(不再夾帶第二份完整程式)
173 lines
7.2 KiB
Go
173 lines
7.2 KiB
Go
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 對 SIGTERM/SIGINT 有反應。
|
||
//
|
||
// 🔴 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 object/macOS 的 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() 收尾,行程一死立刻進 StateError/StateStopped)
|
||
// ⇒ 比掃行程表**更有憑據**,而且跨平台。
|
||
// 順帶修掉 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: // Starting/Watching/Syncing
|
||
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
|
||
}
|