Use atproto actions with ease in iOS shortcuts
at main 8.2 kB view raw
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}