damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

LoginView.swift (15137B)


      1 //
      2 //  LoginView.swift
      3 //  damus
      4 //
      5 //  Created by William Casarin on 2022-05-22.
      6 //
      7 
      8 import CodeScanner
      9 import SwiftUI
     10 
     11 enum ParsedKey {
     12     case pub(Pubkey)
     13     case priv(Privkey)
     14     case hex(String)
     15     case nip05(String)
     16 
     17     var is_pub: Bool {
     18         if case .pub = self {
     19             return true
     20         }
     21 
     22         if case .nip05 = self {
     23             return true
     24         }
     25         return false
     26     }
     27 
     28     var is_hex: Bool {
     29         if case .hex = self {
     30             return true
     31         }
     32         return false
     33     }
     34 
     35     var is_priv: Bool {
     36         if case .priv = self {
     37             return true
     38         }
     39         return false
     40     }
     41 }
     42 
     43 struct LoginView: View {
     44     @State var key: String = ""
     45     @State var is_pubkey: Bool = false
     46     @State var error: String? = nil
     47     @State private var credential_handler = CredentialHandler()
     48     @State private var shouldSaveKey: Bool = true
     49     var nav: NavigationCoordinator
     50 
     51     func get_error(parsed_key: ParsedKey?) -> String? {
     52         if self.error != nil {
     53             return self.error
     54         }
     55 
     56         if !key.isEmpty && parsed_key == nil {
     57             return LoginError.invalid_key.errorDescription
     58         }
     59 
     60         return nil
     61     }
     62     
     63     var body: some View {
     64         ZStack(alignment: .top) {
     65             VStack {
     66                 Spacer()
     67                 
     68                 SignInHeader()
     69                 
     70                 SignInEntry(key: $key, shouldSaveKey: $shouldSaveKey)
     71                 
     72                 let parsed = parse_key(key)
     73                 
     74                 if parsed?.is_hex ?? false {
     75                     // convert to bech32 here
     76                 }
     77 
     78                 if let error = get_error(parsed_key: parsed) {
     79                     Text(error)
     80                         .foregroundColor(.red)
     81                         .padding()
     82                 }
     83 
     84                 if parsed?.is_pub ?? false {
     85                     Text("This is a public key, you will not be able to make notes or interact in any way. This is used for viewing accounts from their perspective.", comment: "Warning that the inputted account key is a public key and the result of what happens because of it.")
     86                         .foregroundColor(Color.orange)
     87                         .bold()
     88                         .fixedSize(horizontal: false, vertical: true)
     89                 }
     90 
     91                 if let p = parsed {
     92                     Button(action: {
     93                         Task {
     94                             do {
     95                                 try await process_login(p, is_pubkey: is_pubkey, shouldSaveKey: shouldSaveKey)
     96                             } catch {
     97                                 self.error = error.localizedDescription
     98                             }
     99                         }
    100                     }) {
    101                         HStack {
    102                             Text("Login", comment:  "Button to log into account.")
    103                                 .fontWeight(.semibold)
    104                         }
    105                         .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
    106                     }
    107                     .accessibilityIdentifier(AppAccessibilityIdentifiers.sign_in_confirm_button.rawValue)
    108                     .buttonStyle(GradientButtonStyle())
    109                     .padding(.top, 10)
    110                 }
    111 
    112                 CreateAccountPrompt(nav: nav)
    113                     .padding(.top, 10)
    114 
    115                 Spacer()
    116             }
    117             .padding()
    118             .padding(.bottom, 50)
    119         }
    120         .background(DamusBackground(maxHeight: UIScreen.main.bounds.size.height/2), alignment: .top)
    121         .onAppear {
    122             credential_handler.check_credentials()
    123         }
    124         .navigationBarBackButtonHidden(true)
    125         .navigationBarItems(leading: BackNav())
    126     }
    127 }
    128 
    129 extension View {
    130     func nsecLoginStyle(key: String, title: String) -> some View {
    131         self
    132             .placeholder(when: key.isEmpty) {
    133                 Text(title).foregroundColor(.white.opacity(0.6))
    134             }
    135             .padding(10)
    136             .autocapitalization(.none)
    137             .autocorrectionDisabled(true)
    138             .textInputAutocapitalization(.never)
    139             .font(.body.monospaced())
    140             .textContentType(.password)
    141     }
    142 }
    143 
    144 func parse_key(_ thekey: String) -> ParsedKey? {
    145     var key = thekey
    146     if key.count > 0 && key.first! == "@" {
    147         key = String(key.dropFirst())
    148     }
    149 
    150     if hex_decode(key) != nil {
    151         return .hex(key)
    152     }
    153 
    154     if (key.contains { $0 == "@" }) {
    155         return .nip05(key)
    156     }
    157 
    158     if let bech_key = decode_bech32_key(key) {
    159         switch bech_key {
    160         case .pub(let pk):  return .pub(pk)
    161         case .sec(let sec): return .priv(sec)
    162         }
    163     }
    164 
    165     return nil
    166 }
    167 
    168 enum LoginError: LocalizedError {
    169     case invalid_key
    170     case nip05_failed
    171     
    172     var errorDescription: String? {
    173         switch self {
    174         case .invalid_key:
    175             return NSLocalizedString("Invalid key", comment: "Error message indicating that an invalid account key was entered for login.")
    176         case .nip05_failed:
    177             return "Could not fetch pubkey"
    178         }
    179     }
    180 }
    181 
    182 func process_login(_ key: ParsedKey, is_pubkey: Bool, shouldSaveKey: Bool = true) async throws {
    183     if shouldSaveKey {
    184         switch key {
    185         case .priv(let priv):
    186             try handle_privkey(priv)
    187         case .pub(let pub):
    188             try clear_saved_privkey()
    189             save_pubkey(pubkey: pub)
    190 
    191         case .nip05(let id):
    192             guard let nip05 = await get_nip05_pubkey(id: id) else {
    193                 throw LoginError.nip05_failed
    194             }
    195 
    196             // this is a weird way to login anyways
    197             /*
    198              var bootstrap_relays = load_bootstrap_relays(pubkey: nip05.pubkey)
    199              for relay in nip05.relays {
    200              if !(bootstrap_relays.contains { $0 == relay }) {
    201              bootstrap_relays.append(relay)
    202              }
    203              }
    204              */
    205             save_pubkey(pubkey: nip05.pubkey)
    206 
    207         case .hex(let hexstr):
    208             if is_pubkey, let pubkey = hex_decode_pubkey(hexstr) {
    209                 try clear_saved_privkey()
    210 
    211                 save_pubkey(pubkey: pubkey)
    212             } else if let privkey = hex_decode_privkey(hexstr) {
    213                 try handle_privkey(privkey)
    214             }
    215         }
    216     }
    217     
    218     func handle_privkey(_ privkey: Privkey) throws {
    219         try save_privkey(privkey: privkey)
    220         
    221         guard let pk = privkey_to_pubkey(privkey: privkey) else {
    222             throw LoginError.invalid_key
    223         }
    224 
    225         CredentialHandler().save_credential(pubkey: pk, privkey: privkey)
    226         save_pubkey(pubkey: pk)
    227     }
    228 
    229     func handle_transient_privkey(_ key: ParsedKey) -> Keypair? {
    230         if case let .priv(priv) = key, let pubkey = privkey_to_pubkey(privkey: priv) {
    231             return Keypair(pubkey: pubkey, privkey: priv)
    232         }
    233         return nil
    234     }
    235 
    236     let keypair = shouldSaveKey ? get_saved_keypair() : handle_transient_privkey(key)
    237 
    238     guard let keypair = keypair else {
    239         return
    240     }
    241 
    242     await MainActor.run {
    243         notify(.login(keypair))
    244     }
    245 }
    246 
    247 struct NIP05Result: Decodable {
    248     let names: Dictionary<String, String>
    249     let relays: Dictionary<String, [String]>?
    250 }
    251 
    252 struct NIP05User {
    253     let pubkey: Pubkey
    254     //let relays: [String]
    255 }
    256 
    257 func get_nip05_pubkey(id: String) async -> NIP05User? {
    258     let parts = id.components(separatedBy: "@")
    259 
    260     guard parts.count == 2 else {
    261         return nil
    262     }
    263 
    264     let user = parts[0]
    265     let host = parts[1]
    266 
    267     guard let url = URL(string: "https://\(host)/.well-known/nostr.json?name=\(user)"),
    268           let (data, _) = try? await URLSession.shared.data(for: URLRequest(url: url)),
    269           let json: NIP05Result = decode_data(data),
    270           let pubkey_hex = json.names[user],
    271           let pubkey = hex_decode_pubkey(pubkey_hex)
    272     else {
    273         return nil
    274     }
    275 
    276     /*
    277     var relays: [String] = []
    278 
    279     if let rs = json.relays, let rs = rs[pubkey] {
    280         relays = rs
    281     }
    282      */
    283 
    284     return NIP05User(pubkey: pubkey/*, relays: relays*/)
    285 }
    286 
    287 struct KeyInput: View {
    288     let title: String
    289     let key: Binding<String>
    290     let shouldSaveKey: Binding<Bool>
    291     var privKeyFound: Binding<Bool>
    292     @State private var is_secured: Bool = true
    293 
    294     init(_ title: String, key: Binding<String>, shouldSaveKey: Binding<Bool>, privKeyFound: Binding<Bool>) {
    295         self.title = title
    296         self.key = key
    297         self.shouldSaveKey = shouldSaveKey
    298         self.privKeyFound = privKeyFound
    299     }
    300 
    301     var body: some View {
    302         HStack {
    303             Button(action: {
    304                 if let pastedkey = UIPasteboard.general.string {
    305                     self.key.wrappedValue = pastedkey
    306                 }
    307             }, label: {
    308                 Image(systemName: "doc.on.clipboard")
    309             })
    310             .foregroundColor(.gray)
    311             .accessibilityLabel(NSLocalizedString("Paste private key", comment: "Accessibility label for the private key paste button"))
    312             
    313             SignInScan(shouldSaveKey: shouldSaveKey, loginKey: key, privKeyFound: privKeyFound)
    314 
    315             if is_secured  {
    316                  SecureField("", text: key)
    317                      .nsecLoginStyle(key: key.wrappedValue, title: title)
    318                      .accessibilityLabel(NSLocalizedString("Account private key", comment: "Accessibility label for the private key input field"))
    319              } else {
    320                  TextField("", text: key)
    321                      .nsecLoginStyle(key: key.wrappedValue, title: title)
    322                      .accessibilityLabel(NSLocalizedString("Account private key", comment: "Accessibility label for the private key input field"))
    323              }
    324             
    325             Button(action: {
    326                 is_secured.toggle()
    327             }, label: {
    328                 Image(systemName: "eye.slash")
    329             })
    330             .foregroundColor(.gray)
    331             .accessibilityLabel(NSLocalizedString("Toggle key visibility", comment: "Accessibility label for toggling the visibility of the private key input field"))
    332         }
    333         .padding(.vertical, 2)
    334         .padding(.horizontal, 10)
    335         .background {
    336             RoundedRectangle(cornerRadius: 12)
    337                 .stroke(.gray, lineWidth: 1)
    338                 .background {
    339                     RoundedRectangle(cornerRadius: 12)
    340                         .foregroundColor(.damusAdaptableWhite)
    341                 }
    342         }
    343     }
    344 }
    345 
    346 struct SignInHeader: View {
    347     var body: some View {
    348         VStack {
    349             Image("logo-nobg")
    350                 .resizable()
    351                 .frame(width: 56, height: 56, alignment: .center)
    352                 .shadow(color: DamusColors.purple, radius: 2)
    353                 .padding(.bottom)
    354                 .accessibilityLabel(NSLocalizedString("Damus logo", comment: "Accessibility label for damus logo"))
    355             
    356             Text("Sign in", comment: "Title of view to log into an account.")
    357                 .foregroundColor(DamusColors.neutral6)
    358                 .font(.system(size: 32, weight: .bold))
    359                 .padding(.bottom, 5)
    360             
    361             Text("Welcome to the social network you control", comment: "Welcome text")
    362                 .foregroundColor(DamusColors.neutral6)
    363         }
    364     }
    365 }
    366 
    367 struct SignInEntry: View {
    368     let key: Binding<String>
    369     let shouldSaveKey: Binding<Bool>
    370     @State private var privKeyFound: Bool = false
    371     var body: some View {
    372         VStack(alignment: .leading) {
    373             Text("Enter your account key", comment: "Prompt for user to enter an account key to login.")
    374                 .foregroundColor(DamusColors.neutral6)
    375                 .fontWeight(.medium)
    376                 .padding(.top, 30)
    377             
    378             KeyInput(NSLocalizedString("nsec1…", comment: "Prompt for user to enter in an account key to login. This text shows the characters the key could start with if it was a private key."),
    379                      key: key,
    380                      shouldSaveKey: shouldSaveKey,
    381                      privKeyFound: $privKeyFound)
    382             .accessibilityIdentifier(AppAccessibilityIdentifiers.sign_in_nsec_key_entry_field.rawValue)
    383             
    384             if privKeyFound {
    385                 Toggle(NSLocalizedString("Save Key in Secure Keychain", comment: "Toggle to save private key to the Apple secure keychain."), isOn: shouldSaveKey)
    386             }
    387         }
    388     }
    389 }
    390 
    391 struct SignInScan: View {
    392     @State var showQR: Bool = false
    393     @State var qrkey: ParsedKey?
    394     @Binding var shouldSaveKey: Bool
    395     @Binding var loginKey: String
    396     @Binding var privKeyFound: Bool
    397     let generator = UINotificationFeedbackGenerator()
    398 
    399     var body: some View {
    400         VStack {
    401             Button(action: { showQR.toggle() }, label: {
    402                 Image(systemName: "qrcode.viewfinder")})
    403             .foregroundColor(.gray)
    404             .accessibilityLabel(NSLocalizedString("Scan QR code", comment: "Accessibility label for a button that scans a private key QR code"))
    405         }
    406         .sheet(isPresented: $showQR, onDismiss: {
    407             if qrkey == nil { resetView() }}
    408         ) {
    409             QRScanNSECView(showQR: $showQR,
    410                            privKeyFound: $privKeyFound,
    411                            codeScannerCompletion: { scannerCompletion($0) })
    412         }
    413         .onChange(of: showQR) { show in
    414             if showQR { resetView() }
    415         }
    416     }
    417 
    418     func handleQRString(_ string: String) {
    419         qrkey = parse_key(string)
    420         if let key = qrkey, key.is_priv {
    421             loginKey = string
    422             privKeyFound = true
    423             shouldSaveKey = false
    424             generator.notificationOccurred(.success)
    425         }
    426     }
    427 
    428     func scannerCompletion(_ result: Result<ScanResult, ScanError>) {
    429         switch result {
    430         case .success(let success):
    431             handleQRString(success.string)
    432         case .failure:
    433             return
    434         }
    435     }
    436 
    437     func resetView() {
    438         loginKey = ""
    439         qrkey = nil
    440         privKeyFound = false
    441         shouldSaveKey = true
    442     }
    443 }
    444 
    445 struct CreateAccountPrompt: View {
    446     var nav: NavigationCoordinator
    447     var body: some View {
    448         HStack {
    449             Text("New to Nostr?", comment: "Ask the user if they are new to Nostr")
    450                 .foregroundColor(Color("DamusMediumGrey"))
    451             
    452             Button(NSLocalizedString("Create account", comment: "Button to navigate to create account view.")) {
    453                 nav.push(route: Route.CreateAccount)
    454             }
    455             
    456             Spacer()
    457         }
    458     }
    459 }
    460 
    461 struct LoginView_Previews: PreviewProvider {
    462     static var previews: some View {
    463 //        let pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681"
    464         let pubkey = "npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955"
    465         let bech32_pubkey = "KeyInput"
    466         Group {
    467             LoginView(key: pubkey, nav: .init())
    468                 .previewDevice(PreviewDevice(rawValue: "iPhone SE (3rd generation)"))
    469             LoginView(key: bech32_pubkey, nav: .init())
    470                 .previewDevice(PreviewDevice(rawValue: "iPhone 15 Pro Max"))
    471         }
    472     }
    473 }