feat: 初始化 iOS 远程控制端项目

This commit is contained in:
2026-07-29 10:34:02 +08:00
parent 11173eb04c
commit 1e37aa64cc
20 changed files with 2610 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import SwiftUI
/// / / Android
struct AuthSheetView: View {
@Environment(\.dismiss) private var dismiss
let onConfirm: (AuthType, String) -> Void
@State private var authType: AuthType = .none
@State private var authValue: String = ""
var body: some View {
NavigationView {
Form {
Section("鉴权方式") {
Picker("鉴权方式", selection: $authType) {
ForEach(AuthType.allCases) { type in
Text(type.title).tag(type)
}
}
.pickerStyle(.segmented)
}
if authType != .none {
Section(authType == .code ? "验证码" : "密码") {
if authType == .code {
TextField("请输入被控端显示的 6 位验证码", text: $authValue)
.keyboardType(.numberPad)
} else {
SecureField("请输入被控端设置的连接密码", text: $authValue)
}
}
}
Section {
Button {
onConfirm(authType, authValue)
} label: {
HStack {
Spacer()
Text("开始连接")
.fontWeight(.semibold)
Spacer()
}
}
.disabled(authType != .none && authValue.isEmpty)
} footer: {
Text(footerTips)
}
}
.navigationTitle("连接鉴权")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
}
}
.navigationViewStyle(.stack)
}
private var footerTips: String {
switch authType {
case .none:
return "免密连接:需要被控端手动确认或开启免验证。"
case .code:
return "动态验证码:输入被控端界面上显示的验证码,验证通过后自动接受连接。"
case .password:
return "固定密码:输入被控端预设的连接密码,验证通过后自动接受连接。"
}
}
}

View File

