~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
372 lines · 14.2 KB Raw History
import AuthenticationServices
import CryptoKit
import Foundation
import Security
import SwiftUI

/// The session token, in the keychain. Read by the client on any thread.
enum SessionStore {
    private static var query: [String: Any] {
        [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: "dev.knotless.app",
            kSecAttrAccount as String: "session",
        ]
    }

    static var token: String? {
        var ask = query
        ask[kSecReturnData as String] = true
        var found: CFTypeRef?
        guard SecItemCopyMatching(ask as CFDictionary, &found) == errSecSuccess,
              let data = found as? Data
        else { return nil }
        return String(data: data, encoding: .utf8)
    }

    static func save(_ token: String) {
        clear()
        var item = query
        item[kSecValueData as String] = Data(token.utf8)
        item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        SecItemAdd(item as CFDictionary, nil)
    }

    static func clear() { SecItemDelete(query as CFDictionary) }
}

/// A PKCE pair: the verifier stays here, and its challenge goes to the browser.
struct PKCE {
    let verifier: String
    let challenge: String

    init() {
        var bytes = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
        verifier = Self.encoded(Data(bytes))
        challenge = Self.encoded(Data(SHA256.hash(data: Data(verifier.utf8))))
    }

    private static func encoded(_ data: Data) -> String {
        data.base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }
}

/// Who the app is signed in as, for the screens to watch.
@MainActor @Observable
final class Account {
    private(set) var person: Person?
    private(set) var checking = true
    /// The session asked for less than the app asks for now, so a write it
    /// wasn't granted would fail: signing in again grants the rest.
    private(set) var reauth = false
    var failure: String?
    /// Moved by following or unfollowing, so every profile is read again: the
    /// one followed, for its followers, and your own, for who you follow.
    var follows = 0
    private var web: ASWebAuthenticationSession?
    private let anchor = BrowserAnchor()

    var signedIn: Bool { person != nil }

    /// Finds who the kept session names, or that it no longer holds.
    func restore() async {
        defer { checking = false }
        guard SessionStore.token != nil || DevAccount.signedIn else { return }
        await loadViewer()
    }

    private func loadViewer() async {
        do {
            let viewer: Viewer = try await Client.shared.query("getViewer")
            hold(viewer)
        } catch {
            // Offline isn't signed out: the token stays for next time.
            failure = error.localizedDescription
        }
    }

    private func hold(_ viewer: Viewer) {
        person = viewer.actor
        reauth = viewer.reauth == true
        if person == nil { SessionStore.clear() }
    }

    /// Reads the session again as the app comes to the front, for what changed
    /// while it was away. Nothing to say if that fails, and nothing to do if the
    /// account signed in or out meanwhile, as this answer is of the session before.
    func refresh() async {
        let asked = SessionStore.token
        guard !checking, person != nil else { return }
        guard let viewer: Viewer = try? await Client.shared.query("getViewer"), SessionStore.token == asked else { return }
        hold(viewer)
    }

    /// Logs in through the browser, as `handle`.
    func signIn(handle: String) async {
        failure = nil
        let pkce = PKCE()
        var parts = URLComponents(url: Client.site.appendingPathComponent("oauth/app-login"), resolvingAgainstBaseURL: false)!
        parts.queryItems = [URLQueryItem(name: "challenge", value: pkce.challenge), URLQueryItem(name: "handle", value: handle)]
        let opened = Date()
        do {
            let back = try await browse(parts.url!)
            let items = URLComponents(url: back, resolvingAgainstBaseURL: false)?.queryItems ?? []
            guard let code = items.first(where: { $0.name == "code" })?.value else {
                failure = "Sign-in wasn't approved."
                return
            }
            let token = try await Client.shared.trade(code: code, verifier: pkce.verifier)
            SessionStore.save(token)
            await loadViewer()
        } catch let error as ASWebAuthenticationSessionError where error.code == .canceledLogin {
            // They closed it, with nothing to say. Too soon for anyone to have,
            // iOS refused it, as it does an app it can't match to the site.
            if Date().timeIntervalSince(opened) < 1 {
                failure = "This device couldn't open sign-in for this app."
            }
        } catch {
            failure = error.localizedDescription
        }
    }

