修首頁狀態說謊+DMG 補上拖曳版面(leo 08-05 封測回報 ①②)

## ② 首頁狀態是壞的
leo:「拖新的檔案進資料夾,完成本地萃取、上傳,但自始至終 daemon 的首頁都顯示
『有變動就自動開始』『等待中』,都顯示綠燈沒動,實際上已經做完了。」

兩個獨立的真兇:

**A. 「同步中」判斷用錯依據(回歸)**
t191 早就做好機制:collector 開工印 phase:"start"、跑完印 "done",
supervisor 據此維護 StateSyncing。**換 Wails 時 App 沒接這條線**,改看 sync-now 訊號檔:
  ① 訊號檔只有手動按「立刻同步」才產生 ⇒ 拖檔進資料夾(自動觸發)整輪不亮燈
  ② 就算手動按,collector 是**先刪檔再跑**(consumeSyncNowSignal)⇒ 真正在跑時檔早沒了
⇒ 改讀 sup.Status().State == StateSyncing。**接回既有機制,不發明第三種判斷法。**

**B. 做完的證據被下一輪抹掉**
ExtractedOK/ExtractFailed 是**本輪**計數、每輪覆寫整份 status.json
⇒ 有產出那輪寫下 N,十幾秒後空轉的一輪把它蓋成 0
⇒ 「上一輪 N 份」永遠空白、四步時間軸的綠燈只靠「跑過任何一輪」判斷=與實際進度脫鉤。
⇒ 新增 last_activity_{at,ok,failed}:有產出記本輪、空轉沿用上一輪(CarryForwardActivity)。
   首頁改顯示「幾點整理了幾份」,綠燈只在真的整理過東西時才亮。

## ① Mac DMG 少了「看得出要拖」的版面
leo:「要跳出虛擬隨身碟,打開一個 finder 的視窗,**顯示 Arcrun 和 Application 的捷徑**,
用戶把 App 拖進 Application」。
原本 DMG 內容是對的(Arcrun.app+Applications 捷徑),但**沒設版面**
⇒ 預設清單視圖、位置隨機,看不出「要往右拖」。
⇒ build-dmg.sh 改走 UDRW → Finder AppleScript 設大圖示/視窗大小/左右並排 → 轉 UDZO。
   best-effort:無桌面工作階段時只警告不中止(版面是加分,不該讓打包掛掉)。

⚠️ 真正讓 leo 拿到 zip 的原因不在這裡——是**線上 portal 還沒出貨**(見下)。

## 驗(實測輸出)
· collector 全測綠;新增 TestCarryForwardActivity 四子測(含「空轉不該抹掉上次成果」)
· 掛載 dist/Arcrun-v0.18.5.dmg 實查:
    內容剛好兩項(Arcrun.app + Applications→/Applications 捷徑)
    .DS_Store 6148 bytes(版面已存進去)
    Contents/MacOS/ 有 arcrun-app **和 arcrun-collector**
    CFBundleShortVersionString = 0.18.5(不是 1.0.0)/LSUIElement = true/codesign -v 通過
