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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
296 lines · 13.2 KB Raw History
import Foundation
import SwiftUI

struct APIError: LocalizedError {
    let status: Int
    let name: String?
    let message: String?
    var errorDescription: String? { message ?? "The server answered \(status)." }
    var needsSignIn: Bool { status == 401 }
}

private struct Failure: Decodable { var error: String?; var message: String? }

/// Reads knotless's XRPC queries, as the signed-in account when there is one,
/// by the session cookie the pages' login sets. Everything public also answers
/// with no one signed in.
actor Client {
    static let shared = Client()
    nonisolated static let site = URL(string: "https://knotless.dev/")!

    private let decoder = JSONDecoder()
    private let session: URLSession = {
        // The cookie is sent by hand: none is ever kept in a shared store.
        let config = URLSessionConfiguration.default
        config.httpShouldSetCookies = false
        config.httpCookieStorage = nil
        // Room for accounts' pictures and those in Markdown, across launches.
        config.urlCache = URLCache(memoryCapacity: 0, diskCapacity: 128 * 1024 * 1024)
        return URLSession(configuration: config)
    }()

    /// Who on the Atmosphere matches what's typed of a handle or name, best first,
    /// from the typeahead service the pages ask: none for under two letters, or
    /// when it can't say. It's told nothing but the text.
    func findActors(_ typed: String) async -> [FoundActor] {
        let text = typed.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "^@", with: "", options: .regularExpression)
        guard text.count >= 2 else { return [] }
        struct Found: Decodable { var actors: [FoundActor]? }
        let data: Data
        #if DEBUG
        if let recorded = DevAccount.recording {
            guard let answer = try? recorded.answer("searchActors", ["q": text]) else { return [] }
            return (try? decoder.decode(Found.self, from: answer))?.actors ?? []
        }
        #endif
        var parts = URLComponents(string: "https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors")!
        parts.queryItems = [URLQueryItem(name: "q", value: text), URLQueryItem(name: "limit", value: "6")]
        var request = URLRequest(url: parts.url!)
        request.timeoutInterval = 10
        do {
            let (body, response) = try await session.data(for: request)
            guard (response as? HTTPURLResponse)?.statusCode == 200 else { return [] }
            data = body
        } catch {
            return []
        }
        let actors = (try? decoder.decode(Found.self, from: data))?.actors ?? []
        return actors.filter { !$0.handle.isEmpty && $0.handle != "handle.invalid" }
    }

    func query<T: Decodable & Sendable>(_ method: String, _ params: [String: String?] = [:]) async throws -> T {
        try await query(method, items: params.compactMap { k, v in v.map { URLQueryItem(name: k, value: $0) } })
    }

    /// As above, with parameters that may repeat, as an array's items do.
    func query<T: Decodable & Sendable>(_ method: String, items: [URLQueryItem]) async throws -> T {
        let nsid = "dev.knotless." + method
        #if DEBUG
        if let recorded = DevAccount.recording {
            let params = Dictionary(items.map { ($0.name, $0.value) }) { _, last in last }
            return try decoder.decode(T.self, from: recorded.answer(method, params))
        }
        #endif
        var parts = URLComponents(url: Self.site.appendingPathComponent("xrpc/" + nsid), resolvingAgainstBaseURL: false)!
        parts.queryItems = items.sorted { $0.name < $1.name }
        var request = URLRequest(url: parts.url!)
        request.timeoutInterval = 40
        authorize(&request, nsid)
        return try await send(request)
    }

    /// A write, as the account signed in. What it answers is left, as a screen
    /// that writes reads what it shows again.
    func send(_ write: Write) async throws {
        #if DEBUG
        if DevAccount.recording != nil { return }
        #endif
        _ = try await call(write)
    }

    /// One part of deleting the account's data, and whether that was the last.
    func deleteStep(_ write: Write) async throws -> DeleteStep {
        #if DEBUG
        if DevAccount.recording != nil { return DeleteStep(deleted: 0, done: true) }
        #endif
        return try await decoder.decode(DeleteStep.self, from: call(write))
    }

    /// What the disk cache holds, in bytes.
    func cacheSize() -> Int { session.configuration.urlCache?.currentDiskUsage ?? 0 }

    /// Forgets what's kept to load faster. Everything can be asked for again.
    func clearCache() {
        session.configuration.urlCache?.removeAllCachedResponses()
        Faces.forget()
    }

    /// A picture Markdown names. Those on this site are asked for as the account
    /// signed in, as a private repository's must be.
    func picture(_ url: URL) async throws -> Data {
        #if DEBUG
        if let recorded = DevAccount.recording, url.host() == Self.site.host() {
            return try recorded.file(url.path)
        }
        #endif
        var request = URLRequest(url: url)
        request.timeoutInterval = 40
        if url.host() == Self.site.host() { authorize(&request, nil) }
        let (data, response) = try await session.data(for: request)
        guard ((response as? HTTPURLResponse)?.statusCode ?? 0) < 300 else { throw URLError(.badServerResponse) }
        return data
    }

    /// A write whose answer the screen needs, as a fork's says what it made.
    func answer<T: Decodable & Sendable>(to write: Write) async throws -> T {
        #if DEBUG
        if let recorded = DevAccount.recording {
            return try decoder.decode(T.self, from: recorded.answer(write.method, write.input))
        }
        #endif
        return try decoder.decode(T.self, from: await call(write))
    }

    private func call(_ write: Write) async throws -> Data {
        var request = URLRequest(url: Self.site.appendingPathComponent("xrpc/dev.knotless." + write.method))
        request.httpMethod = "POST"
        request.timeoutInterval = 60
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONSerialization.data(withJSONObject: write.body)
        authorize(&request, nil)
        let (data, response) = try await session.data(for: request)
        let status = (response as? HTTPURLResponse)?.statusCode ?? 0
        guard (200..<300).contains(status) else {
            let failure = try? decoder.decode(Failure.self, from: data)
            let text = failure == nil ? String(data: data, encoding: .utf8) : nil
            throw APIError(status: status, name: failure?.error, message: failure?.message ?? text)
        }
        return data
    }

    /// Trades the code a login came back with for the session's token.
    func trade(code: String, verifier: String) async throws -> String {
        struct Answer: Decodable, Sendable { let token: String }
        var request = URLRequest(url: Self.site.appendingPathComponent("oauth/app-token"))
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONSerialization.data(withJSONObject: ["code": code, "verifier": verifier])
        let answer: Answer = try await send(request)
        return answer.token
    }

    /// Logs the session out on the server. Whether that worked doesn't change
    /// what the app does next, which is forget it.
    func logOut() async {
        var request = URLRequest(url: Self.site.appendingPathComponent("oauth/logout"))
        request.httpMethod = "POST"
        authorize(&request, nil)
        _ = try? await session.data(for: request)
    }

    private func authorize(_ request: inout URLRequest, _ nsid: String?) {
        request.httpShouldHandleCookies = false
        if let token = SessionStore.token {
            request.setValue("knotless_session=\(token)", forHTTPHeaderField: "Cookie")
        } else if let nsid, let token = DevAccount.token(for: nsid) {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
    }

    private func send<T: Decodable & Sendable>(_ request: URLRequest) async throws -> T {
        let (data, response) = try await session.data(for: request)
        let status = (response as? HTTPURLResponse)?.statusCode ?? 0
        guard (200..<300).contains(status) else {
            let failure = try? decoder.decode(Failure.self, from: data)
            let text = failure == nil ? String(data: data, encoding: .utf8) : nil
            throw APIError(status: status, name: failure?.error, message: failure?.message ?? text)
        }
        return try decoder.decode(T.self, from: data)
    }
}

