johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
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
}
}
}