· 三支機械閘 check-cis/check-render/check-tray 全 PASS

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 18:01:17 +08:00
parent 1a1478260b
commit 6bf0daa786
6 changed files with 185 additions and 21 deletions
+51 -17
View File
@@ -78,6 +78,11 @@ type syncStatus struct {
ExtractFailed int `json:"extract_failed"`
ExtractorOK bool `json:"extractor_ok"`
ExtractorError string `json:"extractor_error,omitempty"`
// 「上次真的有做事」那一輪(2026-08-05)——ExtractedOK 是本輪計數、會被下一輪歸零,
// 只靠它顯示成果,使用者在同步完成 15 秒後就看不到任何證據(leo 實撞)。
LastActivityAt string `json:"last_activity_at,omitempty"`
LastActivityOK int `json:"last_activity_ok"`
LastActivityFailed int `json:"last_activity_failed"`
}
// loadCfg 同時保留原始 map ⇒ 回寫時**不會弄丟我們沒宣告的欄位**
@@ -164,21 +169,33 @@ func buildSteps(s syncStatus, syncing bool) []Step {
{Title: "上傳到你的知識庫", State: ""},
}
}
// 🔴 2026-08-05:這三步以前只要「跑過任何一輪」(LastSync 非空)就全標綠,
// 而 meta 用的是**本輪**計數(下一輪歸零)⇒ 綠燈與實際進度脫鉤、數字又永遠空白,
// 正是 leo 說的「都顯示綠燈沒動,實際上已經做完了」。
// ⇒ 綠燈只在「真的有整理過東西」時才亮,且亮的是上次有產出那輪的數字+時間。
done := ""
if s.ExtractedOK > 0 || s.LastSync != "" {
if s.LastActivityOK > 0 || s.LastActivityFailed > 0 {
done = "done"
}
found := "等待中"
if done != "" {
if t, err := time.Parse(time.RFC3339, s.LastActivityAt); err == nil {
found = "上次 " + t.Local().Format("15:04")
} else {
found = "已處理"
}
}
meta := ""
if s.ExtractedOK > 0 {
meta = fmt.Sprintf("上一輪 %d 份", s.ExtractedOK)
if s.LastActivityOK > 0 {
meta = fmt.Sprintf("上 %d 份", s.LastActivityOK)
}
up := Step{Title: "上傳到你的知識庫", Meta: meta, State: done}
if s.ExtractFailed > 0 {
up.Meta = fmt.Sprintf("%s · ⚠ %d 份失敗", meta, s.ExtractFailed)
if s.LastActivityFailed > 0 {
up.Meta = strings.TrimPrefix(fmt.Sprintf("%s · ⚠ %d 份失敗", meta, s.LastActivityFailed), " · ")
}
return []Step{
watch,
{Title: "發現變化", Meta: "等待中", State: done},
{Title: "發現變化", Meta: found, State: done},
{Title: "用 AI 整理成知識卡", State: done},
up,
}
@@ -232,14 +249,23 @@ func loadSyncStatus() syncStatus {
// · collector 沒在跑 → 明說「同步引擎沒有在跑」,不要假裝在整理
// · 有訊號檔且引擎活著 → 才是真的「同步中」
// · 其餘 → 看守中
//
// 🔴 2026-08-05 第二修(leo:「拖新檔進資料夾…自始至終都顯示『等待中』,
//
// 實際上已經做完了,這個 status 是壞的」)——t195 只補了「引擎活著」那半,
// 「同步中」仍靠 sync-now 訊號檔判斷,那是錯的判準,兩個理由:
// ① 訊號檔**只有手動按「立刻同步」才會產生**;leo 這次是拖檔進資料夾
// (自動觸發),整輪從頭到尾沒有訊號檔 ⇒ 畫面永遠停在「看守中/等待中」。
// ② 就算是手動按的,collector 是**先刪檔再跑**consumeSyncNowSignal),
// 所以真正在跑的那段時間訊號檔早就不見了。
// ⇒ 改用 t191 已經做好的機制:collector 開工印 phase:"start"、跑完印 "done"
// supervisor 據此維護 StateSyncingsupervisor.go)。**那條線本來就在,
// Wails 版換代時沒接上而已**——不要再自己發明第三種判斷法。
func describeStatus(s syncStatus) (syncing bool, big, sub string) {
alive := collectorAlive()
_, queued := os.Stat(syncNowSignal())
if !alive {
if !collectorAlive() {
return false, "同步引擎沒有在跑", "資料夾不會被自動整理 ⇒ 請結束 Arcrun 再重新開啟"
}
if queued == nil {
if collectorSyncing() {
return true, "同步中… 正在讀檔並整理成知識卡", "請稍候,完成後會顯示整理了幾份"
}
if s.ExtractorError != "" && !s.ExtractorOK {
@@ -247,13 +273,21 @@ func describeStatus(s syncStatus) (syncing bool, big, sub string) {
}
parts := []string{}
if t, err := time.Parse(time.RFC3339, s.LastSync); err == nil {
parts = append(parts, "上次同步 "+t.Local().Format("15:04"))
parts = append(parts, "上次檢查 "+t.Local().Format("15:04"))
}
if s.ExtractedOK > 0 {
parts = append(parts, fmt.Sprintf("已整理 %d 份", s.ExtractedOK))
}
if s.ExtractFailed > 0 {
parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", s.ExtractFailed))
// 用「上次真的有做事」那輪的數字,不用本輪計數——後者每輪歸零,
// 會讓剛整理完的成果在十幾秒後從畫面上消失(就是 leo 撞到的那個「壞掉的 status」)。
if s.LastActivityOK > 0 || s.LastActivityFailed > 0 {
when := ""
if t, err := time.Parse(time.RFC3339, s.LastActivityAt); err == nil {
when = t.Local().Format("15:04") + " "
}
if s.LastActivityOK > 0 {
parts = append(parts, fmt.Sprintf("%s已整理 %d 份", when, s.LastActivityOK))
}
if s.LastActivityFailed > 0 {
parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", s.LastActivityFailed))
}
}
if len(parts) == 0 {
return false, "看守中 · 資料夾有變動就會自動整理", "還沒有同步紀錄"
+42 -3
View File
@@ -22,12 +22,51 @@ echo "② 放 app Applications 捷徑"
ditto "$APP" "$STAGE/Arcrun.app"
ln -s /Applications "$STAGE/Applications"
echo "③ 產 DMG-fs HFS+:否則含空格/中文的名稱會被截斷)"
echo "③ 產可寫 DMG(-fs HFS+:否則含空格/中文的名稱會被截斷)"
rm -f "$DMG"
hdiutil create -volname "Arcrun" -srcfolder "$STAGE" -fs HFS+ -ov -format UDZO "$DMG" >/dev/null
RW="$(mktemp -d)/rw.dmg"
hdiutil create -volname "Arcrun" -srcfolder "$STAGE" -fs HFS+ -ov -format UDRW "$RW" >/dev/null
rm -rf "$(dirname "$STAGE")"
# ── ④ 排版面:讓「拖進去」這件事一眼可見 ────────────────────────────────────
# 🔴 leo 2026-08-05:「要跳出虛擬隨身碟,打開一個 finder 的視窗,**顯示 Arcrun 和
# Application 的捷徑**,用戶把 App 拖進 Application」。
# 光是「裡面有兩個項目」不夠——預設是清單視圖、位置隨機,使用者看不出「要往右拖」。
# ⇒ 設成大圖示、固定視窗大小、兩個 icon 左右並排。
# best-effortFinder 自動化在沒有桌面工作階段時(ssh/CI)會失敗,
# 失敗只警告不中止——版面是加分,DMG 本身照樣可用,不該讓打包整個掛掉。
hdiutil attach "$RW" -noautoopen -quiet
if osascript <<'APPLESCRIPT' >/dev/null 2>&1
tell application "Finder"
tell disk "Arcrun"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {200, 160, 800, 560}
set vopts to the icon view options of container window
set arrangement of vopts to not arranged
set icon size of vopts to 128
set position of item "Arcrun.app" of container window to {150, 190}
set position of item "Applications" of container window to {450, 190}
update without registering applications
close
end tell
end tell
APPLESCRIPT
then
echo " ✅ 版面已設定(大圖示,Arcrun 在左、Applications 在右)"
else
echo " ⚠️ Finder 版面設定失敗(多半是沒有桌面工作階段)——DMG 仍可用,只是視圖是預設值" >&2
fi
sync
hdiutil detach "/Volumes/Arcrun" -quiet
echo "⑤ 壓成唯讀發佈檔"
hdiutil convert "$RW" -format UDZO -o "$DMG" >/dev/null
rm -rf "$(dirname "$RW")"
SIZE=$(ls -lh "$DMG" | awk '{print $5}')
echo "✅ 完成:${DMG}${SIZE}"
echo
echo "🔬 使用者流程:開 DMG → 把 Arcrun 拖到 Applications → 從啟動台開啟"
echo "🔬 使用者流程:開 DMG → 把 Arcrun 拖到 Applications → 退出這個虛擬磁碟 → 從啟動台開啟"
+13
View File
@@ -144,3 +144,16 @@ func collectorAlive() bool {
}
return processRunning(collectorBinPath())
}
// 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
}
+7 -1
View File
@@ -640,7 +640,13 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
break
}
}
if serr := SaveSyncStatus(StatusFilePath(cfg.Manifest), st); serr != nil {
// 🔴 2026-08-05(leo:「自始至終都顯示『等待中』…實際上已經做完了,這個 status 是壞的」):
// 本輪有產出就記成「最近一次有做事」;本輪沒事做則把上一輪的原樣帶下來,
// 不要讓「剛整理完 N 份」這個唯一的完成證據在 15 秒後被歸零抹掉。
statusPath := StatusFilePath(cfg.Manifest)
prev, _ := LoadSyncStatus(statusPath) // 讀不到=零值,等同「沒有上一輪」
CarryForwardActivity(prev, &st)
if serr := SaveSyncStatus(statusPath, st); serr != nil {
fmt.Fprintf(os.Stderr, "status 寫入失敗(不擋看守):%v\n", serr)
}
}
+29
View File
@@ -32,6 +32,14 @@ type SyncStatus struct {
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
@@ -59,6 +67,27 @@ func SaveSyncStatus(path string, s SyncStatus) error {
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
+43
View File
@@ -225,3 +225,46 @@ func TestRunDirectOnceWritesStatusExtractorFail(t *testing.T) {
t.Error("ExtractorError 應有白話說明")
}
}
// TestCarryForwardActivity 鎖住「做完的證據不會被下一輪抹掉」(2026-08-05 leo 實撞的 status 假象)。
//
// 症狀:拖檔進資料夾 → 萃取上傳都跑完了,但首頁自始至終顯示「等待中」、綠燈沒動。
// 真兇:ExtractedOK 是本輪計數,每輪覆寫整份 status.json ⇒ 有產出那輪寫下 N,
// 十幾秒後沒事做的那輪把它蓋成 0,使用者永遠看不到自己剛做完的事。
func TestCarryForwardActivity(t *testing.T) {
t.Run("本輪有產出→記本輪", func(t *testing.T) {
st := SyncStatus{LastSync: "2026-08-05T17:18:34+08:00", ExtractedOK: 3, ExtractFailed: 1}
CarryForwardActivity(SyncStatus{}, &st)
if st.LastActivityAt != st.LastSync || st.LastActivityOK != 3 || st.LastActivityFailed != 1 {
t.Fatalf("有產出的一輪應記成 last_activity,得到 %+v", st)
}
})
t.Run("本輪沒事做→沿用上一輪,不歸零", func(t *testing.T) {
prev := SyncStatus{LastActivityAt: "2026-08-05T17:18:34+08:00", LastActivityOK: 3, LastActivityFailed: 1}
st := SyncStatus{LastSync: "2026-08-05T17:36:38+08:00"} // 空轉的一輪
CarryForwardActivity(prev, &st)
if st.LastActivityOK != 3 || st.LastActivityFailed != 1 {
t.Fatalf("空轉的一輪不該抹掉上次成果,得到 OK=%d Failed=%d", st.LastActivityOK, st.LastActivityFailed)
}
if st.LastActivityAt != prev.LastActivityAt {
t.Fatalf("時間也要沿用上一輪:want %q got %q", prev.LastActivityAt, st.LastActivityAt)
}
})
t.Run("只有失敗也算有做事", func(t *testing.T) {
st := SyncStatus{LastSync: "2026-08-05T18:00:00+08:00", ExtractFailed: 2}
CarryForwardActivity(SyncStatus{}, &st)
if st.LastActivityFailed != 2 || st.LastActivityAt == "" {
t.Fatalf("全失敗的一輪也要留痕(否則使用者不知道剛剛出過事),得到 %+v", st)
}
})
t.Run("從沒跑過→全空,不編造", func(t *testing.T) {
st := SyncStatus{LastSync: "2026-08-05T18:00:00+08:00"}
CarryForwardActivity(SyncStatus{}, &st)
if st.LastActivityAt != "" || st.LastActivityOK != 0 || st.LastActivityFailed != 0 {
t.Fatalf("沒有任何歷史時應保持空值,得到 %+v", st)
}
})
}