iOS: Implement RFD Architecture (#68026)

* Move VerifyApp to App Lifecycle folder and add some architecture documentation to it

* Add VerifyAppModel.swift

* Tuck existing files into their own subfolders

* Add SwiftUINavigation as a dependency to support Destination-style navigation

(More on Destination-style navigation in a future commit)

* Add convenient logger initializers

* Move openDeepLink up to VerifyApp

* Move LandingView into the App Lifecycle folder

* Wire up parsing failures to new alert destination

* Swift-ify DeepLink parsing

* Rename Atempt -> LoadingState

* Update LandingView to navigate correctly to EnrollMobileDeviceView

* Rename EnrollMobileDeviceView -> EnrollDeviceView to make it more concise

* Improve names of LandingViewModel.Destination

* Add delegate relationship between LandingViewModel and EnrollDeviceViewModel to support the cancellation button

Since this view is now in a navigation stack, we might actually do away
with the cancel button entirely because you can just tap the back button
to do the same thing.

* Remove ContentView since it's no longer used

* Further break apart DeepLink.swift

* Remove stray reference to ContentView

* Add URL to debug log in VerifyAppModel
This commit is contained in:
Gustavo Medori
2026-06-26 20:54:34 +00:00
committed by GitHub
parent 570b1e48c1
commit 827a04ffcd
18 changed files with 517 additions and 283 deletions
+1 -1
View File
@@ -36,7 +36,7 @@
--func-attributes prev-line
--generic-types
--group-blank-lines true
--grouped-extension "MARK: - %t + %c"
--grouped-extension "MARK: - %c"
--guard-else auto
--header ignore
--hex-grouping 4,8
@@ -13,6 +13,7 @@
CD79992C2FE33F0600133048 /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = CD79992B2FE33EFF00133048 /* libresolv.9.tbd */; };
CD7999332FE345D400133048 /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = CD7999322FE345D400133048 /* Dependencies */; };
CD7999352FE346B800133048 /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = CD7999342FE346B800133048 /* DependenciesMacros */; };
CD7999D62FE9C9F300133048 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CD7999D52FE9C9F300133048 /* SwiftUINavigation */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -79,6 +80,7 @@
files = (
0C94C8CE2FA35F3D00C13C9A /* libresolv.9.tbd in Frameworks */,
CD7999232FE33AE000133048 /* Core.framework in Frameworks */,
CD7999D62FE9C9F300133048 /* SwiftUINavigation in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -157,6 +159,7 @@
);
name = Verify;
packageProductDependencies = (
CD7999D52FE9C9F300133048 /* SwiftUINavigation */,
);
productName = Verify;
productReference = 0C94C8522FA232E600C13C9A /* Verify.app */;
@@ -219,6 +222,7 @@
packageReferences = (
CDDEB8752FE1F5F10052BC27 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */,
CD7999312FE345D400133048 /* XCRemoteSwiftPackageReference "swift-dependencies" */,
CD7999D42FE9C9F300133048 /* XCRemoteSwiftPackageReference "swift-navigation" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 0C94C8532FA232E600C13C9A /* Products */;
@@ -703,6 +707,14 @@
minimumVersion = 1.14.0;
};
};
CD7999D42FE9C9F300133048 /* XCRemoteSwiftPackageReference "swift-navigation" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/pointfreeco/swift-navigation";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.8.0;
};
};
CDDEB8752FE1F5F10052BC27 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SimplyDanny/SwiftLintPlugins";
@@ -729,6 +741,11 @@
package = CD7999312FE345D400133048 /* XCRemoteSwiftPackageReference "swift-dependencies" */;
productName = DependenciesMacros;
};
CD7999D52FE9C9F300133048 /* SwiftUINavigation */ = {
isa = XCSwiftPackageProductDependency;
package = CD7999D42FE9C9F300133048 /* XCRemoteSwiftPackageReference "swift-navigation" */;
productName = SwiftUINavigation;
};
CDDEB8762FE1F5FC0052BC27 /* SwiftLintBuildToolPlugin */ = {
isa = XCSwiftPackageProductDependency;
package = CDDEB8752FE1F5F10052BC27 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */;
@@ -1,5 +1,5 @@
{
"originHash" : "607d9506e8d7561d7b28a75c3a23aa6ba2a4f751152a768b7e0ab6c8878b2e86",
"originHash" : "c838215d62b4997f60cd867f02d8cd46eb80a505e276b4e9370658ac42876f0f",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -10,6 +10,15 @@
"version" : "1.2.0"
}
},
{
"identity" : "swift-case-paths",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-case-paths",
"state" : {
"revision" : "1197e80bc7e4b177051b6869ef93d8ac3ad677da",
"version" : "1.8.0"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
@@ -19,6 +28,15 @@
"version" : "1.1.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
"version" : "1.6.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
@@ -28,6 +46,15 @@
"version" : "1.4.0"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e",
"version" : "1.6.1"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",
@@ -37,6 +64,24 @@
"version" : "1.14.0"
}
},
{
"identity" : "swift-navigation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-navigation",
"state" : {
"revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248",
"version" : "2.8.0"
}
},
{
"identity" : "swift-perception",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-perception",
"state" : {
"revision" : "25ac73741c3436605d61eceb5207e896973918e7",
"version" : "2.0.10"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
@@ -0,0 +1,54 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import SwiftUI
import SwiftUINavigation
struct LandingView: View {
@Bindable
var viewModel: LandingViewModel
var body: some View {
NavigationStack {
VStack(spacing: .zero) {
Image("logo")
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: 44, alignment: .leading)
Text("Open the Camera app to scan a QR code in the Web UI.")
.font(.title)
.fontWeight(.medium)
.multilineTextAlignment(.center)
.frame(maxHeight: .infinity)
}
.padding(.horizontal)
.navigationDestination(item: $viewModel.destination.deviceEnrollment, destination: { deviceEnrollmentViewModel in
EnrollDeviceView(viewModel: deviceEnrollmentViewModel)
})
.alert(item: $viewModel.destination.deepLinkParsingAlert, title: { errorMessage in
Text(errorMessage)
}, actions: { _ in
Button("OK") {}
})
}
}
}
#Preview("In ContentView") {
@Previewable @State
var viewModel = LandingViewModel()
LandingView(viewModel: viewModel)
}
@@ -0,0 +1,49 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see http://www.gnu.org/licenses/
import Observation
import SwiftNavigation
@Observable @MainActor
final class LandingViewModel {
@CasePathable
enum Destination {
case deviceEnrollment(EnrollDeviceViewModel)
case deepLinkParsingAlert(errorMessage: String)
}
var destination: Destination? = nil
}
// MARK: - Programmatic Navigation
extension LandingViewModel {
func navigateToDeviceEnrollment(with deepLink: EnrollMobileDeviceDeepLink) {
destination = .deviceEnrollment(EnrollDeviceViewModel(deepLink: deepLink, delegate: self))
}
func showParserError(errorMessage: String) {
destination = .deepLinkParsingAlert(errorMessage: errorMessage)
}
}
// MARK: - EnrollDeviceViewModel.Delegate
extension LandingViewModel: EnrollDeviceViewModel.Delegate {
func enrollDeviceViewModelDidCancelOperation(_ viewModel: EnrollDeviceViewModel) {
destination = nil
}
}
@@ -0,0 +1,77 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import SwiftUI
/// This type is the entrypoint of our app.
///
/// Roughly speaking, the core of our app can be broken into two main subtrees: a view tree and a view model tree. They
/// mostly mirror each other (though it's possible and reasonable to have views that don't have view models). In
/// general, the view model tree attempts to be the stateful source of truth for what the views should do, while the
/// views attempt to be as dumb as possible and simply display the stuff the view models tell them too. This ensures
/// that:
///
/// 1. We always have programmatic control over what the views are displaying.
/// 2. Much more of our code is testable (since the behavior all lives in view models and SwiftUI views can't be
/// tested).
///
/// This type, by serving as the entrypoint into our app, also serves as the origin of both the view and view model
/// trees. However, for the sake of symmetry, we have ``VerifyAppModel`` type that will serve as the actual place we
/// initialize our root-most view models. We also have it because it's useful to have a reference type that shares your
/// app's lifecycle, which we accomplish by marking the `appModel` property here with `@State`.
///
/// With this understanding, we can construct the following rough sketch of how the iOS app is structured.
///
/// - Important: The below diagram is meant for illustrative purposes only and doesn't reflect the constantly evolving
/// nature of the app. Types you see here are not meant to reflect the live structure of the app, but merely its shape.
///
/// ```
/// ┌─────────────┐ ┌──────────────────┐
/// │ VerifyApp │───────Owns────────▶│ VerifyAppModel │
/// └─────────────┘ └──────────────────┘
/// │ │ ▲
/// │ │ │
/// │ │
/// │ │ │
/// Owns Owns Delegate
/// │ │
/// │ │ │
/// │ │
/// ▼ ▼ │
/// ┌───────────────┐ ┌────────────────────┐
/// │ LandingView │───References────▶│ LandingViewModel │
/// └───────────────┘ └────────────────────┘
/// │ │ ▲
/// │ │
/// │ │ │
/// │ │
/// ▼ ▼ │
/// etc. etc.
/// ```
@main
struct VerifyApp: App {
@State
private var appModel = VerifyAppModel()
var body: some Scene {
WindowGroup {
LandingView(viewModel: appModel.landingViewModel)
.onOpenURL { url in
appModel.openDeepLink(url)
}
}
}
}
@@ -0,0 +1,49 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see http://www.gnu.org/licenses/
import Foundation
import Observation
import OSLog
/// The root of our app's view model tree.
@Observable @MainActor
final class VerifyAppModel {
static var logger = Logger.forType(VerifyAppModel.self)
// MARK: Child View Models
let landingViewModel = LandingViewModel()
}
// MARK: - Deep Link Handling
// We keep the deep link handing here at the root-most level of the app because from here we have a bird's eye view of
// the whole app and can manipulate any pieces we need.
extension VerifyAppModel {
func openDeepLink(_ url: URL) {
do {
switch try DeepLink(from: url) {
case let .enrollMobileDevice(deepLink):
Self.logger.debug("Correctly parsed deep link from \(url): \(String(describing: deepLink))")
landingViewModel.navigateToDeviceEnrollment(with: deepLink)
}
} catch {
Self.logger.warning("Failed to parse deep link \"\(url)\", error: \(String(describing: error))")
landingViewModel.showParserError(errorMessage: error.localizedDescription)
}
}
}
@@ -1,6 +1,33 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.788",
"green" : "0.184",
"red" : "0.318"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.522",
"red" : "0.624"
}
},
"idiom" : "universal"
}
],
-104
View File
@@ -1,104 +0,0 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import SwiftUI
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.gravitational.verify",
category: String(describing: ContentView.self),
)
enum AppScreen {
case landing
case enroll(EnrollMobileDeviceDeepURL)
}
struct ContentView: View {
@State
private var screen: AppScreen
@State
private var parseError: DeepLinkParseError? = nil
init(initialScreen: AppScreen = .landing) {
_screen = State(initialValue: initialScreen)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Image("logo")
.resizable()
.scaledToFit()
.frame(height: 32)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemBackground))
Group {
switch screen {
case .landing:
LandingView()
case let .enroll(deepURL):
EnrollMobileDeviceView(
deepURL: deepURL,
onCancel: { screen = .landing },
)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onOpenURL { url in
let result = parseDeepLink(url)
logger
.debug(
"Got URL: \(url, privacy: .public), parsed: \(String(describing: result), privacy: .public)",
)
switch result {
case let .success(.enrollMobileDevice(deepURL)):
parseError = nil
screen = .enroll(deepURL)
case let .failure(error):
parseError = error
}
}
.alert(
"Cannot open the link",
isPresented: Binding(
get: { parseError != nil },
set: { if !$0 { parseError = nil } },
),
presenting: parseError,
) { _ in
Button("Dismiss", role: .cancel) {}
} message: { error in
switch error {
case .unsupportedPath:
Text(
"This version of the app does not support the action represented by this link.",
)
case .urlComponentsFailed:
Text(
"The link appears to be malformed and could not be parsed.",
)
case let .missingPart(part):
Text("The \(part) part of the link is missing.")
}
}
}
}
#Preview {
ContentView()
}
@@ -0,0 +1,62 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import Foundation
enum DeepLink {
case enrollMobileDevice(EnrollMobileDeviceDeepLink)
init(from url: URL) throws {
switch url.path(percentEncoded: false) {
case "/enroll_mobile_device":
// Make sure to first switch on the path and only then attempt to parse out individual fields
// out of the URL. This way this function always returns the most important error first,
// which is the error about the unsupported path.
self = try .enrollMobileDevice(EnrollMobileDeviceDeepLink(from: url))
default:
throw DeepLinkParseError.unsupportedPath
}
}
}
enum DeepLinkParseError: LocalizedError {
case unsupportedPath
case urlComponentsFailed
case missingPart(String)
var errorDescription: String? {
switch self {
case .unsupportedPath:
NSLocalizedString(
"This version of the app does not support the action represented by this link.",
comment: "An error message that appears when a user tries to open a deep link with an unsupported path.",
)
case .urlComponentsFailed:
NSLocalizedString(
"The link appears to be malformed and could not be parsed.",
comment: "An error message that appears when a user tries to open a malformed deep link.",
)
case let .missingPart(part):
String(
format: NSLocalizedString(
"The %@ part of the link is missing.",
comment: "An error message that appears when a user tries to open a deep link with a missing required part.",
),
part,
)
}
}
}
@@ -0,0 +1,43 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see http://www.gnu.org/licenses/
import Foundation
struct EnrollMobileDeviceDeepLink {
var hostname: String
var port: Int? = nil
var enrollPairingToken: String
}
extension EnrollMobileDeviceDeepLink {
init(from url: URL) throws(DeepLinkParseError) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
throw DeepLinkParseError.urlComponentsFailed
}
guard let hostname = components.host, hostname != "" else {
throw DeepLinkParseError.missingPart("hostname")
}
guard let enrollPairingToken = components.nonEmptyQueryValue(named: "enroll_pairing_token") else {
throw DeepLinkParseError.missingPart("enroll pairing token")
}
self.init(
hostname: hostname,
port: components.port,
enrollPairingToken: enrollPairingToken,
)
}
}
-105
View File
@@ -1,105 +0,0 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import Core
import Foundation
import os
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.gravitational.verify",
category: "deep_link",
)
func parseDeepLink(_ url: URL) -> DeepLinkParseResult {
let path = url.path(percentEncoded: false)
switch path {
case "/enroll_mobile_device":
// Make sure to first switch on the path and only then attempt to parse out individual fields
// out of the URL. This way this function always returns the most important error first,
// which is the error about the unsupported path.
return parseDeepURL(url) { deepURL, parts in
guard
let enrollPairingToken = getQueryParam(
parts,
"enroll_pairing_token",
)
else {
return .failure(.missingPart("enroll pairing token"))
}
return .success(
.enrollMobileDevice(
EnrollMobileDeviceDeepURL(
url: deepURL,
enrollPairingToken: enrollPairingToken,
),
),
)
}
default:
logger.warning("Unsupported path: \(path, privacy: .public)")
return .failure(.unsupportedPath)
}
}
typealias DeepLinkParseResult = Result<ParsedDeepLink, DeepLinkParseError>
enum ParsedDeepLink {
case enrollMobileDevice(EnrollMobileDeviceDeepURL)
}
enum DeepLinkParseError: Error {
case unsupportedPath
case urlComponentsFailed
case missingPart(String)
}
struct DeepURL {
/// hostname is just the name of the host without the port.
var hostname: String
/// port of the host.
var port: Int? = nil
}
struct EnrollMobileDeviceDeepURL {
var url: DeepURL
/// user_token query params from the deep link.
var enrollPairingToken: String
}
/// parseDeepURL could return Result<(DeepURL, URLComponents), DeepLinkParseError> instead, but
/// there's no convienient way to unwrap it, that's why it accepts a trailing closure instead.
func parseDeepURL(
_ url: URL,
toParsedDeepLink: (_ deepURL: DeepURL, _ parts: URLComponents) ->
DeepLinkParseResult,
) -> DeepLinkParseResult {
guard let parts = URLComponents(string: url.absoluteString) else {
return .failure(.urlComponentsFailed)
}
guard let hostname = parts.host, hostname != "" else {
return .failure(.missingPart("hostname"))
}
let port = parts.port
return toParsedDeepLink(DeepURL(hostname: hostname, port: port), parts)
}
func getQueryParam(_ parts: URLComponents, _ param: String) -> String? {
parts.queryItems?
.first(
where: { $0.name == param && $0.value != nil && $0.value != "" },
)?.value
}
@@ -16,19 +16,8 @@
import SwiftUI
struct EnrollMobileDeviceView: View {
let deepURL: EnrollMobileDeviceDeepURL
let onCancel: () -> Void
@State
private var viewModel: EnrollMobileDeviceViewModel
init(deepURL: EnrollMobileDeviceDeepURL, onCancel: @escaping () -> Void) {
self.deepURL = deepURL
self.onCancel = onCancel
_viewModel = State(
initialValue: EnrollMobileDeviceViewModel(deepURL: deepURL),
)
}
struct EnrollDeviceView: View {
var viewModel: EnrollDeviceViewModel
var body: some View {
VStack(spacing: 16) {
@@ -83,7 +72,7 @@ struct EnrollMobileDeviceView: View {
.animation(.easeInOut, value: viewModel.attempt.isLoading)
.disabled(viewModel.attempt.isLoading)
Button(role: .cancel, action: onCancel) {
Button(role: .cancel, action: viewModel.userTappedCancel) {
Text("Cancel").frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
@@ -114,14 +103,3 @@ struct EnrollMobileDeviceView: View {
}
}
}
#Preview("In ContentView") {
ContentView(
initialScreen: .enroll(
EnrollMobileDeviceDeepURL(
url: DeepURL(hostname: "example.com", port: 3080),
enrollPairingToken: "abc123",
),
),
)
}
@@ -19,26 +19,21 @@ import Observation
@Observable
@MainActor
class EnrollMobileDeviceViewModel {
enum Attempt {
case idle
case loading
case success(token: String)
case failure(any Error)
var isLoading: Bool {
if case .loading = self { return true }
return false
}
}
var attempt: Attempt = .idle
private let deepURL: EnrollMobileDeviceDeepURL
class EnrollDeviceViewModel {
var attempt: LoadingState<String> = .idle
private let deepLink: EnrollMobileDeviceDeepLink
private let enrollClient: EnrollClient
init(deepURL: EnrollMobileDeviceDeepURL, enrollClient: EnrollClient = .liveValue) {
self.deepURL = deepURL
weak var delegate: (any Delegate)? = nil
init(
deepLink: EnrollMobileDeviceDeepLink,
enrollClient: EnrollClient = .liveValue,
delegate: (any Delegate)? = nil,
) {
self.deepLink = deepLink
self.enrollClient = enrollClient
self.delegate = delegate
}
func requestEnrollToken() async {
@@ -46,13 +41,29 @@ class EnrollMobileDeviceViewModel {
let defaultHTTPSPort = 443
do {
let token = try await enrollClient.requestEnrollmentToken(
hostName: deepURL.url.hostname,
port: deepURL.url.port ?? defaultHTTPSPort,
pairingToken: deepURL.enrollPairingToken,
hostName: deepLink.hostname,
port: deepLink.port ?? defaultHTTPSPort,
pairingToken: deepLink.enrollPairingToken,
)
attempt = .success(token: token)
attempt = .success(token)
} catch {
attempt = .failure(error)
}
}
}
// MARK: - EnrollDeviceViewModel.Delegate
extension EnrollDeviceViewModel {
protocol Delegate: AnyObject {
func enrollDeviceViewModelDidCancelOperation(_ viewModel: EnrollDeviceViewModel)
}
}
// MARK: - User Actions
extension EnrollDeviceViewModel {
func userTappedCancel() {
delegate?.enrollDeviceViewModelDidCancelOperation(self)
}
}
@@ -17,7 +17,7 @@
import SwiftUI
struct EnrollRequestStatusView: View {
let attempt: EnrollMobileDeviceViewModel.Attempt
let attempt: LoadingState<String>
let onDismiss: () -> Void
var body: some View {
@@ -39,7 +39,7 @@ struct EnrollRequestStatusView: View {
}
Spacer()
switch attempt {
case .success:
case .success, .reloading:
Button {
// Navigate to the cluster
} label: {
@@ -67,7 +67,7 @@ struct EnrollRequestStatusView: View {
private var iconName: String {
switch attempt {
case .idle, .loading: "clock"
case .idle, .loading, .reloading: "clock"
case .success: "checkmark.circle"
case .failure: "exclamationmark.triangle"
}
@@ -75,7 +75,7 @@ struct EnrollRequestStatusView: View {
private var title: String {
switch attempt {
case .idle, .loading: "Request Sent"
case .idle, .loading, .reloading: "Request Sent"
case .success: "Request Approved"
case .failure: "Request Failed"
}
@@ -83,7 +83,7 @@ struct EnrollRequestStatusView: View {
private var message: String {
switch attempt {
case .idle, .loading:
case .idle, .loading, .reloading:
"Your enrollment request has been sent. Approve it from the Web UI to continue."
case .success:
"This device is now trusted and can access protected resources."
@@ -99,7 +99,7 @@ struct EnrollRequestStatusView: View {
#Preview("Request Approved") {
EnrollRequestStatusView(
attempt: .success(token: "demo-placeholder-token"),
attempt: .success("demo-placeholder-token"),
onDismiss: {},
)
}
@@ -12,19 +12,19 @@
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// along with this program. If not, see http://www.gnu.org/licenses/
import SwiftUI
enum LoadingState<Value> {
case idle
case loading
case reloading(Value)
case success(Value)
case failure(any Error)
struct LandingView: View {
var body: some View {
Text("Open the Camera app\nto scan a QR code in the Web UI.")
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding()
var isLoading: Bool {
switch self {
case .loading, .reloading: true
case .idle, .success, .failure: false
}
}
}
#Preview("In ContentView") {
ContentView()
}
@@ -0,0 +1,32 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see http://www.gnu.org/licenses/
import OSLog
extension Logger {
/// The subsystem that almost all loggers in this target should use.
static let defaultSubsystem = "com.gravitational.Verify"
/// Creates a logger for a specific type.
static func forType<T>(_ type: T.Type) -> Logger {
Logger(subsystem: defaultSubsystem, category: String(describing: type))
}
/// Creates a logger for the file in which it is declared.
static func forFile(_ fileID: StaticString = #fileID) -> Logger {
Logger(subsystem: defaultSubsystem, category: "\(fileID)")
}
}
@@ -12,15 +12,14 @@
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// along with this program. If not, see http://www.gnu.org/licenses/
import SwiftUI
import Foundation
@main
struct VerifyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
extension URLComponents {
func nonEmptyQueryValue(named name: String) -> String? {
queryItems?
.first { $0.name == name && $0.value?.isEmpty == false }?
.value
}
}