原生 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 AuthManagerBroker
// 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 AppRootCDropApp 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 BrokerASWebAuthenticationSession
// 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
// i18nShared/I18n t() iOS
// 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 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
+22
View File
@@ -1,22 +1,44 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
// web accent #644AC9 / #9580FF Shared使
// app / app RootView
extension Color
{
#if os(iOS)
static let cdropAccent = Color(uiColor: UIColor
{ trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.584, green: 0.502, blue: 1.0, alpha: 1)
: UIColor(red: 0.392, green: 0.290, blue: 0.788, alpha: 1)
})
#else
static let cdropAccent = Color(nsColor: NSColor(name: nil, dynamicProvider:
{ appearance in
appearance.bestMatch(from: [ .aqua, .darkAqua ]) == .darkAqua
? NSColor(red: 0.584, green: 0.502, blue: 1.0, alpha: 1)
: NSColor(red: 0.392, green: 0.290, blue: 0.788, alpha: 1)
}))
#endif
// Drop web #DB9410 / #F8C637
#if os(iOS)
static let cdropAmber = Color(uiColor: UIColor
{ trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.973, green: 0.776, blue: 0.216, alpha: 1)
: UIColor(red: 0.859, green: 0.580, blue: 0.063, alpha: 1)
})
#else
static let cdropAmber = Color(nsColor: NSColor(name: nil, dynamicProvider:
{ appearance in
appearance.bestMatch(from: [ .aqua, .darkAqua ]) == .darkAqua
? NSColor(red: 0.973, green: 0.776, blue: 0.216, alpha: 1)
: NSColor(red: 0.859, green: 0.580, blue: 0.063, alpha: 1)
}))
#endif
}
+2
View File
@@ -1,7 +1,9 @@
import AuthenticationServices
import Foundation
import Observation
#if canImport(UIKit)
import UIKit
#endif
// + iOS web /
// /api/auth/qr/status / internal/httpapi/
+8
View File
@@ -1,7 +1,11 @@
import AuthenticationServices
import CryptoKit
import Foundation
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
// Broker device-authorization + PKCE desktop/platform/oauth.goiOS
// loopback ASWebAuthenticationSession + scheme cdrop://auth-callback
@@ -53,10 +57,14 @@ final class BrokerAuthFlow: NSObject, ASWebAuthenticationPresentationContextProv
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor
{
#if os(iOS)
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
return scenes.flatMap { $0.windows }.first { $0.isKeyWindow }
?? scenes.first?.windows.first
?? ASPresentationAnchor()
#else
return NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first ?? ASPresentationAnchor()
#endif
}
}
@@ -1,3 +1,4 @@
#if os(iOS)
import BackgroundTasks
import UIKit
@@ -104,3 +105,15 @@ final class BackgroundTaskManager
}
}
}
#else
// macOS BackgroundTasksBGContinuedProcessingTask API_UNAVAILABLE(macos)
// no-op stub使 EngineController.syncBackgroundTask
// macOS
final class BackgroundTaskManager
{
static let shared = BackgroundTaskManager()
private init() {}
func registerOnce() {}
func sync(activeCount: Int, fraction: Double) {}
}
#endif
+6
View File
@@ -1,5 +1,7 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
// UserDefaults qr/start device_name
// PATCH /api/devices/{device_id}
@@ -14,7 +16,11 @@ enum DeviceNameStore
get
{
if let v = UserDefaults.standard.string(forKey: key), !v.isEmpty { return v }
#if os(iOS)
let sys = UIDevice.current.name
#else
let sys = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
#endif
return sys.isEmpty ? "iPhone" : sys
}
set
@@ -1,6 +1,10 @@
import Foundation
import Observation
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
import WebKit
// presence web store DeviceInfolastSeen ms epoch
@@ -275,12 +279,18 @@ final class EngineController: NSObject
let ucc = WKUserContentController()
ucc.add(self, name: "cdropEngine")
// boot nulldevice_type ios
// api_base /api prod URL
// boot nulldevice_type /
// + isWakeable api_base /api prod
// URL iOS "ios" Mac "macos" Wails /
let debug = ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1"
#if os(macOS)
let deviceType = "macos"
#else
let deviceType = "ios"
#endif
let boot = "window.__CDROP_BOOT__ = { session: \(sessionJSON()), "
+ "device_name: \(jsString(currentDeviceName())), "
+ "api_base: \(jsString(apiBaseForBoot())), device_type: \"ios\", debug: \(debug) };"
+ "api_base: \(jsString(apiBaseForBoot())), device_type: \(jsString(deviceType)), debug: \(debug) };"
ucc.addUserScript(WKUserScript(source: boot,
injectionTime: .atDocumentStart,
forMainFrameOnly: true))
@@ -539,7 +549,11 @@ final class EngineController: NSObject
// UIPasteboard
func uploadClipboard()
{
#if os(macOS)
let content = NSPasteboard.general.string(forType: .string) ?? ""
#else
let content = UIPasteboard.general.string ?? ""
#endif
guard !content.isEmpty else { notify(t("ios.clipboard.empty"), kind: .info); return }
sendCommand("clipboardUpload", payload: [ "content": content ])
}
@@ -982,7 +996,12 @@ extension EngineController: WKScriptMessageHandler
// UIPasteboard
if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
{
#if os(macOS)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(content, forType: .string)
#else
UIPasteboard.general.string = content
#endif
notify(t("ios.clipboard.pulled"), kind: .success)
}
else
+23 -1
View File
@@ -1,9 +1,18 @@
import SwiftUI
import WebKit
// iOS UIViewRepresentablemacOS NSViewRepresentable
// makeUIView/updateUIView makeNSView/updateNSView typealias
// struct
#if os(iOS)
private typealias PlatformViewRepresentable = UIViewRepresentable
#else
private typealias PlatformViewRepresentable = NSViewRepresentable
#endif
// EngineController WKWebView SwiftUI UI
// controller JS ios/PLAN.md arch A
struct EngineWebView: UIViewRepresentable
struct EngineWebView: PlatformViewRepresentable
{
let controller: EngineController
// WebView __CDROP_BOOT__
@@ -11,6 +20,7 @@ struct EngineWebView: UIViewRepresentable
// session:null missing injected session boot #11
let auth: AuthManager
#if os(iOS)
func makeUIView(context: Context) -> WKWebView
{
controller.auth = auth
@@ -21,4 +31,16 @@ struct EngineWebView: UIViewRepresentable
{
//
}
#else
func makeNSView(context: Context) -> WKWebView
{
controller.auth = auth
return controller.makeWebView()
}
func updateNSView(_ nsView: WKWebView, context: Context)
{
//
}
#endif
}
+11 -1
View File
@@ -1,4 +1,8 @@
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
import UserNotifications
// APNs AppDelegate UIKit EngineController
@@ -28,7 +32,13 @@ final class PushRegistry: NSObject
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
{ granted, _ in
guard granted else { return }
Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() }
Task { @MainActor in
#if os(iOS)
UIApplication.shared.registerForRemoteNotifications()
#else
NSApplication.shared.registerForRemoteNotifications()
#endif
}
}
}
+17
View File
@@ -24,9 +24,26 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>net.commilitia.Commilitia-Drop</string>
<key>CFBundleURLSchemes</key>
<array>
<string>cdrop</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>26.0</string>
<key>NSBonjourServices</key>
<array>
<string>_cdrop._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
</dict>
</plist>
+27
View File
@@ -180,6 +180,24 @@ targets:
deploymentTarget: "26.0"
sources:
- path: MacApp
- path: Shared
# 复用 iOS 引擎与鉴权(跨平台 in-placeUIKit 处 #if 守卫 / AppKit 分支)。仅纳入「引擎 + 登录」
# 最小集:Engine 全部 + Auth(除 iOS 专属 LoginViewMac 另写原生登录)+ 若干单例 + i18n。iOS UI
# 树(RootView / MessagesView / ScannerView / AppDelegate 等)暂不纳入,随 Phase 3b 复用。
- path: Sources/Engine
- path: Sources/Auth
excludes:
- LoginView.swift
- path: Sources/RecordsStore.swift
- path: Sources/PushRegistry.swift
- path: Sources/LocalNetworkPermission.swift
- path: Sources/DeviceNameStore.swift
- path: Sources/BackgroundTaskManager.swift
- path: Sources/Resources/i18n
dependencies:
# 原生数据面 libwebrtcLibWebRtcEngine)。macOS 切片缺子头,构建经 just mac-prep 补齐
# scripts/patch-macos-webrtc-headers.sh)。
- package: WebRTC
info:
path: Support/macOS/App-Info.plist
properties:
@@ -191,6 +209,15 @@ targets:
- zh-Hans
- zh-Hant
- en
# ASWebAuthenticationSession 的 OAuth 回调经 cdrop:// scheme 回到 app(同 iOS)。
CFBundleURLTypes:
- CFBundleURLName: net.commilitia.Commilitia-Drop
CFBundleURLSchemes:
- cdrop
# 本地网络:libwebrtc 收集 host 候选、同内网直连须本地网络权限(macOS 15+ 亦弹窗)。
NSLocalNetworkUsageDescription: "Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。"
NSBonjourServices:
- "_cdrop._tcp"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop