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:
@@ -1,15 +1,19 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
|
||||
// 原生 macOS 客户端入口。单窗口 + 菜单栏常驻,桌面壳(MacRootView)替代 iOS 的标签栏。复用 iOS 引擎
|
||||
// (EngineController:离屏 WKWebView 跑 JS 引擎 + 原生 libwebrtc 数据面)与鉴权(AuthManager:Broker
|
||||
// 登录);引擎 WebView 挂在 MacRootView 背景保活(登录后才启)。iOS 入口另见 CDropApp。桌面专属功能
|
||||
// (剪贴板自动同步 / 全局热键 / 原生通知)随后续阶段并入。
|
||||
// 原生 macOS 客户端入口。单窗口 + 菜单栏常驻。桌面壳(MacRootView)替代 iOS 的标签栏。引擎(离屏
|
||||
// WKWebView 跑 JS 引擎 + 原生 libwebrtc 数据面)与鉴权(Broker 登录)由 App 级 MacBackgroundController
|
||||
// 持有并驱动后台续跑——引擎 WebView 挂常驻隐藏窗口,关主窗口仍在菜单栏后台同步(见 MacAppDelegate /
|
||||
// MacBackgroundController)。iOS 入口另见 CDropApp。
|
||||
@main
|
||||
struct CommilitiaDropMacApp: App
|
||||
{
|
||||
@State private var auth = AuthManager()
|
||||
@State private var engine = EngineController()
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) private var appDelegate
|
||||
|
||||
// engine / auth 由 App 级 MacBackgroundController 持有(生命周期与 App 一致、不随窗口显隐,以支持
|
||||
// 关窗后台续跑);此处仅引用、经 .environment 注入视图观察。
|
||||
private var engine: EngineController { MacBackgroundController.shared.engine }
|
||||
private var auth: AuthManager { MacBackgroundController.shared.auth }
|
||||
|
||||
var body: some Scene
|
||||
{
|
||||
@@ -40,8 +44,8 @@ struct CommilitiaDropMacApp: App
|
||||
}
|
||||
}
|
||||
|
||||
// 登录门:有会话进桌面壳(引擎随 MacRootView 背景 WebView 启动),无会话进原生 Broker 登录页。逻辑
|
||||
// 对齐 iOS 的 AppRoot(CDropApp):注入 auth 源、会话激活即按 userId 加载持久记录。
|
||||
// 登录门:有会话进桌面壳,无会话进原生 Broker 登录页。引擎的启停 / 记录加载 / 后台常驻由
|
||||
// MacBackgroundController 统一管(观察 auth.session),故本视图只做界面切换、不挂引擎生命周期。
|
||||
struct MacAppRoot: View
|
||||
{
|
||||
let auth: AuthManager
|
||||
@@ -63,16 +67,6 @@ struct MacAppRoot: View
|
||||
.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
|
||||
|
||||
@@ -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 注入观察它们(皆 @Observable)。iOS 侧不受影响(iOS 入口 CDropApp 仍用自身 @State)。
|
||||
// @MainActor:engine / 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.session(Observation 桥到命令式):每次变化重扫状态并重新武装(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
|
||||
@@ -40,7 +40,12 @@ struct MacMenuContent: View
|
||||
}
|
||||
|
||||
Divider()
|
||||
Button("打开主窗口") { openWindow(id: "main") }
|
||||
Button("打开主窗口")
|
||||
{
|
||||
// accessory(隐藏 Dock)或窗口已关时,须先激活 app 再开窗,窗口才前置可见。
|
||||
NSApp.activate()
|
||||
openWindow(id: "main")
|
||||
}
|
||||
Button("退出") { NSApplication.shared.terminate(nil) }
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
@@ -10,7 +10,6 @@ struct MacRootView: View
|
||||
{
|
||||
@Environment(EngineController.self) private var engine
|
||||
@Environment(AuthManager.self) private var auth
|
||||
@AppStorage("macClipboardAutoSync") private var autoSync = true
|
||||
@State private var quickSend = MacQuickSend.shared
|
||||
@State private var selection: MacSection = .transfers
|
||||
|
||||
@@ -44,24 +43,14 @@ struct MacRootView: View
|
||||
detail
|
||||
}
|
||||
}
|
||||
// 离屏无头引擎 WebView:零尺寸不可见,仅保持存活(登录后随本视图出现即 boot)。同 iOS RootView。
|
||||
.background
|
||||
{
|
||||
EngineWebView(controller: engine, auth: auth)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// 引擎离屏 WebView 已由 App 级 MacBackgroundController 挂在常驻隐藏窗口后台续跑(脱离本窗口
|
||||
// 生命周期),故关主窗口引擎不销毁;本视图只呈现引擎状态、不再内嵌 WebView。剪贴板 monitor /
|
||||
// 全局热键 / App Nap 防护同样上移到 MacBackgroundController(按登录态启停,不随本视图显隐)。
|
||||
// 瞬时通知横幅(吊销 / 改名 / 剪贴板 / 登录审批反馈)覆盖窗口顶部。同 iOS RootView。
|
||||
.overlay(alignment: .top)
|
||||
{
|
||||
NoticeOverlay()
|
||||
}
|
||||
// 剪贴板自动同步(上行):登录进壳即按开关启停;开关变化即时生效;登出(本视图消失)停。
|
||||
// 同时注册 / 注销 Quick Send 全局热键(⌃⌥⌘S)。
|
||||
.onAppear { syncClipboardMonitor(); MacHotkey.shared.register() }
|
||||
.onChange(of: autoSync) { syncClipboardMonitor() }
|
||||
.onDisappear { MacClipboardMonitor.shared.stop(); MacHotkey.shared.unregister() }
|
||||
// Quick Send(热键 / 菜单栏触发):有待发文件即切到「传输」并弹发送表单。
|
||||
.onChange(of: quickSend.pending)
|
||||
{ _, 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
|
||||
private var detail: some View
|
||||
{
|
||||
@@ -231,6 +214,7 @@ struct MacSettingsView: View
|
||||
@Environment(AuthManager.self) private var auth
|
||||
@AppStorage("macClipboardAutoSync") private var autoSync = true
|
||||
@AppStorage("macDownloadDir") private var downloadDirPath = ""
|
||||
@AppStorage("macHideDock") private var hideDock = false
|
||||
@State private var nameDraft = DeviceNameStore.value
|
||||
|
||||
private var signalingLabel: String
|
||||
@@ -256,16 +240,25 @@ struct MacSettingsView: View
|
||||
}
|
||||
Button("登出", role: .destructive)
|
||||
{
|
||||
engine.reset()
|
||||
// 引擎复位由 App 级 MacBackgroundController 随登出(会话变 nil)统一处理,此处只登出。
|
||||
auth.logout()
|
||||
}
|
||||
}
|
||||
Section("云剪贴板")
|
||||
{
|
||||
Toggle("自动同步本机复制(上行)", isOn: $autoSync)
|
||||
.onChange(of: autoSync) { MacBackgroundController.shared.refreshClipboardMonitor() }
|
||||
Button("上传本机剪贴板") { engine.uploadClipboard() }
|
||||
Button("拉取云剪贴板到本机") { engine.pullClipboard() }
|
||||
}
|
||||
Section("后台")
|
||||
{
|
||||
Toggle("隐藏 Dock 图标(仅菜单栏常驻)", isOn: $hideDock)
|
||||
.onChange(of: hideDock) { MacBackgroundController.shared.applyDockPolicy() }
|
||||
Text("关闭主窗口后仍在菜单栏后台运行、持续同步;点菜单栏图标可重开窗口。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section("接收")
|
||||
{
|
||||
LabeledContent("保存到", value: downloadDirDisplay)
|
||||
|
||||
Reference in New Issue
Block a user