/// For trying the app without signing in, in a debug build only: an account to
/// read as, and service tokens handed over at launch by `ios/run.sh`.
enum DevAccount {
    #if DEBUG
    private static let env = ProcessInfo.processInfo.environment
    static var route: String? { env["KNOTLESS_ROUTE"] }
    static var signedIn: Bool { !tokens.isEmpty || env["KNOTLESS_SIGNED_IN"] != nil }
    /// The time to read others against, so a picture reads the same on any day.
    static let now: Date? = env["KNOTLESS_NOW"].flatMap { ISO8601DateFormatter().date(from: $0) }
    static let scheme: ColorScheme? = ["light": ColorScheme.light, "dark": .dark][env["KNOTLESS_SCHEME"] ?? ""]
    /// A theme to draw the app in, by the name the pages give it: `dracula`, or `latte/mocha` for a pair.
    /// Forgets how the app was last left drawn, for `ios/look.mjs`.
    static func freshen() {
        guard env["KNOTLESS_FRESH"] != nil else { return }
        for key in [Appearance.key, "account-theme", "account-themes"] { UserDefaults.standard.removeObject(forKey: key) }
    }

    static let theme: ThemeChoice? = env["KNOTLESS_THEME"].map { name in
        let halves = name.split(separator: "/").map { ThemeHalf(theme: String($0)) }
        return ThemeChoice(day: halves[0], night: halves.count > 1 ? halves[1] : nil)
    }
    /// Answers read from a file rather than a server, for `ios/look.mjs`.
    static let recording: Recording? = env["KNOTLESS_ANSWERS"].flatMap { Recording(path: $0, misses: env["KNOTLESS_MISSES"]) }

