原生 macOS 引擎 + Broker 登录接通(Phase 3a)

- 引擎 / 鉴权跨平台 in-place(iOS 路径 #if 守卫、逐字不变,仅加 macOS/AppKit 分支):EngineController(import 守卫 + 剪贴板 UIPasteboard→NSPasteboard 上下行两处 + device_type 平台条件)、EngineWebView(UIViewRepresentable→typealias 统一后分 NSViewRepresentable)、Theme(动态色 Color(nsColor:dynamicProvider) 同 RGB)、DeviceNameStore(Host.current().localizedName)、PushRegistry(NSApplication.registerForRemoteNotifications)、Auth/BrokerLogin(presentationAnchor→NSApplication.keyWindow)、Auth/AuthManager(死 import UIKit 守卫)
- BackgroundTaskManager:整文件 #if os(iOS)(BGContinuedProcessingTask 为 API_UNAVAILABLE(macos)、编译级拒绝)+ macOS 同签名 no-op stub,使 EngineController.syncBackgroundTask 调用点无须改动
- device_type 修正(非机械):Mac 原生客户端 boot 注入 device_type=macos(与桌面 Wails 客户端一致,后端 / 各端已识别),不再硬编 ios——否则其他端设备列表图标错、且触发其 isWakeable 的 contains("ios") 误判本 Mac 可推送唤醒
- CommilitiaDropMac target 纳入「引擎 + 登录」最小集:Shared + Sources/Engine + Sources/Auth(除 iOS 专属 LoginView)+ RecordsStore / PushRegistry / LocalNetworkPermission / DeviceNameStore / BackgroundTaskManager + Resources/i18n;依赖 WebRTC;Info.plist 加 cdrop:// 回调 scheme(ASWebAuthenticationSession OAuth 回调)+ 本地网络(NSLocalNetworkUsageDescription / NSBonjourServices _cdrop._tcp)
- Mac 壳接引擎:CommilitiaDropMacApp(@State auth + engine,MacAppRoot 登录门 + 会话激活分桶记录)、MacLoginView(Broker 账户登录,免相机)、MacRootView(背景挂离屏引擎 WebView 保活 + 设备真实 presence 列表 + 设置账号/登出/引擎日志)、MacMenuContent(账号 + 在线设备数)。传输/文件/消息详情暂占位,随 Phase 3b 复用 iOS 视图
- 验证:just mac-build(CommilitiaDropMac.app)+ iOS 模拟器(CommilitiaDrop.app)均 BUILD SUCCEEDED。运行期登录 / 引擎联通待真机自测(Broker OAuth 需账号交互)
This commit is contained in:
2026-07-07 22:22:30 +08:00
parent 9d46e26f50
commit 512d7563ee
14 changed files with 378 additions and 20 deletions
+46 -6
View File
@@ -1,18 +1,21 @@
#if os(macOS)
import SwiftUI
// 原生 macOS 客户端入口。单窗口 + 菜单栏常驻,桌面壳(MacRootView)替代 iOS 的标签栏。
// 当前为脚手架:导航骨架 + 菜单栏占位;引擎(LibWebRtcEngine + 离屏 WebView)、复用视图与
// 桌面专属功能(剪贴板自动同步 / 全局热键 / 原生通知)随后续阶段并入。iOS 入口另见 CDropApp。
// 原生 macOS 客户端入口。单窗口 + 菜单栏常驻,桌面壳(MacRootView)替代 iOS 的标签栏。复用 iOS 引擎
// (EngineController:离屏 WKWebView 跑 JS 引擎 + 原生 libwebrtc 数据面)与鉴权(AuthManager:Broker
// 登录);引擎 WebView 挂在 MacRootView 背景保活(登录后才启)。iOS 入口另见 CDropApp。桌面专属功能
// (剪贴板自动同步 / 全局热键 / 原生通知)随后续阶段并入。
@main
struct CommilitiaDropMacApp: App
{
@State private var auth = AuthManager()
@State private var engine = EngineController()
var body: some Scene
{
// 启动即打开的单主窗口。
Window("Commilitia Drop", id: "main")
{
MacRootView()
MacAppRoot(auth: auth, engine: engine)
.frame(minWidth: 820, minHeight: 560)
}
.defaultLaunchBehavior(.presented)
@@ -23,10 +26,11 @@ struct CommilitiaDropMacApp: App
CommandGroup(replacing: .newItem) {}
}
// 菜单栏常驻:状态 + 快捷剪贴板 / Quick Send(当前占位)。
MenuBarExtra
{
MacMenuContent()
.environment(engine)
.environment(auth)
}
label:
{
@@ -35,4 +39,40 @@ struct CommilitiaDropMacApp: App
.menuBarExtraStyle(.window)
}
}
// 登录门:有会话进桌面壳(引擎随 MacRootView 背景 WebView 启动),无会话进原生 Broker 登录页。逻辑
// 对齐 iOS 的 AppRoot(CDropApp):注入 auth 源、会话激活即按 userId 加载持久记录。
struct MacAppRoot: View
{
let auth: AuthManager
let engine: EngineController
var body: some View
{
Group
{
if auth.session != nil
{
MacRootView()
.environment(engine)
.environment(auth)
}
else
{
MacLoginView()
.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
+54
View File
@@ -0,0 +1,54 @@
#if os(macOS)
import SwiftUI
// 原生 macOS 登录:以 Commilitia 账户(Auth Broker)登录为主,免相机——ASWebAuthenticationSession
// 弹系统授权窗,回调经 cdrop:// 回到 app(见 BrokerLogin / AuthManager.startBrokerLogin)。不复用
// iOS 的 LoginView(其二维码 / UIImage 路径为 iOS 专属,且没人扫 Mac 屏幕来登录 Mac 自己)。
struct MacLoginView: View
{
@Environment(AuthManager.self) private var auth
@State private var busy = false
var body: some View
{
VStack(spacing: 20)
{
Image(systemName: "paperplane.fill")
.font(.system(size: 52))
.foregroundStyle(.tint)
Text("Commilitia Drop")
.font(.largeTitle.weight(.semibold))
Text("跨设备直连传输与云剪贴板")
.font(.callout)
.foregroundStyle(.secondary)
Button
{
busy = true
Task
{
await auth.startBrokerLogin()
busy = false
}
}
label:
{
Text(busy ? "登录中…" : "使用 Commilitia 账户登录")
.frame(maxWidth: 260)
}
.controlSize(.large)
.buttonStyle(.borderedProminent)
.disabled(busy)
if !auth.statusText.isEmpty
{
Text(auth.statusText)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
#endif
+19 -4
View File
@@ -1,9 +1,11 @@
#if os(macOS)
import SwiftUI
// 菜单栏常驻内容(占位):后续接入连接状态 + 一键剪贴板 push/pull + Quick Send。
// 菜单栏常驻内容:账号 / 在线设备数 / 打开主窗口。后续接入一键剪贴板 push/pull + Quick Send。
struct MacMenuContent: View
{
@Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth
@Environment(\.openWindow) private var openWindow
var body: some View
@@ -12,9 +14,22 @@ struct MacMenuContent: View
{
Text("Commilitia Drop")
.font(.headline)
Text("桌面功能适配中")
.font(.caption)
.foregroundStyle(.secondary)
if let name = auth.session?.user.name
{
Text(name)
.font(.caption)
.foregroundStyle(.secondary)
Text("在线设备:\(engine.devices.filter(\.online).count)")
.font(.caption)
.foregroundStyle(.secondary)
}
else
{
Text("未登录")
.font(.caption)
.foregroundStyle(.secondary)
}
Divider()
Button("打开主窗口") { openWindow(id: "main") }
+108 -5
View File
@@ -2,10 +2,13 @@
import SwiftUI
// 桌面根界面:NavigationSplitView 侧栏(传输 / 文件 / 消息 / 设备 / 设置)+ 详情 NavigationStack,
// 替代 iOS 的标签栏。当前为脚手架:各区详情为占位(MacPlaceholder),随后续阶段接入复用视图
// 与桌面专属功能。分区标签暂硬编中文;接入 i18n(Shared/I18n)后改走 t()(与 iOS 单一真源一致)。
// 替代 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 selection: MacSection = .transfers
var body: some View
@@ -23,6 +26,7 @@ struct MacRootView: View
ForEach(MacSection.sidebar)
{ section in
Label(section.title, systemImage: section.icon)
.badge(badge(for: section))
.tag(section)
}
}
@@ -37,6 +41,14 @@ struct MacRootView: View
detail
}
}
// 离屏无头引擎 WebView:零尺寸不可见,仅保持存活(登录后随本视图出现即 boot)。同 iOS RootView。
.background
{
EngineWebView(controller: engine, auth: auth)
.frame(width: 0, height: 0)
.opacity(0)
.allowsHitTesting(false)
}
}
@ViewBuilder
@@ -51,9 +63,9 @@ struct MacRootView: View
case .messages:
MacPlaceholder(section: .messages)
case .devices:
MacPlaceholder(section: .devices)
MacDevicesView()
case .settings:
MacPlaceholder(section: .settings)
MacSettingsView()
}
}
@@ -69,6 +81,17 @@ struct MacRootView: View
.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 标签一致:传输 / 文件 / 消息 / 设备 / 设置。
@@ -105,7 +128,7 @@ enum MacSection: Hashable, Identifiable, CaseIterable
static var sidebar: [MacSection] { MacSection.allCases }
}
// 阶段占位详情:标明该区将接入的功能,随后续阶段替换为实际视图。
// 阶段占位详情(传输 / 文件 / 消息,随 Phase 3b 替换为复用 iOS 视图)。
struct MacPlaceholder: View
{
let section: MacSection
@@ -123,4 +146,84 @@ struct MacPlaceholder: View
.navigationTitle(section.title)
}
}
// 设备:引擎经桥推来的真实 presence(在线绿点 + 名称 + 类型),证明登录后引擎已联通信令 / SSE。
struct MacDevicesView: View
{
@Environment(EngineController.self) private var engine
var body: some View
{
Group
{
if engine.devices.isEmpty
{
ContentUnavailableView("暂无其他设备", systemImage: "laptopcomputer.and.iphone",
description: Text("同账户的其他在线设备将显示在此。"))
}
else
{
List(engine.devices)
{ dev in
HStack(spacing: 10)
{
Circle()
.fill(dev.online ? Color.green : Color.secondary)
.frame(width: 9, height: 9)
Text(dev.name)
Spacer()
Text(dev.type)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("设备")
}
}
// 设置:账号 + 本机设备名 + 登出 + 引擎日志(3a 联通与现场自检)。
struct MacSettingsView: View
{
@Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth
var body: some View
{
Form
{
Section("账号")
{
LabeledContent("用户", value: auth.session?.user.name ?? "—")
LabeledContent("本机设备", value: engine.deviceName)
Button("登出", role: .destructive)
{
engine.reset()
auth.logout()
}
}
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("设置")
}
}
#endif