@@ -0,0 +1,235 @@
import SwiftUI
///
struct ContentView: View {
@StateObject private var viewModel = ControllerViewModel()
var body: some View {
ZStack {
if viewModel.isControlling {
ControlPanelView(viewModel: viewModel)
} else {
SetupPanelView(viewModel: viewModel)
}
}
.sheet(isPresented: $viewModel.showAuthSheet) {
AuthSheetView { authType, authValue in
viewModel.connect(authType: authType, authValue: authValue)
}
}
.alert("提示", isPresented: Binding(
get: { viewModel.alertMessage != nil },
set: { if !$0 { viewModel.alertMessage = nil } })) {
Button("确定", role: .cancel) { viewModel.alertMessage = nil }
} message: {
Text(viewModel.alertMessage ?? "")
}
}
}
// MARK: -
private struct SetupPanelView: View {
@ObservedObject var viewModel: ControllerViewModel
var body: some View {
NavigationView {
Form {
Section("本机信息") {
HStack {
Text("本机设备 ID")
Spacer()
Text(viewModel.myDeviceId)
.font(.system(.body, design: .monospaced))
.foregroundColor(.secondary)
.textSelection(.enabled)
}
}
Section("连接设置") {
TextField("信令服务器地址 (wss://...)", text: $viewModel.serverUrl)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
TextField("目标设备 ID", text: $viewModel.targetDeviceId)
.autocapitalization(.allCharacters)
.disableAutocorrection(true)
}
Section {
Button {
viewModel.requestConnect()
} label: {
HStack {
Spacer()
if viewModel.isConnecting {
ProgressView()
.padding(.trailing, 8)
}
Text(viewModel.isConnecting ? "连接中..." : "连接")
.fontWeight(.semibold)
Spacer()
}
}
.disabled(viewModel.isConnecting)
if viewModel.isConnecting {
Button(role: .destructive) {
viewModel.disconnect()
} label: {
HStack {
Spacer()
Text("取消")
Spacer()
}
}
}
} footer: {
Text(viewModel.statusText)
}
}
.navigationTitle("WebRTC 控制端")
}
.navigationViewStyle(.stack)
}
}
// MARK: -
private struct ControlPanelView: View {
@ObservedObject var viewModel: ControllerViewModel
var body: some View {
VStack(spacing: 0) {
topBar
videoArea
statsView
navButtons
}
.background(Color.black.ignoresSafeArea())
}
private var topBar: some View {
HStack(spacing: 12) {
Text(viewModel.statusText)
.font(.footnote)
.foregroundColor(.green)
.lineLimit(1)
Spacer()
// WebRTC /
Toggle(isOn: Binding(
get: { viewModel.streamMode == .selfCodec },
set: { viewModel.toggleStreamMode($0) })) {
Text("自编码")
.font(.footnote)
.foregroundColor(.white)
}
.toggleStyle(.switch)
.fixedSize()
//
Menu {
ForEach(ResolutionOption.all) { option in
Button {
viewModel.selectResolution(option)
} label: {
if option == viewModel.selectedResolution {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
} label: {
Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical")
.font(.footnote)
}
Button(role: .destructive) {
viewModel.disconnect()
} label: {
Text("断开")
.font(.footnote)
.fontWeight(.semibold)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(white: 0.1))
}
///
private var videoArea: some View {
GeometryReader { _ in
ZStack {
Color.black
ZStack {
// WebRTC UIKit RTCMTLVideoView
RemoteVideoView(videoView: viewModel.remoteVideoView)
.opacity(viewModel.streamMode == .webrtc ? 1 : 0)
// H.264 UIKit AVSampleBufferDisplayLayer
SelfCodecDisplayViewRepresentable(view: viewModel.selfCodecView)
.opacity(viewModel.streamMode == .selfCodec ? 1 : 0)
// UIKit
TouchOverlay(
onMotionEvent: { action, x, y in
viewModel.sendMotionEvent(action: action, x: x, y: y)
},
onSwipe: { x1, y1, x2, y2, duration in
viewModel.sendSwipe(x1: x1, y1: y1, x2: x2, y2: y2, duration: duration)
})
}
.aspectRatio(viewModel.videoAspect, contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
private var statsView: some View {
HStack {
Text(viewModel.statsText)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(Color(white: 0.75))
.multilineTextAlignment(.leading)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Color(white: 0.08))
}
private var navButtons: some View {
HStack(spacing: 24) {
navButton(title: "返回", systemImage: "arrow.uturn.backward") {
viewModel.sendKey(ControllerViewModel.AndroidKey.back)
}
navButton(title: "主页", systemImage: "circle") {
viewModel.sendKey(ControllerViewModel.AndroidKey.home)
}
navButton(title: "多任务", systemImage: "square.on.square") {
viewModel.sendKey(ControllerViewModel.AndroidKey.appSwitch)
}
}
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(Color(white: 0.1))
}
private func navButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 2) {
Image(systemName: systemImage)
.font(.system(size: 18))
Text(title)
.font(.caption2)
}
.foregroundColor(.white)
.frame(width: 64)
}
}
}

View File

@@ -0,0 +1,98 @@
import UIKit
import SwiftUI
/// UIKit 0.0~1.0
/// Android RemoteTouchView
/// - MotionEventDOWN=0 / UP=1 / MOVE=2 / CANCEL=3""
/// - >= 200ms >= 2% SWIPE
final class RemoteTouchView: UIView {
/// Android MotionEvent
enum MotionAction {
static let down: Int32 = 0
static let up: Int32 = 1
static let move: Int32 = 2
static let cancel: Int32 = 3
}
var onMotionEvent: ((Int32, Double, Double) -> Void)?
var onSwipe: ((Double, Double, Double, Double, Int64) -> Void)?
private var startX: Double = 0
private var startY: Double = 0
private var touchStartTime: TimeInterval = 0
override init(frame: CGRect) {
super.init(frame: frame)
isMultipleTouchEnabled = false
backgroundColor = .clear
}
required init?(coder: NSCoder) {
super.init(coder: coder)
isMultipleTouchEnabled = false
backgroundColor = .clear
}
private func relativePoint(of touch: UITouch) -> (Double, Double) {
let p = touch.location(in: self)
let w = max(bounds.width, 1)
let h = max(bounds.height, 1)
let relX = min(max(Double(p.x / w), 0), 1)
let relY = min(max(Double(p.y / h), 0), 1)
return (relX, relY)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let (x, y) = relativePoint(of: touch)
startX = x
startY = y
touchStartTime = Date().timeIntervalSince1970
onMotionEvent?(MotionAction.down, x, y)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let (x, y) = relativePoint(of: touch)
onMotionEvent?(MotionAction.move, x, y)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let (x, y) = relativePoint(of: touch)
onMotionEvent?(MotionAction.up, x, y)
let duration = Int64((Date().timeIntervalSince1970 - touchStartTime) * 1000)
let dx = abs(x - startX)
let dy = abs(y - startY)
// SWIPE Android
if !(duration < 200 && dx < 0.02 && dy < 0.02) {
onSwipe?(startX, startY, x, y, duration)
}
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let (x, y) = relativePoint(of: touch)
onMotionEvent?(MotionAction.cancel, x, y)
}
}
/// SwiftUI UIKit SwiftUI
struct TouchOverlay: UIViewRepresentable {
let onMotionEvent: (Int32, Double, Double) -> Void
let onSwipe: (Double, Double, Double, Double, Int64) -> Void
func makeUIView(context: Context) -> RemoteTouchView {
let view = RemoteTouchView()
view.onMotionEvent = onMotionEvent
view.onSwipe = onSwipe
return view
}
func updateUIView(_ uiView: RemoteTouchView, context: Context) {
uiView.onMotionEvent = onMotionEvent
uiView.onSwipe = onSwipe
}
}

View File

@@ -0,0 +1,15 @@
import SwiftUI
import WebRTC
/// SwiftUI WebRTC Metal UIKit
/// ViewModel attach SwiftUI
struct RemoteVideoView: UIViewRepresentable {
let videoView: RTCMTLVideoView
func makeUIView(context: Context) -> RTCMTLVideoView {
videoView.videoContentMode = .scaleAspectFit
return videoView
}
func updateUIView(_ uiView: RTCMTLVideoView, context: Context) {}
}

View File

@@ -0,0 +1,39 @@
import UIKit
import SwiftUI
import AVFoundation
/// UIKit AVSampleBufferDisplayLayer backing layer
/// "" H.264 Android SurfaceView
final class SampleBufferDisplayView: UIView {
override class var layerClass: AnyClass {
AVSampleBufferDisplayLayer.self
}
var sampleBufferLayer: AVSampleBufferDisplayLayer {
layer as! AVSampleBufferDisplayLayer
}
override init(frame: CGRect) {
super.init(frame: frame)
sampleBufferLayer.videoGravity = .resizeAspect
backgroundColor = .black
}
required init?(coder: NSCoder) {
super.init(coder: coder)
sampleBufferLayer.videoGravity = .resizeAspect
backgroundColor = .black
}
}
/// SwiftUI
struct SelfCodecDisplayViewRepresentable: UIViewRepresentable {
let view: SampleBufferDisplayView
func makeUIView(context: Context) -> SampleBufferDisplayView {
view
}
func updateUIView(_ uiView: SampleBufferDisplayView, context: Context) {}
}