    private static let tokens: [String: String] = {
        guard let path = env["KNOTLESS_TOKENS"],
              let data = FileManager.default.contents(atPath: path),
              let map = try? JSONDecoder().decode([String: String].self, from: data)
        else { return [:] }
        return map
    }()

    static func token(for nsid: String) -> String? { tokens[nsid] }
    #else
    static let route: String? = nil
    static let signedIn = false
    static let now: Date? = nil
    static let scheme: ColorScheme? = nil
    static let theme: ThemeChoice? = nil
    static func freshen() {}
    static func token(for nsid: String) -> String? { nil }
    #endif
}

#if DEBUG
/// What `ios/look.mjs` has the app answered with. A method's answer is the one
/// whose parameters best match what was asked, as a recording's rarely match
/// the app's exactly.
final class Recording: Sendable {
    private struct Answer: Decodable, Sendable {
        let method: String
        let params: [String: String]
        let status: Int
        let body: String
    }

    private let answers: [Answer]
    private let misses: String?

    init?(path: String, misses: String?) {
        guard let data = FileManager.default.contents(atPath: path),
              let answers = try? JSONDecoder().decode([Answer].self, from: data)
        else { return nil }
        self.answers = answers
        self.misses = misses
    }

    /// A file the recording holds by its path, as a README's picture.
    func file(_ path: String) throws -> Data {
        guard let found = answers.first(where: { $0.method == "file" && $0.params["path"] == path }) else {
            throw APIError(status: 404, name: nil, message: "Nothing recorded at \(path).")
        }
        return Data(found.body.utf8)
    }

    func answer(_ method: String, _ params: [String: String?]) throws -> Data {
        let asked = params.compactMapValues { $0 }
        func score(_ answer: Answer) -> Int {
            asked.reduce(0) { sum, pair in
                guard let value = answer.params[pair.key] ?? answer.params.values.first(where: { $0.hasSuffix("/" + pair.value) }) else { return sum }
                return value == pair.value || value.hasSuffix("/" + pair.value) ? sum + 1 : sum
            }
        }
        var best: Answer?
        var bestScore = -1
        for answer in answers where answer.method == method && answer.status == 200 && score(answer) > bestScore {
            best = answer
            bestScore = score(answer)
        }
        guard let best else {
            if let misses, let line = "\(method)\n".data(using: .utf8) {
                if let file = FileHandle(forWritingAtPath: misses) {
                    file.seekToEndOfFile()
                    file.write(line)
                    file.closeFile()
                } else {
                    FileManager.default.createFile(atPath: misses, contents: line)
                }
            }
            throw APIError(status: 404, name: nil, message: "Nothing recorded for \(method).")
        }
        return Data(best.body.utf8)
    }
}
#endif