34f0db215c
实现②隐藏 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 需账号)
322 lines
11 KiB
Swift
322 lines
11 KiB
Swift
#if os(macOS)
|
||
import AppKit
|
||
import SwiftUI
|
||
|
||
// 桌面根界面:NavigationSplitView 侧栏(传输 / 文件 / 消息 / 设备 / 设置)+ 详情 NavigationStack,
|
||
// 替代 iOS 的标签栏。离屏无头引擎 WebView 挂在背景保活(同 iOS RootView)。传输 / 文件 / 消息详情为
|
||
// 占位(随 Phase 3b 复用 iOS 视图);设备 / 设置已接引擎真实态(presence / 账号 / 日志),作 3a 引擎
|
||
// 联通证明。分区标签暂硬编中文;接入 i18n(t())待 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
|