Files
Commilitia-Drop/ios/CDrop/MacApp/MacRootView.swift
T
admin 34f0db215c 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 需账号)
2026-07-11 12:26:34 +08:00

322 lines
11 KiB
Swift
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.
#if os(macOS)
import AppKit
import SwiftUI
// NavigationSplitView / / / / + NavigationStack
// iOS WebView iOS RootView / /
// Phase 3b iOS / presence / / 3a
// i18nt() Phase 3b
struct MacRootView: View
{
@Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth
@State private var quickSend = MacQuickSend.shared
@State private var selection: MacSection = .transfers
var body: some View
{
NavigationSplitView
{
List(selection: $selection)
{
brandHeader
.listRowInsets(EdgeInsets(top: 10, leading: 8, bottom: 14, trailing: 8))
.listRowSeparator(.hidden)
.selectionDisabled()
Section
{
ForEach(MacSection.sidebar)
{ section in
Label(section.title, systemImage: section.icon)
.badge(badge(for: section))
.tag(section)
}
}
}
.navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280)
.navigationTitle("Commilitia Drop")
}
detail:
{
NavigationStack
{
detail
}
}
// WebView App MacBackgroundController
// WebView monitor /
// / App Nap MacBackgroundController
// / / / iOS RootView
.overlay(alignment: .top)
{
NoticeOverlay()
}
// Quick Send /
.onChange(of: quickSend.pending)
{ _, urls in
if !urls.isEmpty { selection = .transfers }
}
.sheet(isPresented: Binding(get: { !quickSend.pending.isEmpty },
set: { if !$0 { quickSend.pending = [] } }))
{
MacComposeSheet(urls: quickSend.pending,
onCancel: { quickSend.pending = [] },
onSend:
{ device in
for url in quickSend.pending { engine.sendFile(to: device, fileURL: url) }
quickSend.pending = []
})
.environment(engine)
}
}
@ViewBuilder
private var detail: some View
{
switch selection
{
case .transfers:
MacTransfersView()
case .files:
MacFilesView()
case .messages:
MacMessagesView()
case .devices:
MacDevicesView()
case .settings:
MacSettingsView()
}
}
private var brandHeader: some View
{
HStack(spacing: 10)
{
Image(systemName: "paperplane.fill")
.font(.title2)
.foregroundStyle(.tint)
Text("Commilitia Drop")
.font(.title3.weight(.semibold))
.lineLimit(1)
}
}
// / iOS RootView badge
private func badge(for section: MacSection) -> Int
{
switch section
{
case .messages: return engine.unreadMessages
case .transfers: return engine.unreadTransfers
default: return 0
}
}
}
// iOS / / / /
enum MacSection: Hashable, Identifiable, CaseIterable
{
case transfers, files, messages, devices, settings
var id: Self { self }
var title: String
{
switch self
{
case .transfers: return "传输"
case .files: return "文件"
case .messages: return "消息"
case .devices: return "设备"
case .settings: return "设置"
}
}
var icon: String
{
switch self
{
case .transfers: return "arrow.up.arrow.down"
case .files: return "folder"
case .messages: return "bubble.left.and.bubble.right"
case .devices: return "laptopcomputer.and.iphone"
case .settings: return "gearshape"
}
}
static var sidebar: [MacSection] { MacSection.allCases }
}
// presence + 线 +
struct MacDevicesView: View
{
@Environment(EngineController.self) private var engine
@State private var revokeTarget: DeviceItem?
private var others: [DeviceItem]
{
engine.devices.filter { $0.deviceID != engine.selfDeviceID }
}
var body: some View
{
Group
{
if others.isEmpty
{
ContentUnavailableView("暂无其他设备", systemImage: "laptopcomputer.and.iphone",
description: Text("同账户的其他设备将显示在此。"))
}
else
{
List(others)
{ dev in
HStack(spacing: 10)
{
Image(systemName: macDeviceSymbol(dev.type))
.foregroundStyle(dev.online ? Color.green : Color.secondary)
VStack(alignment: .leading, spacing: 2)
{
Text(dev.name)
Text(dev.online ? "在线" : "离线")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.contextMenu
{
Button("移除设备", role: .destructive) { revokeTarget = dev }
}
}
}
}
.navigationTitle("设备")
.confirmationDialog("移除该设备?",
isPresented: Binding(get: { revokeTarget != nil },
set: { if !$0 { revokeTarget = nil } }),
presenting: revokeTarget)
{ dev in
Button("移除 \(dev.name)", role: .destructive) { engine.revokeDevice(dev.deviceID) }
Button("取消", role: .cancel) {}
}
message:
{ dev in
Text("将吊销 \(dev.name) 的登录,该设备需重新登录。")
}
}
}
// + + + + +
struct MacSettingsView: View
{
@Environment(EngineController.self) private var engine
@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
{
if engine.hubConnected { return "已连接" }
if engine.hubReconnecting { return "重连中…" }
return "未连接"
}
var body: some View
{
Form
{
Section("账号")
{
LabeledContent("用户", value: auth.session?.user.name ?? "")
HStack
{
TextField("本机设备名", text: $nameDraft)
.onSubmit { commitRename() }
Button("保存") { commitRename() }
.disabled(nameDraft.trimmingCharacters(in: .whitespaces).isEmpty)
}
Button("登出", role: .destructive)
{
// 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)
Button("选择目录…") { pickDownloadDir() }
}
Section("引擎")
{
LabeledContent("状态", value: engine.status)
LabeledContent("信令", value: signalingLabel)
LabeledContent("在线设备", value: "\(engine.presenceCount)")
}
Section("引擎日志")
{
if engine.logs.isEmpty
{
Text("暂无日志").foregroundStyle(.secondary)
}
else
{
ForEach(engine.logs.prefix(30))
{ entry in
Text(entry.message)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
}
}
}
.formStyle(.grouped)
.navigationTitle("设置")
}
private func commitRename()
{
let name = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
engine.renameSelf(to: name)
}
private var downloadDirDisplay: String
{
downloadDirPath.isEmpty ? "下载(默认)" : (downloadDirPath as NSString).abbreviatingWithTildeInPath
}
private func pickDownloadDir()
{
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "选择"
if panel.runModal() == .OK, let url = panel.url
{
downloadDirPath = url.path
}
}
}
#endif