Use atproto actions with ease in iOS shortcuts
1//
2// Login.swift
3// shortcut
4//
5// Created by Bailey Townsend on 6/23/25.
6//
7
8import ATCommonWeb
9import ATIdentityTools
10import ATProtoKit
11import SwiftData
12import SwiftUI
13
14struct LoginView: View {
15 @EnvironmentObject var atProtocolManager: AtProtocolManager
16 @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
17
18 @State private var handle = ""
19 @State private var password = ""
20 @State private var didDocument: CommonDIDDocument?
21
22 @State private var showPasswordField = false
23 @State private var isLoading = false
24 @State private var loadingMessage = "Loading..."
25 @State private var errorMessage: String?
26 @State private var showAlert = false
27
28 var body: some View {
29 VStack(spacing: 30) {
30 // AT Protocol logo/text
31 Text("AT Toolbox")
32 .font(.largeTitle)
33 .fontWeight(.bold)
34 .foregroundColor(.blue)
35 .padding(.top, 50)
36
37 // Spacer()
38
39 VStack(spacing: 20) {
40 // Handle input field
41 VStack(alignment: .leading, spacing: 8) {
42 Text("Handle")
43 .font(.headline)
44 .foregroundColor(.primary)
45
46 TextField(
47 "@alice.bsky.social", text: $handle,
48 onEditingChanged: { _ in
49
50 if showPasswordField && !self.password.isEmpty {
51 self.showPasswordField = false
52 self.password = ""
53 }
54
55 }
56 )
57 .textFieldStyle(.roundedBorder)
58 .autocapitalization(.none)
59 .disableAutocorrection(true)
60 }
61
62 // Password field (only shown after submit is pressed)
63 if showPasswordField {
64 VStack(alignment: .leading, spacing: 8) {
65 Text("App Password")
66 .font(.headline)
67 .foregroundColor(.primary)
68
69 SecureField("Enter a App Password", text: $password)
70 .textFieldStyle(.roundedBorder)
71 .padding(.bottom, 12)
72 Link(
73 "What's an App Password?",
74 destination: URL(
75 string:
76 "https://bsky.app/profile/safety.bsky.app/post/3k7waehomo52m"
77 )!)
78 }
79 .transition(.opacity.combined(with: .move(edge: .top)))
80 }
81
82 // Submit button
83 Button(
84 action: {
85 Task {
86 if !showPasswordField {
87 await resolveHandle()
88 } else {
89 self.loadingMessage = "Logging in..."
90 self.isLoading = true
91 do {
92 let cleanedHandle = self.handle.replacingOccurrences(
93 of: "@", with: ""
94 ).trim()
95 try await self.atProtocolManager.handleLogin(
96 didDocument: didDocument, handle: cleanedHandle,
97 password: self.password)
98 self.presentationMode.wrappedValue.dismiss()
99 } catch let error as ATAPIError {
100 switch error {
101 case .unauthorized(
102 error: let responseError,
103 wwwAuthenticate: _):
104 if responseError.error == "AuthFactorTokenRequired" {
105 self.errorMessage =
106 "Please only use a App Password. This app does not support using your real password."
107 self.showAlert = true
108 break
109
110 }
111 self.errorMessage = responseError.message
112 self.showAlert = true
113 case .tooManyRequests(let error, retryAfter: _):
114 self.errorMessage = error.message
115 self.showAlert = true
116 default:
117 self.errorMessage =
118 "Login failed. Please check your credentials."
119 self.showAlert = true
120 }
121 } catch {
122 self.errorMessage =
123 "Login failed. Please check your credentials."
124 self.showAlert = true
125
126 }
127 self.isLoading = false
128 }
129 }
130 }
131 ) {
132 Text(showPasswordField ? "Login" : "Continue")
133 .font(.headline)
134 .foregroundColor(.white)
135 .frame(maxWidth: .infinity)
136 .padding()
137 .background(handle.isEmpty ? Color.gray : Color.blue)
138 .cornerRadius(10)
139 }
140 .disabled(handle.isEmpty)
141 }
142 .padding(.horizontal, 30)
143
144 Spacer()
145 }
146 .background(Color(.systemBackground))
147 .alert(isPresented: $showAlert) {
148 Alert(
149 title: Text("Error"), message: Text(errorMessage ?? "Unknown error"),
150 dismissButton: .default(Text("OK")))
151 }
152 .loadingOverlay(isShowing: isLoading, message: self.loadingMessage)
153 }
154
155 func resolveHandle() async {
156 self.loadingMessage = "Resolving handle..."
157 self.isLoading = true
158
159 let cleanedHandle = self.handle.replacingOccurrences(
160 of: "@", with: ""
161 ).trim()
162 let doesHandleExist = await self.atProtocolManager
163 .checkIfAccountExists(handle: cleanedHandle)
164 if doesHandleExist {
165 self.errorMessage = "\(handle) is already a saved handle."
166 self.isLoading = false
167 self.showAlert = true
168 return
169 }
170 if cleanedHandle.hasSuffix(".bsky.social") {
171 // No need to resolve handle if it's a bsky.social handle
172 showPasswordField = true
173 self.isLoading = false
174 return
175 }
176 do {
177
178 self.didDocument =
179 try await self.atProtocolManager.resolveDidDocument(
180 handle: cleanedHandle)
181
182 guard let _ = self.didDocument else {
183 self.errorMessage = "Could not resolve the DID document."
184 self.showAlert = true
185 return
186 }
187 // First tap - show password field
188 withAnimation(.easeInOut(duration: 0.3)) {
189 showPasswordField = true
190 }
191 } catch {
192 print(error)
193 self.errorMessage = "Could not resolve the handle."
194 self.showAlert = true
195 }
196 self.isLoading = false
197
198 }
199}
200
201#Preview {
202 let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
203 let container = try! ModelContainer(
204 for: UserSessionModel.self,
205 configurations: configuration
206 )
207 LoginView()
208 .environmentObject(AtProtocolManager(modelContext: container.mainContext))
209}