Files
Commilitia-Drop/ios/CDrop/MacApp/MacFeatureViews.swift
T
admin 5bee5b8942 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
2026-07-07 22:48:22 +08:00

534 lines
18 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
// 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