301 lines
12 KiB
Swift
301 lines
12 KiB
Swift
//
|
|
// Copyright © 2023 osy. All rights reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
private let kTimeoutSeconds: UInt64 = 15
|
|
|
|
struct UTMRemoteConnectView: View {
|
|
@ObservedObject var remoteClientState: UTMRemoteClient.State
|
|
@Environment(\.openURL) private var openURL
|
|
@EnvironmentObject private var data: UTMRemoteData
|
|
@State private var selectedServer: UTMRemoteClient.State.SavedServer?
|
|
@State private var isAutoConnect: Bool = false
|
|
|
|
private var remoteClient: UTMRemoteClient {
|
|
data.remoteClient
|
|
}
|
|
|
|
var body: some View {
|
|
VStack {
|
|
HStack {
|
|
ProgressView().progressViewStyle(.circular)
|
|
Spacer()
|
|
Text("Select a UTM Server")
|
|
.font(.headline)
|
|
Spacer()
|
|
Button {
|
|
openURL(URL(string: "https://docs.getutm.app/remote/")!)
|
|
} label: {
|
|
Label("Help", systemImage: "questionmark.circle")
|
|
.labelStyle(.iconOnly)
|
|
.font(.title2)
|
|
}
|
|
Button {
|
|
selectedServer = .init()
|
|
} label: {
|
|
Label("New Connection", systemImage: "plus")
|
|
.labelStyle(.iconOnly)
|
|
.font(.title2)
|
|
}
|
|
}.padding()
|
|
List {
|
|
if remoteClientState.savedServers.count > 0 {
|
|
Section(header: Text("Saved")) {
|
|
ForEach(remoteClientState.savedServers) { server in
|
|
Button {
|
|
isAutoConnect = true
|
|
selectedServer = server
|
|
} label: {
|
|
MacDeviceLabel(server.name.isEmpty ? server.hostname : server.name, device: .init(model: server.model))
|
|
}.disabled(!server.isAvailable)
|
|
.contextMenu {
|
|
Button {
|
|
isAutoConnect = false
|
|
selectedServer = server
|
|
} label: {
|
|
Label("Edit…", systemImage: "slider.horizontal.3")
|
|
}
|
|
DestructiveButton("Delete") {
|
|
remoteClientState.delete(server: server)
|
|
Task {
|
|
await remoteClient.refresh()
|
|
}
|
|
}
|
|
}
|
|
}.onDelete { indexSet in
|
|
remoteClientState.savedServers.remove(atOffsets: indexSet)
|
|
Task {
|
|
await remoteClient.refresh()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Section(header: Text("Discovered"), footer: helpText) {
|
|
ForEach(remoteClientState.foundServers) { server in
|
|
Button {
|
|
isAutoConnect = true
|
|
selectedServer = UTMRemoteClient.State.SavedServer(from: server)
|
|
} label: {
|
|
MacDeviceLabel(server.name, device: .init(model: server.model))
|
|
}
|
|
}
|
|
}
|
|
}.listStyle(.insetGrouped)
|
|
}.alert(item: $remoteClientState.alertMessage) { item in
|
|
Alert(title: Text(item.message))
|
|
}
|
|
.sheet(item: $selectedServer) { server in
|
|
ServerConnectView(remoteClientState: remoteClientState, server: server, isAutoConnect: $isAutoConnect)
|
|
}
|
|
.onAppear {
|
|
Task {
|
|
await remoteClient.startScanning()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
Task {
|
|
await remoteClient.stopScanning()
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var helpText: some View {
|
|
if remoteClientState.foundServers.isEmpty {
|
|
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.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ServerConnectView: View {
|
|
@ObservedObject var remoteClientState: UTMRemoteClient.State
|
|
@State var server: UTMRemoteClient.State.SavedServer
|
|
@Binding var isAutoConnect: Bool
|
|
|
|
@EnvironmentObject private var data: UTMRemoteData
|
|
@Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
|
|
|
|
@State private var connectionTask: Task<Void, Error>?
|
|
private var isConnecting: Bool {
|
|
connectionTask != nil
|
|
}
|
|
@State private var isPasswordRequired: Bool = false
|
|
@State private var isTrustButton: Bool = false
|
|
|
|
private var remoteClient: UTMRemoteClient {
|
|
data.remoteClient
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
Form {
|
|
Section {
|
|
if #available(iOS 15, *) {
|
|
TextField("", text: $server.name, prompt: Text("Name (optional)"))
|
|
} else {
|
|
DefaultTextField("", text: $server.name, prompt: "Name (optional)")
|
|
}
|
|
} header: {
|
|
Text("Name")
|
|
}
|
|
Section {
|
|
if server.endpoint != nil {
|
|
Text(server.hostname)
|
|
} else {
|
|
if #available(iOS 15, *) {
|
|
TextField("", text: $server.hostname, prompt: Text("Hostname or IP address"))
|
|
.keyboardType(.asciiCapable)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
TextField("", value: $server.port, format: .number.grouping(.never), prompt: Text("Port"))
|
|
.keyboardType(.decimalPad)
|
|
} else {
|
|
DefaultTextField("", text: $server.hostname, prompt: "Hostname or IP address")
|
|
.keyboardType(.asciiCapable)
|
|
.autocorrectionDisabled()
|
|
NumberTextField("", number: $server.port, prompt: "Port")
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Host")
|
|
}
|
|
let fingerprint = (server.fingerprint ^ remoteClient.fingerprint).hexString()
|
|
if !fingerprint.isEmpty {
|
|
Section {
|
|
if #available(iOS 16.4, *) {
|
|
Text(fingerprint).monospaced()
|
|
} else {
|
|
Text(fingerprint)
|
|
}
|
|
} header: {
|
|
Text("Fingerprint")
|
|
}
|
|
}
|
|
if isPasswordRequired {
|
|
Section {
|
|
if #available(iOS 15, *) {
|
|
FocusedPasswordView(password: $server.password.bound)
|
|
} else {
|
|
SecureField("Password", text: $server.password.bound)
|
|
}
|
|
Toggle("Save Password", isOn: $server.shouldSavePassword)
|
|
} header: {
|
|
Text("Password")
|
|
}
|
|
}
|
|
}.disabled(isConnecting)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Button {
|
|
presentationMode.wrappedValue.dismiss()
|
|
} label: {
|
|
Text("Close")
|
|
}.disabled(isConnecting)
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
HStack {
|
|
if isConnecting {
|
|
ProgressView().progressViewStyle(.circular)
|
|
Button {
|
|
connectionTask?.cancel()
|
|
} label: {
|
|
Text("Cancel")
|
|
}
|
|
} else {
|
|
Button {
|
|
connect()
|
|
} label: {
|
|
if isTrustButton {
|
|
Text("Trust")
|
|
} else {
|
|
Text("Connect")
|
|
}
|
|
}.disabled(server.hostname.isEmpty || !server.isAvailable)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
// if we have an existing password, assume it should be saved
|
|
if server.password?.isEmpty == false {
|
|
server.shouldSavePassword = true
|
|
}
|
|
if isAutoConnect {
|
|
connect()
|
|
}
|
|
}
|
|
.alert(item: $remoteClientState.alertMessage) { item in
|
|
Alert(title: Text(item.message))
|
|
}
|
|
}
|
|
|
|
private func connect() {
|
|
guard connectionTask == nil else {
|
|
return
|
|
}
|
|
connectionTask = Task {
|
|
let timeoutTask = Task {
|
|
try await Task.sleep(nanoseconds: kTimeoutSeconds * NSEC_PER_SEC)
|
|
connectionTask?.cancel()
|
|
remoteClientState.showErrorAlert(NSLocalizedString("Timed out trying to connect.", comment: "UTMRemoteConnectView"))
|
|
}
|
|
do {
|
|
try await remoteClient.connect(server)
|
|
} catch {
|
|
if case UTMRemoteClient.ConnectionError.passwordRequired = error {
|
|
withAnimation {
|
|
isPasswordRequired = true
|
|
isTrustButton = false
|
|
}
|
|
} else if case UTMRemoteClient.ConnectionError.fingerprintUntrusted(let fingerprint) = error, server.fingerprint.isEmpty {
|
|
withAnimation {
|
|
server.fingerprint = fingerprint
|
|
isTrustButton = true
|
|
}
|
|
remoteClientState.showErrorAlert(error.localizedDescription)
|
|
} else if error is CancellationError {
|
|
// ignore it
|
|
} else {
|
|
remoteClientState.showErrorAlert(error.localizedDescription)
|
|
}
|
|
}
|
|
timeoutTask.cancel()
|
|
connectionTask = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 15, *)
|
|
private struct FocusedPasswordView: View {
|
|
@Binding var password: String
|
|
|
|
@FocusState private var isFocused: Bool
|
|
|
|
var body: some View {
|
|
SecureField("Password", text: $password)
|
|
.focused($isFocused)
|
|
.onAppear {
|
|
isFocused = true
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
UTMRemoteConnectView(remoteClientState: .init())
|
|
}
|