mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
@@ -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" */;
|
||||
|
||||
+46
-1
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+3
-25
@@ -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",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+32
-21
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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
-12
@@ -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)")
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user