Mac 原生五区视图(Phase 3b)

在共享引擎公开 API 之上写 Mac 原生视图(不复用 iOS TabView 视图,改按 Mac 惯用交互),接入 MacRootView 五区取代占位。引擎零改动——feature 方法皆 internal,Mac 视图同 target 可直调。

- 传输 MacTransfersView:NSOpenPanel 多选发送 + 拖放发送(.dropDestination,Mac 独有无 iOS 前例)+ 进行中列表(进度条 / 速度 / 直连·中继 / 右键取消·切中继)+ 历史(右键打开·在 Finder 显示·删记录)+ 清空历史;MacComposeSheet(待发文件列表 + 目标设备 radioGroup 选择)
- 文件 MacFilesView:engine.receivedFiles() 轮询(onAppear + history.count 变即刷)+ 双击 / 右键打开(NSWorkspace.open)+ 在 Finder 显示(activateFileViewerSelecting)+ 转发(confirmationDialog 选设备 → sendFile)+ 删除
- 消息 MacMessagesView:HSplitView 双栏(会话列表 + 线程),选中即 setActiveConversation + markConversationRead、离开置 nil,撰写栏 canMessage 门控,气泡 in/out 对齐 + 未读红点
- 设备 MacDevicesView 增强:macDeviceSymbol 类型图标 + 排除本机(selfDeviceID)+ 右键移除设备(confirmationDialog + revokeDevice)
- 设置 MacSettingsView 增强:设备名可改(TextField + renameSelf)+ 云剪贴板上传 / 拉取 + 引擎诊断(status / 信令 hubConnected·hubReconnecting / 在线设备数)+ 引擎日志
- NoticeOverlay 挂 MacRootView 窗口顶部(吊销 / 改名 / 剪贴板 / 登录审批反馈自动横幅);Sources/NotificationBanner.swift 纳入 Mac target
- deviceSymbol / fileByteSize 因所在 iOS 源文件(RootView / SendComposeSheet)未纳入 Mac target,按同语义在 MacFeatureViews 另置一份
- 验证:just mac-build(CommilitiaDropMac.app)+ iOS 模拟器(CommilitiaDrop.app)均 BUILD SUCCEEDED
This commit is contained in:
2026-07-07 22:48:22 +08:00
parent 512d7563ee
commit 5bee5b8942
3 changed files with 611 additions and 35 deletions
+533
View File
@@ -0,0 +1,533 @@
#if os(macOS)
import AppKit
import SwiftUI
// Mac / / / MacRootView
// EngineController API iOS TabView Mac NSOpenPanel /
// / / Finder / HSplitView Mac i18n
// SF Symbol iOS deviceSymbol iOS RootView.swift Mac target
//
func macDeviceSymbol(_ type: String) -> String
{
switch type.lowercased()
{
case let t where t.contains("mac"), let t where t.contains("darwin"): return "laptopcomputer"
case let t where t.contains("win"): return "pc"
case let t where t.contains("linux"): return "terminal"
case let t where t.contains("ipad"): return "ipad"
case let t where t.contains("iphone"), let t where t.contains("ios"): return "iphone"
case let t where t.contains("android"): return "candybarphone"
case let t where t.contains("shortcut"): return "bolt"
case let t where t.contains("browser"): return "globe"
default: return "desktopcomputer"
}
}
private func macFormatBytes(_ n: Int) -> String
{
return ByteCountFormatter.string(fromByteCount: Int64(max(n, 0)), countStyle: .file)
}
// NSOpenPanel runModal
private func pickFilesViaOpenPanel() -> [URL]
{
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = true
panel.prompt = "发送"
return panel.runModal() == .OK ? panel.urls : []
}
// MARK: -
struct MacTransfersView: View
{
@Environment(EngineController.self) private var engine
@State private var composeURLs: [URL] = []
@State private var showCompose = false
@State private var showNoDevices = false
var body: some View
{
List
{
if !engine.transfers.isEmpty
{
Section("进行中")
{
ForEach(engine.transfers) { MacTransferRow(item: $0) }
}
}
if !engine.history.isEmpty
{
Section("历史")
{
ForEach(engine.history) { MacHistoryRow(item: $0) }
}
}
if engine.transfers.isEmpty, engine.history.isEmpty
{
ContentUnavailableView("暂无传输", systemImage: "arrow.up.arrow.down",
description: Text("点右上「发送文件」或把文件拖到此处,选目标设备发送。"))
}
}
.navigationTitle("传输")
.toolbar
{
ToolbarItem
{
Button
{
let urls = pickFilesViaOpenPanel()
if !urls.isEmpty { present(urls) }
}
label:
{
Label("发送文件", systemImage: "plus")
}
}
if !engine.history.isEmpty
{
ToolbarItem
{
Button("清空历史") { engine.clearHistory() }
}
}
}
// Mac iOS
.dropDestination(for: URL.self)
{ urls, _ in
let files = urls.filter { !$0.hasDirectoryPath }
guard !files.isEmpty else { return false }
present(files)
return true
}
.sheet(isPresented: $showCompose)
{
MacComposeSheet(urls: composeURLs,
onCancel: { showCompose = false },
onSend:
{ device in
for url in composeURLs { engine.sendFile(to: device, fileURL: url) }
composeURLs = []
showCompose = false
})
}
.alert("暂无可达设备", isPresented: $showNoDevices)
{
Button("", role: .cancel) {}
}
message:
{
Text("请确保有其他在线设备(或可唤醒的 iOS 设备)。")
}
}
private func present(_ urls: [URL])
{
guard !engine.sendableDevices().isEmpty else { showNoDevices = true; return }
composeURLs = urls
showCompose = true
}
}
// + + + + /
struct MacTransferRow: View
{
@Environment(EngineController.self) private var engine
let item: TransferItem
private var fraction: Double
{
Double(item.bytesTransferred ?? 0) / Double(max(item.fileSize, 1))
}
var body: some View
{
HStack(spacing: 10)
{
Image(systemName: item.direction == "incoming" ? "arrow.down.circle" : "arrow.up.circle")
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 3)
{
Text(item.fileName).lineLimit(1)
ProgressView(value: min(max(fraction, 0), 1))
HStack(spacing: 8)
{
Text(item.peerName)
if let bps = item.bytesPerSec, bps > 0
{
Text("· \(macFormatBytes(Int(bps)))/s")
}
if let mode = item.mode
{
Text("· \(mode == "relay" ? "中继" : "直连")")
}
Spacer()
Text(macFormatBytes(item.bytesTransferred ?? 0) + " / " + macFormatBytes(item.fileSize))
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
.contextMenu
{
Button("取消", role: .destructive) { engine.cancelTransfer(item.sessionId) }
if item.mode != "relay"
{
Button("切换到中继") { engine.switchToRelay(item.sessionId) }
}
}
}
}
// + + /
struct MacHistoryRow: View
{
@Environment(EngineController.self) private var engine
let item: TransferItem
private var localURL: URL?
{
item.direction == "incoming" ? engine.receivedFile(matching: item.fileName) : nil
}
var body: some View
{
HStack(spacing: 10)
{
Image(systemName: item.direction == "incoming" ? "arrow.down" : "arrow.up")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2)
{
Text(item.fileName).lineLimit(1)
Text("\(item.peerName) · \(macFormatBytes(item.fileSize)) · \(macStateLabel(item.state))")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 2)
.contextMenu
{
if let url = localURL
{
Button("打开") { NSWorkspace.shared.open(url) }
Button("在 Finder 中显示") { NSWorkspace.shared.activateFileViewerSelecting([url]) }
}
Button("删除记录", role: .destructive) { engine.deleteTransferRecord(item.sessionId) }
}
}
}
private func macStateLabel(_ state: String) -> String
{
switch state
{
case "completed": return "已完成"
case "failed": return "失败"
case "cancelled", "canceled": return "已取消"
default: return state
}
}
// Mac + iOS SendComposeSheet
struct MacComposeSheet: View
{
@Environment(EngineController.self) private var engine
let urls: [URL]
let onCancel: () -> Void
let onSend: (String) -> Void
@State private var selected = ""
var body: some View
{
VStack(spacing: 0)
{
Form
{
Section("待发送(\(urls.count)")
{
ForEach(urls, id: \.self)
{ url in
HStack
{
Image(systemName: "doc").foregroundStyle(.secondary)
Text(url.lastPathComponent).lineLimit(1)
Spacer()
Text(macFormatBytes(fileByteSizeMac(url)))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section("目标设备")
{
let devices = engine.sendableDevices()
if devices.isEmpty
{
Text("暂无可达设备").foregroundStyle(.secondary)
}
else
{
Picker("发送到", selection: $selected)
{
ForEach(devices)
{ dev in
Text(engine.sendLabel(dev)).tag(dev.name)
}
}
.pickerStyle(.radioGroup)
}
}
}
.formStyle(.grouped)
Divider()
HStack
{
Button("取消", role: .cancel) { onCancel() }
Spacer()
Button("发送") { onSend(selected) }
.keyboardShortcut(.defaultAction)
.disabled(selected.isEmpty || urls.isEmpty)
}
.padding(12)
}
.frame(width: 440, height: 380)
.onAppear { if selected.isEmpty { selected = engine.sendableDevices().first?.name ?? "" } }
}
}
// iOS fileByteSize URL 访
private func fileByteSizeMac(_ url: URL) -> Int
{
let access = url.startAccessingSecurityScopedResource()
defer { if access { url.stopAccessingSecurityScopedResource() } }
return (try? url.resourceValues(forKeys: [ .fileSizeKey ]))?.fileSize ?? 0
}
// MARK: -
struct MacFilesView: View
{
@Environment(EngineController.self) private var engine
@State private var files: [URL] = []
@State private var forwarding: URL?
var body: some View
{
Group
{
if files.isEmpty
{
ContentUnavailableView("暂无收到的文件", systemImage: "folder",
description: Text("收到的文件会落到「文稿」目录,并在此列出。"))
}
else
{
List(files, id: \.self)
{ url in
HStack(spacing: 10)
{
Image(systemName: "doc")
.foregroundStyle(.tint)
Text(url.lastPathComponent).lineLimit(1)
Spacer()
Text(macFormatBytes(fileByteSizeMac(url)))
.font(.caption)
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
.onTapGesture(count: 2) { NSWorkspace.shared.open(url) }
.contextMenu
{
Button("打开") { NSWorkspace.shared.open(url) }
Button("在 Finder 中显示") { NSWorkspace.shared.activateFileViewerSelecting([url]) }
Button("转发…") { forwarding = url }
Divider()
Button("删除", role: .destructive)
{
engine.deleteReceivedFile(url)
reload()
}
}
}
}
}
.navigationTitle("文件")
.onAppear { reload() }
.onChange(of: engine.history.count) { reload() }
.confirmationDialog("转发到…", isPresented: Binding(
get: { forwarding != nil },
set: { if !$0 { forwarding = nil } }))
{
if let url = forwarding
{
ForEach(engine.sendableDevices())
{ dev in
Button(engine.sendLabel(dev)) { engine.sendFile(to: dev.name, fileURL: url) }
}
}
Button("取消", role: .cancel) {}
}
}
private func reload()
{
files = engine.receivedFiles()
}
}
// MARK: -
struct MacMessagesView: View
{
@Environment(EngineController.self) private var engine
@State private var selectedPeer: String?
var body: some View
{
HSplitView
{
//
List(selection: $selectedPeer)
{
ForEach(engine.conversations())
{ convo in
HStack(spacing: 8)
{
Image(systemName: macDeviceSymbol(convo.deviceType))
.foregroundStyle(convo.online ? Color.green : Color.secondary)
VStack(alignment: .leading, spacing: 2)
{
Text(convo.peerName).lineLimit(1)
Text(convo.lastText).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
let n = engine.unread(for: convo.peerName)
if n > 0
{
Text("\(n)")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.red, in: Capsule())
.foregroundStyle(.white)
}
}
.tag(convo.peerName)
}
}
.frame(minWidth: 220, idealWidth: 260, maxWidth: 340)
// 线
Group
{
if let peer = selectedPeer
{
MacThreadView(peer: peer)
.id(peer)
}
else
{
ContentUnavailableView("选择一个会话", systemImage: "bubble.left.and.bubble.right")
}
}
.frame(minWidth: 360, maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle("消息")
.onChange(of: selectedPeer)
{ _, peer in
engine.setActiveConversation(peer)
if let peer { engine.markConversationRead(peer) }
}
.onDisappear { engine.setActiveConversation(nil) }
}
}
// 线 + canMessage
struct MacThreadView: View
{
@Environment(EngineController.self) private var engine
let peer: String
@State private var draft = ""
var body: some View
{
VStack(spacing: 0)
{
ScrollView
{
LazyVStack(alignment: .leading, spacing: 8)
{
ForEach(engine.thread(with: peer))
{ msg in
MacMessageBubble(msg: msg)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
}
Divider()
if engine.canMessage(peer)
{
HStack(spacing: 8)
{
TextField("发消息…", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1 ... 4)
.onSubmit(send)
Button("发送", action: send)
.keyboardShortcut(.return, modifiers: [])
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(10)
}
else
{
Text("对方当前不可达,仅可查看历史。")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(10)
}
}
}
private func send()
{
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
engine.sendMessage(to: peer, text: text)
draft = ""
}
}
struct MacMessageBubble: View
{
let msg: MessageItem
private var outgoing: Bool { msg.direction == "outgoing" }
var body: some View
{
HStack
{
if outgoing { Spacer(minLength: 60) }
Text(msg.text)
.textSelection(.enabled)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(outgoing ? Color.accentColor.opacity(0.85) : Color.secondary.opacity(0.18),
in: RoundedRectangle(cornerRadius: 12))
.foregroundStyle(outgoing ? Color.white : Color.primary)
if !outgoing { Spacer(minLength: 60) }
}
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
}
}
#endif
+75 -33
View File
@@ -49,6 +49,11 @@ struct MacRootView: View
.opacity(0)
.allowsHitTesting(false)
}
// / / / iOS RootView
.overlay(alignment: .top)
{
NoticeOverlay()
}
}
@ViewBuilder
@@ -57,11 +62,11 @@ struct MacRootView: View
switch selection
{
case .transfers:
MacPlaceholder(section: .transfers)
MacTransfersView()
case .files:
MacPlaceholder(section: .files)
MacFilesView()
case .messages:
MacPlaceholder(section: .messages)
MacMessagesView()
case .devices:
MacDevicesView()
case .settings:
@@ -128,66 +133,79 @@ enum MacSection: Hashable, Identifiable, CaseIterable
static var sidebar: [MacSection] { MacSection.allCases }
}
// / / Phase 3b iOS
struct MacPlaceholder: View
{
let section: MacSection
var body: some View
{
ContentUnavailableView
{
Label(section.title, systemImage: section.icon)
}
description:
{
Text("即将接入(原生数据面复用 iOS 引擎,桌面功能适配中)。")
}
.navigationTitle(section.title)
}
}
// presence线绿 + + / SSE
// 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 engine.devices.isEmpty
if others.isEmpty
{
ContentUnavailableView("暂无其他设备", systemImage: "laptopcomputer.and.iphone",
description: Text("同账户的其他在线设备将显示在此。"))
description: Text("同账户的其他设备将显示在此。"))
}
else
{
List(engine.devices)
List(others)
{ dev in
HStack(spacing: 10)
{
Circle()
.fill(dev.online ? Color.green : Color.secondary)
.frame(width: 9, height: 9)
Image(systemName: macDeviceSymbol(dev.type))
.foregroundStyle(dev.online ? Color.green : Color.secondary)
VStack(alignment: .leading, spacing: 2)
{
Text(dev.name)
Spacer()
Text(dev.type)
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) 的登录,该设备需重新登录。")
}
}
}
// + + + 3a
// + + + + +
struct MacSettingsView: View
{
@Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth
@State private var nameDraft = DeviceNameStore.value
private var signalingLabel: String
{
if engine.hubConnected { return "已连接" }
if engine.hubReconnecting { return "重连中…" }
return "未连接"
}
var body: some View
{
@@ -196,13 +214,30 @@ struct MacSettingsView: View
Section("账号")
{
LabeledContent("用户", value: auth.session?.user.name ?? "")
LabeledContent("本机设备", value: engine.deviceName)
HStack
{
TextField("本机设备名", text: $nameDraft)
.onSubmit { commitRename() }
Button("保存") { commitRename() }
.disabled(nameDraft.trimmingCharacters(in: .whitespaces).isEmpty)
}
Button("登出", role: .destructive)
{
engine.reset()
auth.logout()
}
}
Section("云剪贴板")
{
Button("上传本机剪贴板") { engine.uploadClipboard() }
Button("拉取云剪贴板到本机") { engine.pullClipboard() }
}
Section("引擎")
{
LabeledContent("状态", value: engine.status)
LabeledContent("信令", value: signalingLabel)
LabeledContent("在线设备", value: "\(engine.presenceCount)")
}
Section("引擎日志")
{
if engine.logs.isEmpty
@@ -225,5 +260,12 @@ struct MacSettingsView: View
.formStyle(.grouped)
.navigationTitle("设置")
}
private func commitRename()
{
let name = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
engine.renameSelf(to: name)
}
}
#endif
+1
View File
@@ -193,6 +193,7 @@ targets:
- path: Sources/LocalNetworkPermission.swift
- path: Sources/DeviceNameStore.swift
- path: Sources/BackgroundTaskManager.swift
- path: Sources/NotificationBanner.swift
- path: Sources/Resources/i18n
dependencies:
# 原生数据面 libwebrtcLibWebRtcEngine)。macOS 切片缺子头,构建经 just mac-prep 补齐