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(不再夾帶第二份完整程式)
220 lines
11 KiB
Go
220 lines
11 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"embed"
|
||
"os"
|
||
"sync/atomic"
|
||
|
||
collector "arcrun-rag/collector"
|
||
|
||
"github.com/wailsapp/wails/v2"
|
||
"github.com/wailsapp/wails/v2/pkg/options"
|
||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||
)
|
||
|
||
//go:embed all:frontend/dist
|
||
var assets embed.FS
|
||
|
||
//go:embed build/trayicon.png
|
||
var trayIcon []byte
|
||
|
||
type wailsCtx = context.Context
|
||
|
||
// 版本號由 build 時 -ldflags 注入(同 arcrun-tray 的規矩)。
|
||
var version = "dev"
|
||
|
||
// quitting 區分「按 ×(收起來)」與「按托盤的結束(真的退)」。
|
||
// 兩者都會走 OnBeforeClose,沒有這個旗標就無法分辨——leo 08-06 實撞:
|
||
// 「結束 Arcrun」點了沒反應,只能強制結束,導致新版覆蓋不了舊版。
|
||
var quitting atomic.Bool
|
||
|
||
// beginQuit 舉旗表示「這次是真的要退出」,由 App.Quit()(托盤的結束)呼叫。
|
||
func beginQuit() { quitting.Store(true) }
|
||
|
||
// closeMeansHide 回答「這次的關閉要不要攔下來改成隱藏」。
|
||
// 抽成獨立函式是為了**可機械驗證**——OnBeforeClose 需要 Wails 的 ctx,測不到;
|
||
// 這個判斷才是 leo 08-06 那個 bug 的核心(結束與按 × 共用同一個回呼)。
|
||
func closeMeansHide() bool { return !quitting.Load() }
|
||
|
||
// collectorModeFlag 是「把自己當同步引擎跑」的第一個參數。
|
||
//
|
||
// 🔴 2026-08-06 leo 裁決:「不要兩支,寫成一支檔案」。
|
||
//
|
||
// collector 的程式碼已經編進這個執行檔(arcrun-rag/collector.Run),
|
||
// 所以 supervisor 拉起子行程時跑的是**同一個 exe**,只是多帶這個參數。
|
||
// 磁碟上不會出現第二支執行檔——那正是 Windows Defender 判 dropper 的特徵
|
||
// (封測者實撞 Trojan:Win32/Sabsik.FL.A!ml,檔案被隔離)。
|
||
//
|
||
// 用雙連字號開頭而不是子命令,是為了與 collector 自己的子命令(scan/sync/direct…)
|
||
// 區分:使用者手動打的是那些,這一個是我們內部用的。
|
||
const collectorModeFlag = "--collector"
|
||
|
||
func main() {
|
||
// 🔴 必須是 main() 的**第一件事**:這個模式下不能碰 GUI、托盤、單一實例鎖,
|
||
// 否則每跑一輪同步就會多一個托盤 icon、還會被單一實例鎖擋掉。
|
||
if len(os.Args) > 1 && os.Args[1] == collectorModeFlag {
|
||
os.Exit(collector.Run(os.Args[2:]))
|
||
}
|
||
|
||
app := NewApp()
|
||
|
||
// 🔴 托盤必須在**主執行緒**建立(NSStatusItem 內部會 new NSWindow,
|
||
// AppKit 規定 NSWindow 只能在主執行緒 new)。
|
||
// 踩過的兩個錯(都真機實測炸掉):
|
||
// ① systray.Run() 自己開 loop ⇒ 與 Wails 搶主 loop ⇒ SIGTRAP
|
||
// ② 在 OnStartup 或 goroutine 裡呼叫 ⇒ 「NSWindow should only be
|
||
// instantiated on the main thread!」⇒ SIGABRT
|
||
// 正解:main() 一開始(還在主執行緒)就 RunWithExternalLoop 註冊並 start,
|
||
// 它不阻塞,接著再把主執行緒交給 wails.Run()。
|
||
// 🔴 托盤必須在**主執行緒**建立(NSStatusItem 內部會 new NSWindow)。
|
||
// 改用原生 NSStatusItem(見 tray_darwin.m)——實測證明 energye/systray
|
||
// 在 RunWithExternalLoop 之下**根本不會建立托盤**(onReady 從未觸發)。
|
||
setupTray(app)
|
||
installSignalHandler() // 收到 TERM/INT 要能正常退出(不必強制結束)
|
||
|
||
err := wails.Run(&options.App{
|
||
Title: "Arcrun",
|
||
// 這裡的 Width/Height 只是**開得起來的保底值**;真正的尺寸在 OnStartup
|
||
// 依螢幕算(見下方 resizeToScreenFraction)。保底值不能大於常見筆電
|
||
// 的可視高度,否則 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()
|
||
},
|
||
},
|
||
// 🔴 2026-08-06(leo 實測):「在 Mac 似乎 disable 了全螢幕的按鈕,
|
||
// 應該要讓使用者需要時可以全螢幕。查看 Windows 時全螢幕是可以用的」。
|
||
// 真兇在 Wails 原始碼,不在我們的視窗設定:
|
||
// window.go:92 `if frontendOptions.Mac != nil { … zoomable = !mac.DisableZoom … }`
|
||
// ⇒ **沒設 Mac 時 zoomable 維持零值 false**
|
||
// WailsContext.m:208 `if (!zoomable && resizable) { [zoomButton setEnabled:NO]; }`
|
||
// ⇒ 綠色按鈕被停用。Windows 沒有這段,所以只有 Mac 壞。
|
||
// 解=給一個**空的** Mac options(DisableZoom 預設 false ⇒ zoomable=true)。
|
||
Mac: &mac.Options{},
|
||
OnStartup: func(ctx wailsCtx) {
|
||
app.startup(ctx)
|
||
// 🔴 必須在 NSApp 起來之後才切 Accessory——Wails 在
|
||
// applicationWillFinishLaunching 硬設 Regular,會蓋掉 Info.plist 的
|
||
// LSUIElement。詳見 dock_darwin.go(取回 604704a 的原版)。
|
||
hideDockIcon()
|
||
resizeToScreenFraction(ctx) // 視窗=螢幕 70%,置中(leo 08-06 照 Google Drive)
|
||
startSupervisor() // 🔴 daemon 本體:不啟動就不會同步
|
||
// 🔴 托盤必須在**主執行緒**建立(NSStatusItem 會 new NSWindow)。
|
||
// OnStartup 不是主執行緒 ⇒ 直接呼叫會炸
|
||
// 「NSWindow should only be instantiated on the main thread!」(實測)。
|
||
// 用 Wails 的 dispatch 把它丟回主執行緒。
|
||
|
||
},
|
||
// 🔴 daemon 是常駐程式:關窗只是收起來,**不能真的結束**
|
||
// (結束=停止看守資料夾,使用者不會預期按 × 就不再同步)。
|
||
//
|
||
// 🔴 2026-08-06(leo 實測):「在托盤按右鍵結束 Arcrun 但實際上 Dock 上的沒結束…
|
||
// 唯一結束法是強制結束…因為舊版沒結束導致無法覆蓋,**如果不知道怎麼強制結束的人
|
||
// 就放棄了**」。
|
||
// 真兇:Wails 的 `runtime.Quit()` **也走這個 OnBeforeClose**——
|
||
// frontend.go:364 `func (f *Frontend) Quit() { … if !OnBeforeClose(ctx) { mainWindow.Quit() } }`
|
||
// 我們無條件回 true ⇒ `mainWindow.Quit()` 永遠不執行 ⇒ 結束選單點了沒用,
|
||
// 而且還順手 WindowHide ⇒ 看起來像關掉了、其實行程還活著。
|
||
// (AppDelegate.m:41 的 applicationShouldTerminate 也一律回 NSTerminateCancel,
|
||
// 把決定權整個交給這個回呼——所以這裡回 true 就真的沒有任何出口。)
|
||
// 解=用 quitting 旗標區分「按 ×(收起來)」與「按結束(真的退)」。
|
||
OnBeforeClose: func(ctx wailsCtx) bool {
|
||
if !closeMeansHide() {
|
||
return false // false=放行,Wails 才會真的呼叫 mainWindow.Quit()
|
||
}
|
||
wruntime.WindowHide(ctx)
|
||
return true // true=攔下關閉(按 × 只是收起來)
|
||
},
|
||
HideWindowOnClose: true,
|
||
// Dock 不出現 icon:Wails v2.13 的 mac.ActivationPolicy 還沒開放(原始碼裡是註解掉的),
|
||
// 而 Info.plist 的 LSUIElement **會被 Wails 啟動時的 Regular policy 蓋掉**
|
||
// (leo 08-06 實撞:Dock 與托盤兩邊都顯示)⇒ 改在 OnStartup 呼叫 hideDockIcon()
|
||
// runtime 切 Accessory。plist 的 LSUIElement 保留(決定啟動當下的初始 policy)。
|
||
Bind: []interface{}{app},
|
||
})
|
||
if err != nil {
|
||
println("Error:", err.Error())
|
||
}
|
||
}
|
||
|
||
// screenFraction 是預設視窗佔螢幕長寬的比例。
|
||
//
|
||
// 🔴 leo 2026-08-06 兩次調整,**以第二次為準**:
|
||
// - 先說「default 50% 螢幕長寬」(因為寫死的 1000x700「高度太高快擠到外面了」)
|
||
// - 實際看到 50% 後改口:「**我查看 Google Drive 的視窗,它應該是大約螢幕長寬的 70%,
|
||
// 因為你改成 50% 後顯得太小**」
|
||
//
|
||
// ⇒ 70%。要再調就改這一個常數,不要散在各處。
|
||
const screenFraction = 0.70
|
||
|
||
// resizeToScreenFraction 把視窗調成螢幕長寬的 screenFraction 並置中。
|
||
//
|
||
// 寫死尺寸在 1366x768 這種常見筆電上,扣掉工作列就幾乎頂到底。
|
||
// 依螢幕算才不會在別人機器上爆版——我們沒有全世界的螢幕可以試。
|
||
//
|
||
// 拿不到螢幕資訊時**什麼都不做**(沿用 options 的保底值),不要瞎猜一個數字:
|
||
// 猜錯的後果就是原本那個病。
|
||
func resizeToScreenFraction(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
|
||
}
|
||
nw, nh := int(float64(w)*screenFraction), int(float64(h)*screenFraction)
|
||
wruntime.WindowSetSize(ctx, nw, nh)
|
||
wruntime.WindowCenter(ctx)
|
||
// 留痕:使用者回報「視窗太大/太小」時,這一行直接說出當時螢幕多大、算出多大。
|
||
// 沒有它就只能請人量像素(08-06 我自己就卡在這)。
|
||
appLog("視窗依螢幕 %dx%d 設為 %dx%d(%.0f%%)", w, h, nw, nh, screenFraction*100)
|
||
}
|
||
|
||
// setupTray 建立選單列 icon。
|
||
//
|
||
// 🔴 leo 2026-08-05:「依照 google drive,托盤裡**只剩下按右鍵會結束**,
|
||
//
|
||
// 其他設定都在這個頁面,**點擊托盤的 icon 就立刻展開界面**」
|
||
//
|
||
// ⇒ 左鍵=直接開視窗(不彈選單);右鍵=只有一項「結束 Arcrun」。
|
||
//
|
||
// 所有設定都在視窗裡,托盤不再是第二套 UI(fyne 版把設定塞在下拉選單,
|
||
// leo:「daemon 設定項越來越多,不可能統統塞在下拉選單」)。
|
||
//
|
||
// 🔴 **不可以用 systray.Run() 開自己的迴圈**(我第一版這樣寫,真機一跑就 SIGTRAP):
|
||
//
|
||
// macOS 的選單列必須在**主執行緒**跑,Wails 已經佔著那條 loop,
|
||
// 兩個 toolkit 搶主 loop ⇒ `signal arrived during cgo execution` 直接崩。
|
||
// 正解是 `RunWithExternalLoop`——它只註冊、把 start/end 交給既有的 loop 呼叫。
|
||
var trayStart, trayEnd = func() {}, func() {}
|