chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app signs in, and reads as the account signed in
Only what this pull adds. The iOS app can log in through a browser and hold a page's session landed as 688e92d, so its changes do not show here again.
ios/Knotless/API/Account.swiftnew+190−0
| @@ -0,0 +1,190 @@ | ||
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 | ||
var failure: String? | ||
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") | ||
person = viewer.actor | ||
if person == nil { SessionStore.clear() } | ||
} catch { | ||
// Offline isn't signed out: the token stays for next time. | ||
failure = error.localizedDescription | ||
} | ||
} | ||
| ||
/// Logs in through the browser, as `handle`, or making an account. | ||
func signIn(handle: String = "", create: Bool = false) 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)] | ||
+ (create ? [URLQueryItem(name: "create", value: "true")] : [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 Client.shared.logOut() | ||
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() | ||
} | ||
} | ||
} | ||
| ||
/// Asks for a handle and goes to the browser. | ||
struct SignInForm: View { | ||
@Environment(Account.self) private var account | ||
@State private var handle = "" | ||
@State private var busy = false | ||
var why: String | ||
| ||
var body: some View { | ||
Form { | ||
Section { | ||
TextField("alice.bsky.social", text: $handle) | ||
.textInputAutocapitalization(.never) | ||
.autocorrectionDisabled() | ||
.keyboardType(.URL) | ||
.submitLabel(.go) | ||
.onSubmit { go(create: false) } | ||
Button("Sign in") { go(create: false) } | ||
.disabled(handle.trimmingCharacters(in: .whitespaces).isEmpty || busy) | ||
} header: { | ||
Text("Your handle") | ||
} footer: { | ||
Text(why) | ||
} | ||
Section { | ||
Button("Make an account") { go(create: true) }.disabled(busy) | ||
} footer: { | ||
Text("An Atmosphere account is one you can use across apps.") | ||
} | ||
if let failure = account.failure { | ||
Section { Text(failure).foregroundStyle(Color.danger) } | ||
} | ||
} | ||
} | ||
| ||
private func go(create: Bool) { | ||
busy = true | ||
Task { | ||
await account.signIn(handle: handle.trimmingCharacters(in: .whitespaces), create: create) | ||
busy = false | ||
} | ||
} | ||
} | ||
ios/Knotless/API/Client.swift+56−12
| @@ -10,41 +10,80 @@ struct APIError: LocalizedError { | ||
| ||
private struct Failure: Decodable { var error: String?; var message: String? } | ||
| ||
/// Reads knotless's XRPC queries. Asks as an account only for a method it | ||
/// holds a service token for; everything else is public and asked as no one. | ||
/// 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 base = URL(string: "https://knotless.dev/xrpc/")! | ||
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 | ||
return URLSession(configuration: config) | ||
}() | ||
| ||
func query<T: Decodable & Sendable>(_ method: String, _ params: [String: String?] = [:]) async throws -> T { | ||
let nsid = "dev.knotless." + method | ||
var parts = URLComponents(url: base.appendingPathComponent(nsid), resolvingAgainstBaseURL: false)! | ||
var parts = URLComponents(url: Self.site.appendingPathComponent("xrpc/" + nsid), resolvingAgainstBaseURL: false)! | ||
parts.queryItems = params.compactMap { k, v in v.map { URLQueryItem(name: k, value: $0) } }.sorted { $0.name < $1.name } | ||
var request = URLRequest(url: parts.url!) | ||
request.timeoutInterval = 40 | ||
if let token = DevAccount.token(for: nsid) { | ||
authorize(&request, nsid) | ||
return try await send(request) | ||
} | ||
| ||
/// 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") | ||
} | ||
let (data, response) = try await URLSession.shared.data(for: request) | ||
} | ||
| ||
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) | ||
throw APIError(status: status, name: failure?.error, message: failure?.message) | ||
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) | ||
} | ||
} | ||
| ||
/// An account for trying the app before it has a sign-in: a handle to read | ||
/// as, and service tokens handed over at launch, never kept or built in. | ||
/// `ios/run.sh` mints them with goat. | ||
/// 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 actor: String? { env["KNOTLESS_ACTOR"] } | ||
static var route: String? { env["KNOTLESS_ROUTE"] } | ||
static var signedIn: Bool { !tokens.isEmpty } | ||
| ||
private static let tokens: [String: String] = { | ||
guard let path = env["KNOTLESS_TOKENS"], | ||
| @@ -55,4 +94,9 @@ enum DevAccount { | ||
}() | ||
| ||
static func token(for nsid: String) -> String? { tokens[nsid] } | ||
#else | ||
static let route: String? = nil | ||
static let signedIn = false | ||
static func token(for nsid: String) -> String? { nil } | ||
#endif | ||
} | ||
ios/Knotless/API/Models.swift+5−0
| @@ -288,6 +288,11 @@ struct FeedEvent: Decodable, Identifiable, Hashable { | ||
} | ||
} | ||
| ||
struct Viewer: Decodable { | ||
var actor: Person? | ||
var reauth: Bool? | ||
} | ||
| ||
struct Feed: Decodable { | ||
var events: [FeedEvent] = [] | ||
var cursor: String? | ||
ios/Knotless/Views/HomeView.swift+7−31
| @@ -1,42 +1,14 @@ | ||
import SwiftUI | ||
| ||
/// Who the app reads as until it has a sign-in: only a handle, so only what | ||
/// anyone can read. | ||
struct ActorKey { | ||
static let storage = "handle" | ||
static var initial: String { DevAccount.actor ?? "" } | ||
} | ||
| ||
struct HandlePrompt: View { | ||
@Binding var handle: String | ||
@State private var draft = "" | ||
| ||
var body: some View { | ||
Section { | ||
TextField("alice.bsky.social", text: $draft) | ||
.textInputAutocapitalization(.never) | ||
.autocorrectionDisabled() | ||
.submitLabel(.done) | ||
.onSubmit { handle = draft.trimmingCharacters(in: .whitespaces) } | ||
} header: { | ||
Text("Your handle") | ||
} footer: { | ||
Text("Enter your handle to see your repositories and stars.") | ||
} | ||
} | ||
} | ||
| ||
struct HomeView: View { | ||
@AppStorage(ActorKey.storage) private var handle = ActorKey.initial | ||
@Environment(Account.self) private var account | ||
| ||
private struct Answer: Sendable { let repos: [Repo]; let stars: [Repo] } | ||
| ||
var body: some View { | ||
Group { | ||
if handle.isEmpty { | ||
List { HandlePrompt(handle: $handle) } | ||
} else { | ||
Loading(load: { [handle] in | ||
if let handle = account.person?.handle { | ||
Loading(load: { | ||
async let repos: RepoList = Client.shared.query("getActorRepos", ["actor": handle]) | ||
async let stars: RepoList = Client.shared.query("getActorStars", ["actor": handle]) | ||
return try await Answer(repos: repos.repos, stars: stars.repos) | ||
| @@ -54,6 +26,10 @@ struct HomeView: View { | ||
} | ||
} | ||
.id(handle) | ||
} else if account.checking { | ||
ProgressView() | ||
} else { | ||
SignInForm(why: "Sign in to see your repositories and stars.") | ||
} | ||
} | ||
.navigationTitle("Home") | ||
ios/Knotless/Views/InboxView.swift+8−5
| @@ -6,21 +6,25 @@ struct InboxView: View { | ||
("Comments", "comments"), ("Checks", "checks"), ("Pulls and issues", "pulls-and-issues"), | ||
] | ||
| ||
@Environment(Account.self) private var account | ||
@State private var filter: String? | ||
@State private var items: [InboxItem] = [] | ||
@State private var cursor: String? | ||
@State private var phase = Phase.loading | ||
| ||
enum Phase { case loading, ready, signedOut, failed(String) } | ||
private struct ReloadKey: Hashable { let filter: String?; let did: String? } | ||
| ||
var body: some View { | ||
VStack(spacing: 0) { | ||
chips | ||
Divider() | ||
if case .signedOut = phase {} else { | ||
chips | ||
Divider() | ||
} | ||
content.frame(maxHeight: .infinity) | ||
} | ||
.navigationTitle("Inbox") | ||
.task(id: filter) { await reload() } | ||
.task(id: ReloadKey(filter: filter, did: account.person?.did)) { await reload() } | ||
} | ||
| ||
private var chips: some View { | ||
| @@ -45,8 +49,7 @@ struct InboxView: View { | ||
case .loading: | ||
ProgressView() | ||
case .signedOut: | ||
ContentUnavailableView("Sign in to see your inbox", systemImage: "tray", | ||
description: Text("Your notifications will show here once this app can sign in.")) | ||
SignInForm(why: "Sign in to see your inbox.") | ||
case .failed(let message): | ||
ContentUnavailableView { | ||
SwiftUI.Label("This didn't load", systemImage: "wifi.exclamationmark") | ||
ios/Knotless/Views/ProfileView.swift+16−4
| @@ -1,13 +1,25 @@ | ||
import SwiftUI | ||
| ||
struct OwnProfileView: View { | ||
@AppStorage(ActorKey.storage) private var handle = ActorKey.initial | ||
@Environment(Account.self) private var account | ||
| ||
var body: some View { | ||
if handle.isEmpty { | ||
List { HandlePrompt(handle: $handle) }.navigationTitle("Profile") | ||
if let handle = account.person?.handle { | ||
ProfileScreen(actor: handle) | ||
.id(handle) | ||
.toolbar { | ||
ToolbarItem(placement: .topBarTrailing) { | ||
Menu { | ||
Button("Sign out", role: .destructive) { Task { await account.signOut() } } | ||
} label: { | ||
SwiftUI.Label("More", systemImage: "ellipsis") | ||
} | ||
} | ||
} | ||
} else if account.checking { | ||
ProgressView() | ||
} else { | ||
ProfileScreen(actor: handle).id(handle) | ||
SignInForm(why: "Sign in to see your profile.").navigationTitle("Profile") | ||
} | ||
} | ||
} | ||
ios/Knotless/App.swift+3−1
| @@ -2,8 +2,10 @@ import SwiftUI | ||
| ||
@main | ||
struct KnotlessApp: App { | ||
@State private var account = Account() | ||
| ||
var body: some Scene { | ||
WindowGroup { RootView().tint(.accent) } | ||
WindowGroup { RootView().tint(.accent).environment(account).task { await account.restore() } } | ||
} | ||
} | ||
| ||
ios/Knotless/Knotless.entitlementsnew+11−0
| @@ -0,0 +1,11 @@ | ||
<?xml version="1.0" encoding="UTF-8"?> | ||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
<plist version="1.0"> | ||
<dict> | ||
<key>com.apple.developer.associated-domains</key> | ||
<array> | ||
<string>applinks:knotless.dev</string> | ||
<string>webcredentials:knotless.dev</string> | ||
</array> | ||
</dict> | ||
</plist> | ||
ios/KnotlessUITests/FlowTests.swift+1−1
| @@ -9,7 +9,7 @@ final class FlowTests: XCTestCase { | ||
override func setUp() { | ||
continueAfterFailure = false | ||
app = XCUIApplication() | ||
for key in ["KNOTLESS_ACTOR", "KNOTLESS_TOKENS"] { | ||
for key in ["KNOTLESS_TOKENS"] { | ||
app.launchEnvironment[key] = ProcessInfo.processInfo.environment[key] | ||
} | ||
app.launch() | ||
ios/project.yml+8−1
| @@ -16,12 +16,19 @@ settings: | ||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES | ||
INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight | ||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight | ||
CODE_SIGNING_ALLOWED: NO | ||
DEVELOPMENT_TEAM: YN68LN9T7Z | ||
CODE_SIGN_STYLE: Automatic | ||
targets: | ||
Knotless: | ||
type: application | ||
platform: iOS | ||
sources: [Knotless] | ||
entitlements: | ||
path: Knotless/Knotless.entitlements | ||
properties: | ||
com.apple.developer.associated-domains: | ||
- applinks:knotless.dev | ||
- webcredentials:knotless.dev | ||
settings: | ||
base: | ||
PRODUCT_BUNDLE_IDENTIFIER: dev.knotless.app | ||