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(_ 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(_ 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(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(_ 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