2
0

UTMRemoteConnectView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. //
  2. // Copyright © 2023 osy. All rights reserved.
  3. //
  4. // Licensed under the Apache License, Version 2.0 (the "License");
  5. // you may not use this file except in compliance with the License.
  6. // You may obtain a copy of the License at
  7. //
  8. // http://www.apache.org/licenses/LICENSE-2.0
  9. //
  10. // Unless required by applicable law or agreed to in writing, software
  11. // distributed under the License is distributed on an "AS IS" BASIS,
  12. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. // See the License for the specific language governing permissions and
  14. // limitations under the License.
  15. //
  16. import SwiftUI
  17. private let kTimeoutSeconds: UInt64 = 60
  18. struct UTMRemoteConnectView: View {
  19. @ObservedObject var remoteClientState: UTMRemoteClient.State
  20. @Environment(\.openURL) private var openURL
  21. @Environment(\.scenePhase) private var scenePhase
  22. @EnvironmentObject private var data: UTMRemoteData
  23. @State private var selectedServer: UTMRemoteClient.State.SavedServer?
  24. @State private var isAutoConnect: Bool = false
  25. private var remoteClient: UTMRemoteClient {
  26. data.remoteClient
  27. }
  28. var body: some View {
  29. VStack {
  30. HStack {
  31. if remoteClientState.isScanning {
  32. ProgressView().progressViewStyle(.circular)
  33. }
  34. Spacer()
  35. Text("Select a UTM Server")
  36. .font(.headline)
  37. Spacer()
  38. Button {
  39. openURL(URL(string: "https://docs.getutm.app/remote/")!)
  40. } label: {
  41. Label("Help", systemImage: "questionmark.circle")
  42. .labelStyle(.iconOnly)
  43. .font(.title2)
  44. }
  45. Button {
  46. selectedServer = .init()
  47. } label: {
  48. Label("New Connection", systemImage: "plus")
  49. .labelStyle(.iconOnly)
  50. .font(.title2)
  51. }
  52. }.padding()
  53. List {
  54. if remoteClientState.savedServers.count > 0 {
  55. Section(header: Text("Saved")) {
  56. ForEach(remoteClientState.savedServers) { server in
  57. Button {
  58. isAutoConnect = true
  59. selectedServer = server
  60. } label: {
  61. MacDeviceLabel(server.name.isEmpty ? server.hostname : server.name, device: .init(model: server.model))
  62. }.disabled(!server.isAvailable)
  63. .contextMenu {
  64. Button {
  65. isAutoConnect = false
  66. selectedServer = server
  67. } label: {
  68. Label("Edit…", systemImage: "slider.horizontal.3")
  69. }
  70. DestructiveButton("Delete") {
  71. remoteClientState.delete(server: server)
  72. Task {
  73. await remoteClient.refresh()
  74. }
  75. }
  76. }
  77. }.onDelete { indexSet in
  78. remoteClientState.savedServers.remove(atOffsets: indexSet)
  79. Task {
  80. await remoteClient.refresh()
  81. }
  82. }
  83. }
  84. }
  85. Section(header: Text("Discovered"), footer: helpText) {
  86. ForEach(remoteClientState.foundServers) { server in
  87. Button {
  88. isAutoConnect = true
  89. selectedServer = UTMRemoteClient.State.SavedServer(from: server)
  90. } label: {
  91. MacDeviceLabel(server.name, device: .init(model: server.model))
  92. }
  93. }
  94. }
  95. }.listStyle(.insetGrouped)
  96. }.alert(item: $remoteClientState.alertMessage) { item in
  97. Alert(title: Text(item.message), primaryButton: .default(Text("Open Settings")) {
  98. UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
  99. }, secondaryButton: .cancel(Text("Retry")) {
  100. if !remoteClientState.isScanning {
  101. Task {
  102. await remoteClient.startScanning()
  103. }
  104. }
  105. })
  106. }
  107. .sheet(item: $selectedServer) { server in
  108. if #available(iOS 15, *) {
  109. ServerConnectView(remoteClientState: remoteClientState, server: server, isAutoConnect: $isAutoConnect)
  110. } else {
  111. ServerConnectView(remoteClientState: remoteClientState, server: server, isAutoConnect: $isAutoConnect)
  112. .environmentObject(data)
  113. }
  114. }
  115. .onAppear {
  116. Task {
  117. await remoteClient.startScanning()
  118. }
  119. }
  120. .onDisappear {
  121. Task {
  122. await remoteClient.stopScanning()
  123. }
  124. }
  125. .onChange(of: scenePhase) { newValue in
  126. if newValue == .active && !remoteClientState.isScanning {
  127. Task {
  128. await remoteClient.startScanning()
  129. }
  130. }
  131. }
  132. }
  133. @ViewBuilder
  134. private var helpText: some View {
  135. if remoteClientState.foundServers.isEmpty {
  136. Text("Make sure the latest version of UTM is running on your Mac and UTM Server is enabled. You can download UTM from the Mac App Store.")
  137. }
  138. }
  139. }
  140. private struct ServerConnectView: View {
  141. @ObservedObject var remoteClientState: UTMRemoteClient.State
  142. @State var server: UTMRemoteClient.State.SavedServer
  143. @Binding var isAutoConnect: Bool
  144. @EnvironmentObject private var data: UTMRemoteData
  145. @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
  146. @State private var connectionTask: Task<Void, Error>?
  147. private var isConnecting: Bool {
  148. connectionTask != nil
  149. }
  150. @State private var isPasswordRequired: Bool = false
  151. @State private var isTrustButton: Bool = false
  152. private var remoteClient: UTMRemoteClient {
  153. data.remoteClient
  154. }
  155. var body: some View {
  156. NavigationView {
  157. Form {
  158. Section {
  159. if #available(iOS 15, *) {
  160. TextField("", text: $server.name, prompt: Text("Name (optional)"))
  161. } else {
  162. DefaultTextField("", text: $server.name, prompt: "Name (optional)")
  163. }
  164. } header: {
  165. Text("Name")
  166. }
  167. Section {
  168. if server.endpoint != nil {
  169. Text(server.hostname)
  170. } else {
  171. if #available(iOS 15, *) {
  172. TextField("", text: $server.hostname, prompt: Text("Hostname or IP address"))
  173. .keyboardType(.asciiCapable)
  174. .autocorrectionDisabled()
  175. .textInputAutocapitalization(.never)
  176. TextField("", value: $server.port, format: .number.grouping(.never), prompt: Text("Port"))
  177. .keyboardType(.decimalPad)
  178. } else {
  179. DefaultTextField("", text: $server.hostname, prompt: "Hostname or IP address")
  180. .keyboardType(.asciiCapable)
  181. .autocorrectionDisabled()
  182. NumberTextField("", number: $server.port, prompt: "Port")
  183. }
  184. }
  185. } header: {
  186. Text("Host")
  187. }
  188. let fingerprint = (server.fingerprint ^ remoteClient.fingerprint).hexString()
  189. if !fingerprint.isEmpty {
  190. Section {
  191. if #available(iOS 16.4, *) {
  192. Text(fingerprint).monospaced()
  193. } else {
  194. Text(fingerprint)
  195. }
  196. } header: {
  197. Text("Fingerprint")
  198. }
  199. }
  200. if isPasswordRequired {
  201. Section {
  202. if #available(iOS 15, *) {
  203. FocusedPasswordView(password: $server.password.bound)
  204. } else {
  205. SecureField("Password", text: $server.password.bound)
  206. }
  207. Toggle("Save Password", isOn: $server.shouldSavePassword)
  208. } header: {
  209. Text("Password")
  210. }
  211. }
  212. }.disabled(isConnecting)
  213. .toolbar {
  214. ToolbarItem(placement: .topBarLeading) {
  215. Button {
  216. presentationMode.wrappedValue.dismiss()
  217. } label: {
  218. Text("Close")
  219. }.disabled(isConnecting)
  220. }
  221. ToolbarItem(placement: .topBarTrailing) {
  222. HStack {
  223. if isConnecting {
  224. ProgressView().progressViewStyle(.circular)
  225. Button {
  226. connectionTask?.cancel()
  227. } label: {
  228. Text("Cancel")
  229. }
  230. } else {
  231. Button {
  232. connect()
  233. } label: {
  234. if isTrustButton {
  235. Text("Trust")
  236. } else {
  237. Text("Connect")
  238. }
  239. }.disabled(server.hostname.isEmpty || !server.isAvailable)
  240. }
  241. }
  242. }
  243. }
  244. }
  245. .onAppear {
  246. // if we have an existing password, assume it should be saved
  247. if server.password?.isEmpty == false {
  248. server.shouldSavePassword = true
  249. }
  250. if isAutoConnect {
  251. connect()
  252. }
  253. }
  254. .alert(item: $remoteClientState.alertMessage) { item in
  255. Alert(title: Text(item.message))
  256. }
  257. }
  258. private func connect() {
  259. guard connectionTask == nil else {
  260. return
  261. }
  262. connectionTask = Task {
  263. let timeoutTask = Task {
  264. try await Task.sleep(nanoseconds: kTimeoutSeconds * NSEC_PER_SEC)
  265. connectionTask?.cancel()
  266. remoteClientState.showErrorAlert(NSLocalizedString("Timed out trying to connect.", comment: "UTMRemoteConnectView"))
  267. }
  268. if #available(iOS 15, *) {
  269. await _connect()
  270. } else {
  271. Task(priority: .userInteractive) {
  272. await _connect()
  273. }
  274. }
  275. timeoutTask.cancel()
  276. connectionTask = nil
  277. }
  278. }
  279. private func _connect() async {
  280. do {
  281. try await remoteClient.connect(server)
  282. } catch {
  283. if case UTMRemoteClient.ConnectionError.passwordRequired = error {
  284. withAnimation {
  285. isPasswordRequired = true
  286. isTrustButton = false
  287. }
  288. } else if case UTMRemoteClient.ConnectionError.fingerprintUntrusted(let fingerprint) = error, server.fingerprint.isEmpty {
  289. withAnimation {
  290. server.fingerprint = fingerprint
  291. isTrustButton = true
  292. }
  293. remoteClientState.showErrorAlert(error.localizedDescription)
  294. } else if error is CancellationError {
  295. // ignore it
  296. } else {
  297. remoteClientState.showErrorAlert(error.localizedDescription)
  298. }
  299. }
  300. }
  301. }
  302. @available(iOS 15, *)
  303. private struct FocusedPasswordView: View {
  304. @Binding var password: String
  305. @FocusState private var isFocused: Bool
  306. var body: some View {
  307. SecureField("Password", text: $password)
  308. .focused($isFocused)
  309. .onAppear {
  310. isFocused = true
  311. }
  312. }
  313. }
  314. #Preview {
  315. UTMRemoteConnectView(remoteClientState: .init())
  316. }