import SwiftUI import UIKit struct ContentView: View { @Environment(\.scenePhase) private var scenePhase @ObservedObject var session: RemoteSessionModel var body: some View { Group { if session.sessionVisible { RemoteSessionView(session: session) .transition(.opacity) } else { ConnectionHomeView(session: session) .transition(.opacity) } } .animation(.easeInOut(duration: 0.2), value: session.sessionVisible) .preferredColorScheme(session.sessionVisible ? .dark : .light) .onAppear { if !session.sessionVisible { AppOrientation.update(to: .portrait) } if scenePhase == .active { session.refreshRecentConnectionPresenceAfterForeground() } } .onChange(of: scenePhase) { phase in if phase == .active { session.refreshRecentConnectionPresenceAfterForeground() } } } } private struct KeyboardDismissTapView: UIViewRepresentable { let onDismiss: () -> Void func makeCoordinator() -> Coordinator { Coordinator(onDismiss: onDismiss) } func makeUIView(context: Context) -> UIView { let view = UIView(frame: .zero) view.backgroundColor = .clear view.isUserInteractionEnabled = false installRecognizer(for: view, coordinator: context.coordinator) return view } func updateUIView(_ view: UIView, context: Context) { context.coordinator.onDismiss = onDismiss installRecognizer(for: view, coordinator: context.coordinator) } static func dismantleUIView(_ view: UIView, coordinator: Coordinator) { coordinator.uninstall() } private func installRecognizer(for view: UIView, coordinator: Coordinator) { DispatchQueue.main.async { [weak view, weak coordinator] in guard let window = view?.window else { return } coordinator?.install(in: window) } } final class Coordinator: NSObject, UIGestureRecognizerDelegate { var onDismiss: () -> Void private weak var window: UIWindow? private var recognizer: UITapGestureRecognizer? init(onDismiss: @escaping () -> Void) { self.onDismiss = onDismiss } func install(in window: UIWindow) { guard self.window !== window || recognizer == nil else { return } uninstall() let recognizer = UITapGestureRecognizer(target: self, action: #selector(didTapOutsideInput)) recognizer.cancelsTouchesInView = false recognizer.delegate = self window.addGestureRecognizer(recognizer) self.window = window self.recognizer = recognizer } func uninstall() { if let recognizer { window?.removeGestureRecognizer(recognizer) } recognizer = nil window = nil } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { var touchedView = touch.view while let view = touchedView { if view is UITextField || view is UITextView { return false } touchedView = view.superview } return true } @objc private func didTapOutsideInput() { onDismiss() window?.endEditing(true) } } } private struct ConnectionHomeView: View { @ObservedObject var session: RemoteSessionModel @FocusState private var remoteIDFocused: Bool @State private var passwordPromptVisible = false @State private var settingsVisible = false @State private var promptRemoteID = "" @State private var promptPassword = "" @State private var promptRememberPassword = false private let connectionAccent = Color(red: 0.18, green: 0.48, blue: 0.86) private let connectionAccentEnd = Color(red: 0.12, green: 0.38, blue: 0.78) private var trimmedRemoteID: String { session.remoteID.trimmingCharacters(in: .whitespacesAndNewlines) } private var groupedRemoteID: Binding { Binding( get: { formatRemoteID(session.remoteID) }, set: { value in session.remoteID = String(value.filter { $0.isNumber }.prefix(9)) } ) } private var signalIsConnected: Bool { session.signalStatus == "已连接服务器" } private var signalHasError: Bool { session.signalStatus.contains("失败") || session.signalStatus.contains("无效") || session.signalStatus.contains("关闭") || session.signalStatus.contains("未知") } private var signalTint: Color { if signalIsConnected { return .green } if signalHasError { return .red } return .orange } private var signalSymbol: String { if signalIsConnected { return "checkmark.circle.fill" } if signalHasError { return "exclamationmark.triangle.fill" } return "arrow.triangle.2.circlepath" } private var recentConnectionColumns: [GridItem] { [ GridItem(.flexible(), spacing: 12, alignment: .top), GridItem(.flexible(), spacing: 12, alignment: .top) ] } private var orderedRecentConnections: [RecentConnection] { session.recentConnections.enumerated() .sorted { lhs, rhs in let lhsOnline = session.isRecentConnectionOnline(lhs.element) let rhsOnline = session.isRecentConnectionOnline(rhs.element) if lhsOnline != rhsOnline { return lhsOnline } return lhs.offset < rhs.offset } .map(\.element) } var body: some View { ZStack { Color(.systemGroupedBackground).ignoresSafeArea() VStack(spacing: 0) { header remoteConnectionPanel .padding(.top, 24) recentConnectionsPanel .padding(.top, 20) .frame(maxHeight: .infinity) } .padding(.horizontal, 20) .padding(.bottom, 12) if session.isConnecting { connectionProgressOverlay } } .background { KeyboardDismissTapView { remoteIDFocused = false } .allowsHitTesting(false) } .sheet(isPresented: $passwordPromptVisible) { PasswordPromptView(remoteID: promptRemoteID, password: $promptPassword, rememberPassword: $promptRememberPassword) { passwordPromptVisible = false session.remoteID = promptRemoteID session.connect(password: promptPassword, rememberPassword: promptRememberPassword) } .presentationDetents([.height(340)]) .presentationDragIndicator(.visible) } .sheet(isPresented: $settingsVisible) { ServerSettingsView(session: session) .presentationDetents([.fraction(0.80)]) .presentationDragIndicator(.visible) } .alert("设备离线", isPresented: Binding( get: { session.deviceOfflineAlertVisible }, set: { visible in if !visible { session.dismissDeviceOfflineAlert() } } )) { Button("确定") { session.dismissDeviceOfflineAlert() } } .onChange(of: session.remoteID) { value in let formatted = String(value.filter { $0.isNumber }.prefix(9)) if formatted != value { session.remoteID = formatted } } } private var header: some View { HStack(spacing: 8) { Label { Text(session.signalStatus) .font(.caption.weight(.semibold)) .lineLimit(1) } icon: { Image(systemName: signalSymbol) .font(.caption.weight(.bold)) } .foregroundStyle(signalTint) .padding(.horizontal, 11) .frame(height: 34) .background(signalTint.opacity(0.11), in: Capsule()) .overlay { Capsule() .stroke(signalTint.opacity(0.22), lineWidth: 1) } Spacer() Button { settingsVisible = true } label: { Image(systemName: "gearshape") .font(.system(size: 18, weight: .semibold)) .frame(width: 40, height: 40) .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } .foregroundStyle(.primary) .accessibilityLabel("设置") } .padding(.top, 8) } private var remoteConnectionPanel: some View { VStack(alignment: .leading, spacing: 16) { HStack { Text("远程桌面") .font(.title3.weight(.bold)) Spacer() } HStack(spacing: 10) { TextField("对端 ID", text: groupedRemoteID) .keyboardType(.numberPad) .textContentType(.username) .textInputAutocapitalization(.never) .autocorrectionDisabled() .font(.system(.title3, design: .rounded).weight(.semibold)) .monospacedDigit() .focused($remoteIDFocused) .padding(.horizontal, 15) .frame(height: 54) .background(Color(.systemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(remoteIDFocused ? connectionAccent : Color(.separator).opacity(0.4), lineWidth: remoteIDFocused ? 2 : 1) } .shadow(color: remoteIDFocused ? connectionAccent.opacity(0.12) : .clear, radius: 8, y: 2) Button { presentPasswordPrompt(for: trimmedRemoteID) } label: { HStack(spacing: 6) { Text("连接") Image(systemName: "arrow.right") } .font(.system(size: 16, weight: .bold)) .frame(width: 96, height: 54) .foregroundStyle(.white) .background(LinearGradient(colors: [ connectionAccent, connectionAccentEnd ], startPoint: .topLeading, endPoint: .bottomTrailing)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .shadow(color: connectionAccent.opacity(0.24), radius: 8, y: 4) } .buttonStyle(.plain) .disabled(trimmedRemoteID.isEmpty || session.isConnecting) .opacity(trimmedRemoteID.isEmpty || session.isConnecting ? 0.45 : 1) .accessibilityLabel("连接") } } .padding(18) .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 20, style: .continuous) .stroke(Color(.separator).opacity(0.28), lineWidth: 1) } .shadow(color: .black.opacity(0.055), radius: 14, y: 5) } @ViewBuilder private var recentConnectionsPanel: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline) { Text("最近连接") .font(.title3.bold()) Spacer() if !session.recentConnections.isEmpty { Text("\(session.recentConnections.count) 台设备") .font(.caption) .foregroundStyle(.secondary) } } if session.recentConnections.isEmpty { VStack(spacing: 10) { Image(systemName: "clock.arrow.circlepath") .font(.system(size: 30, weight: .medium)) .foregroundStyle(.tertiary) Text("还没有连接记录") .font(.headline) Text("成功连接后,这里会显示远端桌面的缩略图。") .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView(.vertical) { LazyVGrid(columns: recentConnectionColumns, spacing: 12) { ForEach(orderedRecentConnections) { connection in RecentConnectionCard( connection: connection, thumbnail: session.thumbnailImage(for: connection), online: session.isRecentConnectionOnline(connection), connect: { connectRecent(connection) }, delete: { session.removeRecentConnection(connection) } ) } } .padding(.bottom, 2) } } } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 18, style: .continuous) .stroke(Color(.separator).opacity(0.35), lineWidth: 1) } } private var connectionProgressOverlay: some View { ZStack { Color.clear .ignoresSafeArea() .contentShape(Rectangle()) .onTapGesture { } VStack(spacing: 0) { VStack(spacing: 14) { ProgressView() .controlSize(.large) .tint(connectionAccent) VStack(spacing: 6) { Text("正在连接") .font(.title3.weight(.semibold)) .foregroundStyle(.primary) Text(session.connectionStatus) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .lineLimit(2) .frame(minHeight: 36) } } .padding(.horizontal, 26) .padding(.top, 24) .padding(.bottom, 20) Divider() Button(role: .cancel, action: session.disconnect) { Text("取消连接") .font(.subheadline.weight(.semibold)) .foregroundStyle(Color.red) .frame(maxWidth: .infinity) .frame(height: 46) } .buttonStyle(.plain) } .frame(width: 292) .background(Color.white, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 24, style: .continuous) .stroke(Color(.separator).opacity(0.24), lineWidth: 0.8) } .shadow(color: .black.opacity(0.22), radius: 28, y: 10) } .transition(.opacity.combined(with: .scale(scale: 0.96))) } private func presentPasswordPrompt(for identifier: String) { let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { remoteIDFocused = true return } remoteIDFocused = false promptRemoteID = trimmed promptPassword = session.savedPassword(for: trimmed) promptRememberPassword = session.remembersPassword(for: trimmed) passwordPromptVisible = true } private func formatRemoteID(_ value: String) -> String { let digits = Array(value.filter { $0.isNumber }.prefix(9)) return stride(from: 0, to: digits.count, by: 3) .map { start in String(digits[start.. Void let delete: () -> Void var body: some View { Button(action: connect) { VStack(alignment: .leading, spacing: 0) { ZStack { if let thumbnail { Image(uiImage: thumbnail) .resizable() .scaledToFill() } else { LinearGradient(colors: [ Color(red: 0.18, green: 0.48, blue: 0.86), Color(red: 0.38, green: 0.68, blue: 0.96) ], startPoint: .topLeading, endPoint: .bottomTrailing) Image(systemName: "display") .font(.system(size: 34, weight: .medium)) .foregroundStyle(.white.opacity(0.9)) } } .frame(maxWidth: .infinity) .aspectRatio(16 / 9, contentMode: .fit) .clipped() VStack(alignment: .leading, spacing: 3) { Text(connection.displayName) .font(.caption.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(1) HStack(spacing: 5) { Text("ID \(connection.remoteID)") .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) .lineLimit(1) Spacer(minLength: 4) if connection.remembersPassword { Image(systemName: "key.fill") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) .accessibilityLabel("已保存密码") } Text(online ? "在线" : "离线") .font(.caption2.weight(.semibold)) .foregroundStyle(online ? Color.green : Color.secondary) .accessibilityLabel(online ? "在线" : "离线") } } .padding(9) } .background(Color(.secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke(Color(.separator).opacity(0.35), lineWidth: 1) } } .buttonStyle(.plain) .contextMenu { Button(action: connect) { Label("连接", systemImage: "arrow.right.circle") } Button(role: .destructive, action: delete) { Label("删除记录", systemImage: "trash") } } } } private struct PasswordPromptView: View { let remoteID: String @Binding var password: String @Binding var rememberPassword: Bool let connect: () -> Void @Environment(\.dismiss) private var dismiss @FocusState private var passwordFocused: Bool @State private var passwordVisible = false var body: some View { VStack(alignment: .leading, spacing: 18) { HStack { VStack(alignment: .leading, spacing: 4) { Text("连接远程桌面") .font(.title2.bold()) Text("对端 ID \(remoteID)") .font(.subheadline.monospacedDigit()) .foregroundStyle(.secondary) } Spacer() Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.title2) .foregroundStyle(.secondary) } .accessibilityLabel("关闭") } HStack(spacing: 10) { Group { if passwordVisible { TextField("访问密码", text: $password) } else { SecureField("访问密码", text: $password) } } .keyboardType(.numberPad) .textContentType(.password) .focused($passwordFocused) Button { passwordVisible.toggle() } label: { Image(systemName: passwordVisible ? "eye.slash" : "eye") .foregroundStyle(.secondary) } .accessibilityLabel(passwordVisible ? "隐藏密码" : "显示密码") } .padding(.horizontal, 13) .frame(height: 50) .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(Color(.separator).opacity(0.5), lineWidth: 1) } Toggle(isOn: $rememberPassword) { VStack(alignment: .leading, spacing: 2) { Text("保存密码") .font(.subheadline.weight(.semibold)) Text("密码将安全保存在本机 Keychain 中") .font(.caption) .foregroundStyle(.secondary) } } Button(action: connect) { Label("连接", systemImage: "arrow.right") .font(.headline) .frame(maxWidth: .infinity) .frame(height: 48) } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 10)) } .padding(22) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { passwordFocused = true } } } } private struct ServerSettingsView: View { @ObservedObject var session: RemoteSessionModel @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Form { Section("鼠标控制") { Picker("控制模式", selection: $session.mouseControlMode) { ForEach(MouseControlMode.allCases) { mode in Text(mode.title).tag(mode) } } .pickerStyle(.segmented) Text(session.mouseControlMode.detail) .font(.footnote) .foregroundStyle(.secondary) } Section("视频编解码") { Picker("处理方式", selection: $session.videoCodecMode) { ForEach(VideoCodecMode.allCases) { mode in Text(mode.title).tag(mode) } } .pickerStyle(.segmented) Text(session.videoCodecMode.detail) .font(.footnote) .foregroundStyle(.secondary) Text("修改后从下一次连接开始生效。") .font(.caption) .foregroundStyle(.tertiary) } Section("服务器") { TextField("信令服务器", text: $session.signalHost) .textInputAutocapitalization(.never) .autocorrectionDisabled() TextField("信令端口", text: $session.signalPort) .keyboardType(.numberPad) TextField("STUN/TURN 端口", text: $session.turnPort) .keyboardType(.numberPad) } Section { Toggle("启用 SRTP", isOn: $session.enableSRTP) } header: { Text("传输") } footer: { Text(session.localIdentity.isEmpty ? "正在获取本机 ID…" : "本机 ID \(session.localIdentity)") .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 16) .textSelection(.enabled) } } .navigationTitle("设置") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("应用") { session.configureBridge() dismiss() } } ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } } } } }