diff --git a/ios/CDrop/MacApp/CommilitiaDropMacApp.swift b/ios/CDrop/MacApp/CommilitiaDropMacApp.swift index 4a478b9..e6cbed5 100644 --- a/ios/CDrop/MacApp/CommilitiaDropMacApp.swift +++ b/ios/CDrop/MacApp/CommilitiaDropMacApp.swift @@ -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 diff --git a/ios/CDrop/MacApp/MacAppDelegate.swift b/ios/CDrop/MacApp/MacAppDelegate.swift new file mode 100644 index 0000000..21c657c --- /dev/null +++ b/ios/CDrop/MacApp/MacAppDelegate.swift @@ -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 diff --git a/ios/CDrop/MacApp/MacBackgroundController.swift b/ios/CDrop/MacApp/MacBackgroundController.swift new file mode 100644 index 0000000..ce45a65 --- /dev/null +++ b/ios/CDrop/MacApp/MacBackgroundController.swift @@ -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 diff --git a/ios/CDrop/MacApp/MacMenuContent.swift b/ios/CDrop/MacApp/MacMenuContent.swift index 99f3f71..7b34972 100644 --- a/ios/CDrop/MacApp/MacMenuContent.swift +++ b/ios/CDrop/MacApp/MacMenuContent.swift @@ -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) diff --git a/ios/CDrop/MacApp/MacRootView.swift b/ios/CDrop/MacApp/MacRootView.swift index 0bc75cc..04f6408 100644 --- a/ios/CDrop/MacApp/MacRootView.swift +++ b/ios/CDrop/MacApp/MacRootView.swift @@ -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)