import SwiftUI import UIKit enum HomeDestination: Hashable { case settings case announcements case announcement(AnnouncementSelection) } 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 { NavigationStack { ConnectionHomeView(session: session) .navigationTitle("") .navigationBarTitleDisplayMode(.inline) // Keep inbox and detail in the same value-based stack. // Mixing an isPresented inbox with a value-based detail // can reinsert the inbox above the detail on updates. .navigationDestination(for: HomeDestination.self) { destination in switch destination { case .settings: ServerSettingsView(session: session) case .announcements: AnnouncementInboxView(session: session, inbox: session.announcements) case .announcement(let selection): AnnouncementDetailView(inbox: session.announcements, selection: selection) } } } .toolbarBackground(Color(.systemGroupedBackground), for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) .transition(.opacity) } } .animation(.easeInOut(duration: 0.2), value: session.sessionVisible) .preferredColorScheme(session.sessionVisible ? .dark : .light) .sheet(isPresented: Binding( get: { session.privacyNoticeVisible }, set: { if !$0 { session.dismissPrivacyNotice() } } )) { PrivacyConsentView(session: session) .presentationDetents([.large]) .presentationDragIndicator(.hidden) .interactiveDismissDisabled() } .alert("连接提示", isPresented: Binding( get: { session.connectionFailureMessage != nil }, set: { if !$0 { session.dismissConnectionFailure() } } )) { Button("确定", role: .cancel) { session.dismissConnectionFailure() } } message: { Text(session.connectionFailureMessage ?? "") } .onAppear { if !session.sessionVisible { AppOrientation.update(to: .portrait) } if scenePhase == .active { session.applicationDidBecomeActive() } } .onChange(of: scenePhase) { phase in if phase == .active { session.applicationDidBecomeActive() } else if phase == .background { session.applicationDidEnterBackground() } } } } 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 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) { remoteConnectionPanel .padding(.top, 24) recentConnectionsPanel .padding(.top, 20) .frame(maxHeight: .infinity) } .padding(.horizontal, 20) .padding(.bottom, 12) if session.isConnecting { connectionProgressOverlay } } .toolbar { if #available(iOS 26.0, *) { navigationToolbar.sharedBackgroundVisibility(.hidden) } else { navigationToolbar } } .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) } .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 } } .onDisappear { remoteIDFocused = false } } @ToolbarContentBuilder private var navigationToolbar: some ToolbarContent { ToolbarItem(placement: .navigationBarLeading) { if session.hasNetworkConsent { signalStatusBadge .fixedSize(horizontal: true, vertical: false) } else { Button { remoteIDFocused = false session.showPrivacyNotice() } label: { signalStatusBadge .fixedSize(horizontal: true, vertical: false) } .buttonStyle(.plain) .accessibilityHint("打开隐私政策") } } ToolbarItem(placement: .navigationBarTrailing) { NavigationLink(value: HomeDestination.announcements) { AnnouncementBadge(inbox: session.announcements) } .foregroundStyle(.primary) .disabled(session.isConnecting) } ToolbarItem(placement: .navigationBarTrailing) { NavigationLink(value: HomeDestination.settings) { 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) .disabled(session.isConnecting) .accessibilityLabel("设置") } } private var signalStatusBadge: some View { HStack(spacing: 6) { if session.hasNetworkConsent { Image(systemName: signalSymbol) .font(.caption.weight(.bold)) } Text(session.signalStatus) .font(.caption.weight(.semibold)) .lineLimit(1) } .foregroundStyle(signalTint) .padding(.horizontal, 11) .frame(height: 34) .background(signalTint.opacity(0.11), in: Capsule()) .overlay { Capsule() .stroke(signalTint.opacity(0.22), lineWidth: 1) } .contentShape(Capsule()) } 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) { guard session.hasNetworkConsent else { remoteIDFocused = false session.showPrivacyNotice() return } 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 private var platformColors: [Color] { switch connection.platform { case .windows: return [Color(red: 0.12, green: 0.38, blue: 0.70), Color(red: 0.20, green: 0.48, blue: 0.77)] case .linux: return [Color(red: 0.63, green: 0.31, blue: 0.08), Color(red: 0.73, green: 0.39, blue: 0.12)] case .macos: return [Color(red: 0.43, green: 0.29, blue: 0.68), Color(red: 0.55, green: 0.39, blue: 0.77)] case .ios: return [Color(red: 0.08, green: 0.43, blue: 0.40), Color(red: 0.12, green: 0.51, blue: 0.47)] case .unknown: return [Color(red: 0.35, green: 0.40, blue: 0.46), Color(red: 0.45, green: 0.49, blue: 0.55)] } } var body: some View { Button(action: connect) { VStack(alignment: .leading, spacing: 0) { ZStack { if let thumbnail { Image(uiImage: thumbnail) .resizable() .scaledToFill() } else { LinearGradient(colors: platformColors, startPoint: .topLeading, endPoint: .bottomTrailing) Text(connection.platform.displayName) .font(.title3.weight(.semibold)) .lineLimit(1) .minimumScaleFactor(0.8) .foregroundStyle(.white) .padding(8) .accessibilityLabel("被控端平台:\(connection.platform.displayName)") } } .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 @State private var serverConfigurationExpanded = false @State private var usesCustomServer: Bool @State private var customServerHost: String @State private var customServerPort: String @State private var serverConfigurationError: String? @FocusState private var focusedServerField: ServerField? private enum ServerField: Hashable { case host, port } init(session: RemoteSessionModel) { self.session = session let isCustom = !session.usesOfficialServer _usesCustomServer = State(initialValue: isCustom) _customServerHost = State(initialValue: isCustom ? session.signalHost : "") _customServerPort = State(initialValue: isCustom ? session.signalPort : "") } var body: some View { 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.videoAdaptationPolicy) { ForEach(VideoAdaptationPolicy.allCases) { policy in Text(policy.title).tag(policy) } } .pickerStyle(.segmented) Text(session.videoAdaptationPolicy.detail) .font(.footnote) .foregroundStyle(.secondary) } Section { DisclosureGroup(isExpanded: $serverConfigurationExpanded) { Toggle("使用自定义服务器", isOn: $usesCustomServer) if usesCustomServer { LabeledContent("地址") { TextField("填写服务器地址", text: $customServerHost) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) .multilineTextAlignment(.trailing) .focused($focusedServerField, equals: .host) .submitLabel(.next) .onSubmit { focusedServerField = .port } .accessibilityLabel("服务器地址") } LabeledContent("端口") { TextField("填写端口", text: $customServerPort) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) .focused($focusedServerField, equals: .port) .accessibilityLabel("服务器端口") } } if usesCustomServer { Text("结束编辑后生效。关闭开关可立即切回默认配置。") .font(.footnote) .foregroundStyle(.secondary) } } label: { HStack { Text("服务器") Spacer() Text(usesCustomServer ? "自定义" : "默认") .foregroundStyle(.secondary) } } } footer: { if let serverConfigurationError { Text(serverConfigurationError) .foregroundStyle(.red) } if let warning = session.identityStorageWarning { Text(warning) .foregroundStyle(.orange) } Text(!session.hasNetworkConsent ? "完成隐私授权后登记本机身份" : session.localIdentity.isEmpty ? "正在获取本机 ID…" : "本机 ID \(session.localIdentity)") .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 16) .textSelection(.enabled) } PrivacySettingsSection(session: session) Section { NavigationLink("关于") { ThirdPartyLicensesView() } } } .navigationTitle("设置") .navigationBarTitleDisplayMode(.inline) .scrollDismissesKeyboard(.interactively) .background { KeyboardDismissTapView { focusedServerField = nil } .allowsHitTesting(false) } .onChange(of: serverConfigurationExpanded) { expanded in if !expanded { focusedServerField = nil validateAndApplyServerConfiguration() } } .onChange(of: usesCustomServer) { isCustom in if !isCustom { useDefaultServer() } } .onChange(of: focusedServerField) { field in if field == nil { validateAndApplyServerConfiguration() } } .onDisappear { // Native back navigation can remove the view before focus updates. focusedServerField = nil validateAndApplyServerConfiguration() } .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer() Button("完成") { focusedServerField = nil } } } } private func useDefaultServer() { serverConfigurationError = nil focusedServerField = nil customServerHost = "" customServerPort = "" guard !session.usesOfficialServer else { return } session.restoreOfficialServerConfiguration() session.configureBridge() } private func validateAndApplyServerConfiguration() { guard usesCustomServer else { return } let host = customServerHost.trimmingCharacters(in: .whitespacesAndNewlines) let port = customServerPort.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty else { serverConfigurationError = "请输入服务器地址,或关闭“使用自定义服务器”。" return } guard let portNumber = Int(port), (1...65535).contains(portNumber) else { serverConfigurationError = "请输入 1–65535 范围内的端口,或关闭“使用自定义服务器”。" return } serverConfigurationError = nil customServerHost = host customServerPort = String(portNumber) if session.signalHost != host || session.signalPort != customServerPort { session.signalHost = host session.signalPort = customServerPort session.configureBridge() } } }