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