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

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The iOS app signs in, and reads as the account signed in

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:feature/ios-signin

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
ios/README.md+9−8
ios/run.sh+5−5