    private func browse(_ url: URL) async throws -> URL {
        try await withCheckedThrowingContinuation { done in
            let session = ASWebAuthenticationSession(url: url, callback: .https(host: Client.site.host()!, path: "/oauth/app-return")) { back, error in
                if let back {
                    done.resume(returning: back)
                } else {
                    done.resume(throwing: error ?? URLError(.unknown))
                }
            }
            session.presentationContextProvider = anchor
            web = session
            session.start()
        }
    }

    /// Logs out here and on the server, which logs out the pages too. Gone
    /// from here whatever the server answers.
    func signOut() async {
        await Pushes.shared.stop()
        await Client.shared.logOut()
        SessionStore.clear()
        person = nil
    }

    /// Signed out here, once the server has deleted the account's data and with
    /// it the session and the device, so there's nothing to tell it.
    func forgotten() {
        Pushes.shared.forget()
        SessionStore.clear()
        person = nil
    }
}

private final class BrowserAnchor: NSObject, ASWebAuthenticationPresentationContextProviding {
    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        MainActor.assumeIsolated {
            let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
            return scenes.flatMap(\.windows).first(where: \.isKeyWindow) ?? ASPresentationAnchor()
        }
    }
}

/// Says over every tab that the session is behind what the app asks for now, as
/// the pages' note does, with a button that signs in again as the same account.
struct ReauthNote: View {
    @Environment(Account.self) private var account
    @State private var busy = false

    var body: some View {
        VStack(alignment: .leading, spacing: Space.s2_5) {
            Text("Knotless can do more now than when you signed in. Sign in again to allow it.")
                .font(.subheadline)
                .fixedSize(horizontal: false, vertical: true)
            if let failure = account.failure {
                Text(failure).font(.footnote).foregroundStyle(Color.danger)
            }
            Button {
                guard let did = account.person?.did else { return }
                busy = true
                Task {
                    await account.signIn(handle: did)
                    busy = false
                }
            } label: {
                Text(busy ? "Signing in…" : "Sign in again")
                    .font(.subheadline.weight(.semibold))
                    .foregroundStyle(Color.page)
                    .padding(.horizontal, Space.s4)
                    .padding(.vertical, Space.s2)
                    .background(Color.fg.opacity(busy ? 0.4 : 1), in: Capsule())
                    .contentShape(Capsule())
            }
            .buttonStyle(.plain)
            .disabled(busy)
        }
        .padding(Space.s4)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(Color.accentSoft)
    }
}

/// An account the typeahead service matched.
struct FoundActor: Decodable, Identifiable, Sendable {
    let did: String
    let handle: String
    var displayName: String?
    var avatar: String?
    var id: String { did }
}

/// The tilde over a handle to sign in with, which goes to the browser. Whoever
/// matches what's typed takes the tilde's place, right above the field, as the
/// keyboard covers what's under it.
struct SignInForm: View {
    @Environment(Account.self) private var account
    @State private var handle = ""
    @State private var busy = false
    @State private var found: [FoundActor] = []
    /// The handle just picked, which needs no more suggestions.
    @State private var picked: String?
    @FocusState private var typing: Bool
    var why: String

    private var name: String { handle.trimmingCharacters(in: .whitespaces) }
    private var ready: Bool { !name.isEmpty && !busy }
    private var suggesting: Bool { typing && !busy && !found.isEmpty && handle != picked }

