Files
arcrun-collector/cmd/arcrun-app/main.go
T
Leo f4eb3c9fa1 Mac 四修:Dock 不再重複/結束真的會結束/全螢幕可用/視窗 70%
leo 08-06 Mac 實測回報。四個都是**只在 Mac 現形**的病。

## ① Dock 與選單列兩邊都顯示(連帶:只有 Arcrun 列在強制結束清單)
真兇:Wails 在 AppDelegate.m:44 applicationWillFinishLaunching 硬設
`setActivationPolicy:Regular`,**runtime 蓋掉 Info.plist 的 LSUIElement**
(後者只決定啟動當下的初始 policy)。而 v2.13 的 mac.ActivationPolicy 是註解掉的。
🔴 **這件事 fyne 版 604704a(07-24)已經解過且真機驗證通過**,換 Wails 時整段掉了;
   wiki mistakes.md:124 也早就記著同一條。**本次是取回原版,不是重新發明**——
   dock_darwin.go/dock_other.go 逐字沿用,只更新註解說明 Wails 死法相同。

## ② 托盤右鍵「結束 Arcrun」點了不會結束
leo:「唯一結束法是強制結束…因為舊版沒結束導致無法覆蓋,
      **如果不知道怎麼強制結束的人就放棄了**」
真兇:Wails 的 runtime.Quit() 與「按 ×」**共用同一個 OnBeforeClose**
(frontend.go:364 `if !OnBeforeClose(ctx) { mainWindow.Quit() }`),
我們無條件回 true ⇒ mainWindow.Quit() 永遠不執行,還順手 WindowHide
⇒ 看起來像關了、其實行程還活著。
(AppDelegate.m:41 的 applicationShouldTerminate 也一律回 NSTerminateCancel,
  決定權整個在這個回呼 ⇒ 回 true 就沒有任何出口。)
解:quitting 旗標區分兩者,並抽出 closeMeansHide()/beginQuit() 讓它**可機械驗證**。

## ③ Mac 全螢幕(綠色)按鈕被停用
真兇同樣在 Wails:window.go:92 `zoomable` 只在 `frontendOptions.Mac != nil` 時賦值,
我們沒設 Mac ⇒ zoomable 維持零值 false ⇒ WailsContext.m:208
`if (!zoomable && resizable) { [zoomButton setEnabled:NO]; }`。Windows 無此段。
解:`Mac: &mac.Options{}`(空的即可,DisableZoom 預設 false)。

## ④ 視窗 50% 太小
leo:「Google Drive 大約螢幕長寬的 70%,改成 50% 後顯得太小」⇒ 常數 screenFraction=0.70。
並加 appLog 留痕(螢幕多大、算出多大),以後不必請人量像素。

## 驗(本機真機實跑,非推測)
· `lsappinfo` → **type="UIElement"**、Version="0.18.8"
  = 604704a 當年用的同一個驗法,通過 ⇒ Dock 無 icon、不列強制結束
· 截圖放大號誌燈 → **綠燈是彩色的(enabled)**,停用時會是灰色
· app.log 自報 → **視窗依螢幕 1470x956 設為 1029x669(70%)**
· 首頁顯示「看守中·資料夾有變動就會自動整理/已整理 1 份」=判活正常
· go test TestCloseMeansHide 通過(釘住「按×要隱藏、按結束要放行」)
· go vet 全綠;mac build + windows 交叉編譯皆 OK
· **未驗**:托盤右鍵實際點擊(osascript 缺輔助取用權限,點不到選單列)
  ⇒ 邏輯已單元測試+原始碼追到底,但**實際點擊歸 leo 真機**
2026-08-06 14:11:49 +08:00

198 lines
9.6 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
import (
"context"
"embed"
"sync/atomic"
"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() }
func main() {
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-06leo 實測):「在 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 optionsDisableZoom 預設 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-06leo 實測):「在托盤按右鍵結束 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 不出現 iconWails 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() {}