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(不再夾帶第二份完整程式)
125 lines
6.7 KiB
Go
125 lines
6.7 KiB
Go
// sync_status.go — 每輪同步後的彙總狀態(t91 狀態可見性)。
|
||
// 寫成 JSON 供托盤讀取,讓使用者第一眼看到萃取是否正常。
|
||
package collector
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
)
|
||
|
||
// AccountSyncStatus 彙總單一帳號的每輪同步結果(t104 多帳號看守)。
|
||
// key in SyncStatus.AccountDetails = instanceHostOf(cypher_url)。
|
||
type AccountSyncStatus struct {
|
||
LastSync string `json:"last_sync,omitempty"`
|
||
CloudVersion string `json:"cloud_version,omitempty"` // t103 per-account
|
||
CloudCheckOK bool `json:"cloud_check_ok"`
|
||
ExtractedOK int `json:"extracted_ok"`
|
||
ExtractFailed int `json:"extract_failed"`
|
||
// t182(leo 08-04:「沒裝好就顯示 workers AI 還沒通,一旦通了就顯示可用」):
|
||
// 這個帳號的雲端實例有沒有 /portal/daemon/extract。**逐帳號**各自記——
|
||
// 用戶可能有多個實例、更新進度不同步。只在走 workers-ai 這條路時探測。
|
||
CloudAIReady bool `json:"cloud_ai_ready"`
|
||
CloudAINote string `json:"cloud_ai_note,omitempty"` // 還沒通時的白話說明(含該做什麼)
|
||
}
|
||
|
||
// SyncStatus 彙總每輪同步的萃取結果,持久化至 ~/.arcrun-rag/status.json。
|
||
// 托盤依此決定顯示「已萃 N 檔」、「⚠ 萃取失敗 M 檔」還是「⚠ 萃取引擎未就緒」。
|
||
type SyncStatus struct {
|
||
LastSync string `json:"last_sync,omitempty"` // RFC3339,最近一輪完成時間
|
||
ExtractedOK int `json:"extracted_ok"` // 本輪萃取成功件數(跨帳號累計)
|
||
ExtractFailed int `json:"extract_failed"` // 本輪萃取失敗件數(跨帳號)
|
||
Failures []ExtractFail `json:"failures,omitempty"` // 失敗清單(路徑+白話原因)
|
||
ExtractorOK bool `json:"extractor_ok"` // 萃取器本身是否就緒(預檢,機器層級)
|
||
ExtractorError string `json:"extractor_error,omitempty"` // 未就緒的白話原因
|
||
// 🔴 最近一輪「真的有做事」的結果(2026-08-05,leo 實撞)。
|
||
// ExtractedOK/ExtractFailed 是**本輪**計數、每輪覆寫 ⇒ 沒事做的那輪就歸零。
|
||
// leo 拖檔進資料夾,萃取上傳都跑完了,但下一輪(15 秒後)把數字歸零
|
||
// ⇒ 首頁「上一輪 N 份」永遠空白,看起來像從頭到尾什麼都沒發生。
|
||
// ⇒ 另存一組「上次有產出的那輪」,沒事做的輪次原樣往下帶,不被清掉。
|
||
LastActivityAt string `json:"last_activity_at,omitempty"` // RFC3339,上次有產出那輪的完成時間
|
||
LastActivityOK int `json:"last_activity_ok"` // 那一輪成功幾份
|
||
LastActivityFailed int `json:"last_activity_failed"` // 那一輪失敗幾份
|
||
// 頂層 cloud 欄位保留向後相容(單帳號時同時填頂層+AccountDetails)
|
||
CloudVersion string `json:"cloud_version,omitempty"` // bundle_version(單帳號時填)
|
||
CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才為 true
|
||
// t104:per-account 狀態(key = instanceHostOf(cypher_url))
|
||
AccountDetails map[string]AccountSyncStatus `json:"account_details,omitempty"`
|
||
|
||
// 🔴 G-6.2「不准安靜地略過」(2026-08-06):副檔名不在 allowedExt 的檔案,
|
||
// 以前在 scan.go 的白名單閘就 `return nil` 蒸發了——沒事件、沒紀錄、沒畫面。
|
||
// 使用者丟一份 .doc 進資料夾,得到的回應是**完全的沉默**。
|
||
// ⇒ 每輪把它們帶出來,讓 App 首頁講一句人話。
|
||
//
|
||
// ⚠️ 與 ExtractedOK 不同,**這三個欄位不進 CarryForwardActivity**:
|
||
// 它們是每輪重走檔案系統算出來的「現況快照」,不是「本輪做了幾件事」的計數
|
||
//(後者才會在沒事做的那輪被歸零=db17f28 修的那個病)。
|
||
// 檔案還躺在資料夾裡,每輪都會被重新數到,所以原地重算就是對的。
|
||
SkippedDocs []SkippedFile `json:"skipped_docs,omitempty"` // 逐檔點名(已排序,上限 MaxSkippedListed)
|
||
SkippedDocCount int `json:"skipped_doc_count"` // 文件類被略過的**總數**(可能大於清單長度)
|
||
SkippedOtherCount int `json:"skipped_other_count"` // 其餘非文件檔(圖片/影音/程式碼…)只給總數
|
||
}
|
||
|
||
// MaxSkippedListed:status.json 裡最多逐檔列幾個。
|
||
// 超過的只反映在 SkippedDocCount,UI 說「…等 N 個」——避免整批舊 Office 檔
|
||
// 把狀態檔撐大,也避免畫面變成一面看不完的檔名牆。
|
||
const MaxSkippedListed = 20
|
||
|
||
// ExtractFail 記一筆萃取失敗(路徑+白話原因)。
|
||
type ExtractFail struct {
|
||
Path string `json:"path"`
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
// StatusFilePath 回傳狀態檔路徑:與 manifest 同目錄的 status.json。
|
||
func StatusFilePath(manifestPath string) string {
|
||
return filepath.Join(filepath.Dir(manifestPath), "status.json")
|
||
}
|
||
|
||
// SaveSyncStatus 寫入(覆蓋)狀態檔。失敗只印 stderr,不擋看守本體。
|
||
func SaveSyncStatus(path string, s SyncStatus) error {
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
return err
|
||
}
|
||
data, _ := json.MarshalIndent(s, "", " ")
|
||
return os.WriteFile(path, data, 0o644)
|
||
}
|
||
|
||
// CarryForwardActivity 決定「最近一次有做事」那三個欄位的值。
|
||
//
|
||
// 🔴 2026-08-05 leo 實撞:「拖新檔進資料夾,完成本地萃取、上傳,但自始至終 daemon 的
|
||
// 首頁都顯示『等待中』…實際上已經做完了,這個 status 是壞的」。
|
||
// 真兇:ExtractedOK/ExtractFailed 是**本輪**計數,每輪覆寫整個 status.json
|
||
// ⇒ 做完事的那輪寫下 N,下一輪(十幾秒後)沒事做就把它蓋成 0,
|
||
// 使用者看到的畫面永遠是「什麼都沒發生」。
|
||
//
|
||
// 規則:本輪有產出 → 記本輪;本輪沒事做 → 原樣沿用上一輪的(不清空)。
|
||
func CarryForwardActivity(prev SyncStatus, st *SyncStatus) {
|
||
if st.ExtractedOK > 0 || st.ExtractFailed > 0 {
|
||
st.LastActivityAt = st.LastSync
|
||
st.LastActivityOK = st.ExtractedOK
|
||
st.LastActivityFailed = st.ExtractFailed
|
||
return
|
||
}
|
||
st.LastActivityAt = prev.LastActivityAt
|
||
st.LastActivityOK = prev.LastActivityOK
|
||
st.LastActivityFailed = prev.LastActivityFailed
|
||
}
|
||
|
||
// LoadSyncStatus 讀取狀態檔;不存在或解析失敗回零值+error(托盤自行降級)。
|
||
func LoadSyncStatus(path string) (SyncStatus, error) {
|
||
var s SyncStatus
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return s, err
|
||
}
|
||
err = json.Unmarshal(data, &s)
|
||
return s, err
|
||
}
|
||
|
||
// SyncNowSignalPath 回傳立刻同步訊號檔路徑:與 manifest 同目錄的 sync-now。
|
||
// tray 寫入此檔 → collector 偵測到後立刻跑一輪同步並刪除它。
|
||
func SyncNowSignalPath(manifestPath string) string {
|
||
return filepath.Join(filepath.Dir(manifestPath), "sync-now")
|
||
}
|