    var body: some View {
        VStack(spacing: Space.s3) {
            Spacer(minLength: Space.s4)
            if suggesting {
                suggestions
            } else {
                Image("mark").renderingMode(.template).resizable().scaledToFit()
                    .frame(width: 112)
                    .foregroundStyle(.primary)
                    .accessibilityLabel("knotless")
                Spacer(minLength: Space.s4)
            }

            TextField("alice.bsky.social", text: $handle)
                .textContentType(.username)
                .textInputAutocapitalization(.never)
                .autocorrectionDisabled()
                .keyboardType(.URL)
                .submitLabel(.go)
                .focused($typing)
                .onSubmit(go)
                .padding(.horizontal, Space.s5)
                .frame(minHeight: Size.signIn)
                .background(Color.raised, in: Capsule())
                .accessibilityLabel("Your handle")

            Button(action: go) {
                Group {
                    if busy {
                        ProgressView().tint(Color.page)
                    } else {
                        Text("Sign in").font(.body.weight(.semibold))
                    }
                }
                .foregroundStyle(ready || busy ? Color.page : Color.mute)
                .frame(maxWidth: .infinity, minHeight: Size.signIn)
                .background(ready || busy ? Color.fg : Color.fill, in: Capsule())
                .contentShape(Capsule())
            }
            .buttonStyle(.plain)
            .disabled(!ready)

            Group {
                if let failure = account.failure {
                    Text(failure).foregroundStyle(Color.danger)
                } else {
                    Text(why).foregroundStyle(.secondary)
                }
            }
            .font(.footnote)
            .multilineTextAlignment(.center)
            .fixedSize(horizontal: false, vertical: true)

            Text(Self.terms)
                .font(.footnote)
                .multilineTextAlignment(.center)
                .fixedSize(horizontal: false, vertical: true)
                .tint(Color.accent)
                .padding(.top, Space.s5)
                .padding(.bottom, Space.s4)
        }
        .padding(.horizontal, Space.s6)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .contentShape(Rectangle())
        .onTapGesture { typing = false }
        // Asked a moment after typing stops; one overtaken by more typing is let go.
        .task(id: handle) {
            guard handle != picked else { return }
            try? await Task.sleep(for: .milliseconds(150))
            guard !Task.isCancelled else { return }
            let actors = await Client.shared.findActors(handle)
            if !Task.isCancelled { found = actors }
        }
    }

    private var suggestions: some View {
        ScrollView {
            VStack(spacing: 0) {
                ForEach(found) { actor in
                    Button { pick(actor) } label: {
                        HStack(spacing: Space.s3) {
                            Avatar(url: actor.avatar, name: actor.displayName ?? actor.handle, did: actor.did, size: Size.Avatar.regular)
                            VStack(alignment: .leading, spacing: 0) {
                                if let name = actor.displayName, !name.isEmpty {
                                    Text(name).font(.subheadline.weight(.semibold)).lineLimit(1)
                                }
                                Text("@\(actor.handle)").font(.footnote).foregroundStyle(.secondary).lineLimit(1)
                            }
                            Spacer(minLength: 0)
                        }
                        .padding(.horizontal, Space.s4)
                        .frame(minHeight: Size.row)
                        .contentShape(Rectangle())
                    }
                    .buttonStyle(.plain)
                }
            }
        }
        .scrollBounceBehavior(.basedOnSize)
        .frame(maxHeight: Size.row * CGFloat(found.count))
        .background(Color.raised, in: RoundedRectangle(cornerRadius: Radius.card))
        .clipShape(RoundedRectangle(cornerRadius: Radius.card))
        // It takes the room it asks for before the space above it does.
        .layoutPriority(1)
    }

    private static let terms: AttributedString = {
        let terms = Client.site.appendingPathComponent("terms").absoluteString
        let privacy = Client.site.appendingPathComponent("privacy").absoluteString
        let line = "By signing in you accept our [Terms of use](\(terms)) and [Privacy policy](\(privacy))."
        return (try? AttributedString(markdown: line)) ?? AttributedString(line)
    }()

    /// Fills it in and goes on, as the button would.
    private func pick(_ actor: FoundActor) {
        picked = actor.handle
        handle = actor.handle
        go()
    }

    private func go() {
        guard ready else { return }
        typing = false
        busy = true
        Task {
            await account.signIn(handle: name)
            busy = false
        }
    }
}