Mac 后台常驻(隐藏 Dock + 关窗续跑):引擎 / 监听上移 App 级

实现②隐藏 Dock 图标 + ③可靠后台刷新,交付单窗口、可后台常驻的 App。

- 新增 MacBackgroundController(@MainActor App 级单例):持有 engine + auth(脱离窗口 @State,生命周期与 App 一致)。把引擎离屏 WebView 挂常驻隐藏 NSWindow(始终在窗口层级、避 WebKit 无窗口挂起,且脱离主窗口生命周期——关主窗口引擎不销毁,SSE / presence / 传输 / 剪贴板续跑)+ App 级 ProcessInfo.beginActivity 防 App Nap + 按登录态启停剪贴板 monitor / 全局热键。observeAuth(withObservationTracking 桥 Observation→命令式)观察 auth.session 驱动 start/stop
- 新增 MacAppDelegate(@NSApplicationDelegateAdaptor):applicationDidFinishLaunching 应用 Dock 策略 + bootstrap 后台运行时;applicationShouldTerminateAfterLastWindowClosed=false——关最后一个窗口不退出,菜单栏常驻续跑
- CommilitiaDropMacApp:engine / auth 改由 MacBackgroundController 持有(不再窗口 @State);MacAppRoot 只做登录门界面切换、不挂引擎生命周期。MacRootView 移除内嵌 EngineWebView + 移除窗口 onAppear/onDisappear 的 monitor/hotkey(全上移 controller)——关窗引擎 / 同步 / 热键不再停
- 设置加「后台」区:隐藏 Dock 图标开关(@AppStorage macHideDock → NSApp.setActivationPolicy .accessory/.regular,默认 regular 显示)+ 后台常驻说明;剪贴板自动同步开关改调 controller.refreshClipboardMonitor;登出只 auth.logout(引擎复位交 controller 随会话变 nil 统一处理)。菜单栏「打开主窗口」先 NSApp.activate 再 openWindow(accessory / 关窗后窗口可前置可见)
- 三项能力齐备:①菜单栏图标(既有 MenuBarExtra)②隐藏 Dock(新增,可设置切换)③可靠后台刷新(新增:引擎 / monitor / 热键 App 级持有 + 隐藏窗口托 WebView + App 级 App Nap 防护 + 关窗不退出续跑)
- 验证:just mac-build(CommilitiaDropMac.app)+ iOS 模拟器(CommilitiaDrop.app)均 BUILD SUCCEEDED;ad-hoc 启动冒烟通过(进程存活、无崩溃报告、干净退出)。运行期后台同步须用户登录后自测(Broker OAuth 需账号)
This commit is contained in:
2026-07-11 12:26:34 +08:00
parent a33a668286
commit 34f0db215c
5 changed files with 172 additions and 40 deletions
+12 -18
View File
@@ -1,15 +1,19 @@
#if os(macOS) #if os(macOS)
import SwiftUI import SwiftUI
// macOS + MacRootView iOS iOS // macOS + MacRootView iOS
// EngineController WKWebView JS + libwebrtc AuthManagerBroker // WKWebView JS + libwebrtc Broker App MacBackgroundController
// WebView MacRootView iOS CDropApp // WebView MacAppDelegate /
// / / // MacBackgroundControlleriOS CDropApp
@main @main
struct CommilitiaDropMacApp: App struct CommilitiaDropMacApp: App
{ {
@State private var auth = AuthManager() @NSApplicationDelegateAdaptor(MacAppDelegate.self) private var appDelegate
@State private var engine = EngineController()
// engine / auth App MacBackgroundController App
// .environment
private var engine: EngineController { MacBackgroundController.shared.engine }
private var auth: AuthManager { MacBackgroundController.shared.auth }
var body: some Scene var body: some Scene
{ {
@@ -40,8 +44,8 @@ struct CommilitiaDropMacApp: App
} }
} }
// MacRootView WebView Broker // Broker / /
// iOS AppRootCDropApp auth userId // MacBackgroundController auth.session
struct MacAppRoot: View struct MacAppRoot: View
{ {
let auth: AuthManager let auth: AuthManager
@@ -63,16 +67,6 @@ struct MacAppRoot: View
.environment(auth) .environment(auth)
} }
} }
.onAppear
{
engine.auth = auth
// userId reset
if let uid = auth.session?.user.id { engine.activateRecords(userId: uid) }
}
.onChange(of: auth.session?.user.id)
{ _, uid in
if let uid { engine.activateRecords(userId: uid) }
}
} }
} }
#endif #endif
+20
View File
@@ -0,0 +1,20 @@
#if os(macOS)
import AppKit
// App Dock + MacBackgroundController退
//
final class MacAppDelegate: NSObject, NSApplicationDelegate
{
func applicationDidFinishLaunching(_ notification: Notification)
{
MacBackgroundController.shared.applyDockPolicy()
MacBackgroundController.shared.bootstrap()
}
// app + 退退/ Q
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool
{
false
}
}
#endif
@@ -0,0 +1,120 @@
#if os(macOS)
import AppKit
import Observation
// Mac App engine + auth WebView
// SSE / presence / / +
// monitor App beginActivity App Nap Dock
// 使 Wails Dock + +
//
// engine / auth App @State App
// .environment @ObservableiOS iOS CDropApp @State
// @MainActorengine / auth / NSApp / NSWindow 线 App / SwiftUI 线
@MainActor
final class MacBackgroundController
{
static let shared = MacBackgroundController()
let auth = AuthManager()
let engine = EngineController()
private var hostWindow: NSWindow?
private var activity: NSObjectProtocol?
private var started = false
private init() {}
// App applicationDidFinishLaunching auth
// AuthManager.init Keychain
//
func bootstrap()
{
engine.auth = auth
if let uid = auth.session?.user.id { engine.activateRecords(userId: uid) }
observeAuth()
syncToAuthState()
}
// auth.sessionObservation withObservationTracking
// re-arm
private func observeAuth()
{
withObservationTracking
{
_ = auth.session?.user.id
}
onChange:
{
Task { @MainActor in
if let uid = self.auth.session?.user.id { self.engine.activateRecords(userId: uid) }
self.syncToAuthState()
self.observeAuth()
}
}
}
private func syncToAuthState()
{
if auth.session != nil { start() }
else { stop() }
}
private func start()
{
guard !started else { return }
started = true
// App App Nap SSE / /
activity = ProcessInfo.processInfo.beginActivity(
options: [ .userInitiatedAllowingIdleSystemSleep ],
reason: "Commilitia Drop background sync")
// WebView WebKit
// makeWebView
engine.auth = auth
let wv = engine.makeWebView()
let win = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
styleMask: [ .borderless ], backing: .buffered, defer: false)
win.isReleasedWhenClosed = false
win.contentView = wv
win.orderOut(nil)
hostWindow = win
refreshClipboardMonitor()
MacHotkey.shared.register()
}
private func stop()
{
guard started else { return }
started = false
MacClipboardMonitor.shared.stop()
MacHotkey.shared.unregister()
engine.reset()
hostWindow?.contentView = nil
hostWindow?.close()
hostWindow = nil
if let activity { ProcessInfo.processInfo.endActivity(activity) }
activity = nil
}
// / + monitor
func refreshClipboardMonitor()
{
let on = UserDefaults.standard.object(forKey: "macClipboardAutoSync") as? Bool ?? true
if started, on
{
MacClipboardMonitor.shared.start { [weak self] text in self?.engine.uploadClipboardText(text) }
}
else
{
MacClipboardMonitor.shared.stop()
}
}
// Dock / accessory Dock regular Dock
// regular accessory
func applyDockPolicy()
{
let hide = UserDefaults.standard.bool(forKey: "macHideDock")
NSApp.setActivationPolicy(hide ? .accessory : .regular)
}
}
#endif
+6 -1
View File
@@ -40,7 +40,12 @@ struct MacMenuContent: View
} }
Divider() Divider()
Button("打开主窗口") { openWindow(id: "main") } Button("打开主窗口")
{
// accessory Dock app
NSApp.activate()
openWindow(id: "main")
}
Button("退出") { NSApplication.shared.terminate(nil) } Button("退出") { NSApplication.shared.terminate(nil) }
} }
.padding(12) .padding(12)
+14 -21
View File
@@ -10,7 +10,6 @@ struct MacRootView: View
{ {
@Environment(EngineController.self) private var engine @Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth @Environment(AuthManager.self) private var auth
@AppStorage("macClipboardAutoSync") private var autoSync = true
@State private var quickSend = MacQuickSend.shared @State private var quickSend = MacQuickSend.shared
@State private var selection: MacSection = .transfers @State private var selection: MacSection = .transfers
@@ -44,24 +43,14 @@ struct MacRootView: View
detail detail
} }
} }
// WebView boot iOS RootView // WebView App MacBackgroundController
.background // WebView monitor /
{ // / App Nap MacBackgroundController
EngineWebView(controller: engine, auth: auth)
.frame(width: 0, height: 0)
.opacity(0)
.allowsHitTesting(false)
}
// / / / iOS RootView // / / / iOS RootView
.overlay(alignment: .top) .overlay(alignment: .top)
{ {
NoticeOverlay() NoticeOverlay()
} }
//
// / Quick Send S
.onAppear { syncClipboardMonitor(); MacHotkey.shared.register() }
.onChange(of: autoSync) { syncClipboardMonitor() }
.onDisappear { MacClipboardMonitor.shared.stop(); MacHotkey.shared.unregister() }
// Quick Send / // Quick Send /
.onChange(of: quickSend.pending) .onChange(of: quickSend.pending)
{ _, urls in { _, urls in
@@ -81,12 +70,6 @@ struct MacRootView: View
} }
} }
private func syncClipboardMonitor()
{
if autoSync { MacClipboardMonitor.shared.start { engine.uploadClipboardText($0) } }
else { MacClipboardMonitor.shared.stop() }
}
@ViewBuilder @ViewBuilder
private var detail: some View private var detail: some View
{ {
@@ -231,6 +214,7 @@ struct MacSettingsView: View
@Environment(AuthManager.self) private var auth @Environment(AuthManager.self) private var auth
@AppStorage("macClipboardAutoSync") private var autoSync = true @AppStorage("macClipboardAutoSync") private var autoSync = true
@AppStorage("macDownloadDir") private var downloadDirPath = "" @AppStorage("macDownloadDir") private var downloadDirPath = ""
@AppStorage("macHideDock") private var hideDock = false
@State private var nameDraft = DeviceNameStore.value @State private var nameDraft = DeviceNameStore.value
private var signalingLabel: String private var signalingLabel: String
@@ -256,16 +240,25 @@ struct MacSettingsView: View
} }
Button("登出", role: .destructive) Button("登出", role: .destructive)
{ {
engine.reset() // App MacBackgroundController nil
auth.logout() auth.logout()
} }
} }
Section("云剪贴板") Section("云剪贴板")
{ {
Toggle("自动同步本机复制(上行)", isOn: $autoSync) Toggle("自动同步本机复制(上行)", isOn: $autoSync)
.onChange(of: autoSync) { MacBackgroundController.shared.refreshClipboardMonitor() }
Button("上传本机剪贴板") { engine.uploadClipboard() } Button("上传本机剪贴板") { engine.uploadClipboard() }
Button("拉取云剪贴板到本机") { engine.pullClipboard() } Button("拉取云剪贴板到本机") { engine.pullClipboard() }
} }
Section("后台")
{
Toggle("隐藏 Dock 图标(仅菜单栏常驻)", isOn: $hideDock)
.onChange(of: hideDock) { MacBackgroundController.shared.applyDockPolicy() }
Text("关闭主窗口后仍在菜单栏后台运行、持续同步;点菜单栏图标可重开窗口。")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("接收") Section("接收")
{ {
LabeledContent("保存到", value: downloadDirDisplay) LabeledContent("保存到", value: downloadDirDisplay)