macOS client store and API
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
private let originKey = "byre.server"
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Connection {
|
||||
enum Phase {
|
||||
case connect
|
||||
case starting
|
||||
case failed(String)
|
||||
case running(Store)
|
||||
}
|
||||
|
||||
var phase: Phase = .connect
|
||||
var serverText: String
|
||||
var problem: String?
|
||||
var connecting = false
|
||||
@ObservationIgnored private var generation = 0
|
||||
|
||||
init() {
|
||||
let saved = UserDefaults.standard.string(forKey: originKey)
|
||||
serverText = saved ?? ""
|
||||
if let saved, let origin = parseOrigin(saved) {
|
||||
phase = .starting
|
||||
Task {
|
||||
await start(origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var store: Store? {
|
||||
if case .running(let store) = phase {
|
||||
return store
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func connect() async {
|
||||
guard let origin = parseOrigin(serverText) else {
|
||||
problem = "The server URL must be an https origin with no path, such as https://byre.example.ts.net."
|
||||
return
|
||||
}
|
||||
connecting = true
|
||||
problem = nil
|
||||
let gen = generation
|
||||
do {
|
||||
_ = try await API(origin: origin).client.getStatus().ok
|
||||
} catch {
|
||||
if gen == generation {
|
||||
connecting = false
|
||||
problem = explain(error, origin)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard gen == generation else {
|
||||
return
|
||||
}
|
||||
connecting = false
|
||||
UserDefaults.standard.set(origin.absoluteString, forKey: originKey)
|
||||
serverText = origin.absoluteString
|
||||
phase = .starting
|
||||
await start(origin)
|
||||
}
|
||||
|
||||
func changeServer() {
|
||||
generation += 1
|
||||
store?.stop()
|
||||
connecting = false
|
||||
problem = nil
|
||||
phase = .connect
|
||||
}
|
||||
|
||||
private func start(_ origin: URL) async {
|
||||
generation += 1
|
||||
let gen = generation
|
||||
let store = Store(api: API(origin: origin))
|
||||
do {
|
||||
let status = try await store.api.client.getStatus().ok.body.json
|
||||
async let me: Void = store.loadMe()
|
||||
async let projects: Void = store.loadProjects()
|
||||
async let inbox: Void = store.loadInbox()
|
||||
async let lead: Void = store.loadLeadSessions()
|
||||
async let reads: Void = store.loadSummaryReads()
|
||||
_ = try await (me, projects, inbox, lead, reads)
|
||||
try await store.loadFocus()
|
||||
guard gen == generation else {
|
||||
store.stop()
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await store.startLeadStream()
|
||||
}
|
||||
phase = .running(store)
|
||||
store.startGlobalStream(after: status.lastEventSeq)
|
||||
} catch {
|
||||
store.stop()
|
||||
if gen == generation {
|
||||
phase = .failed("byre failed to start: \(errorText(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let tlsErrors: Set<URLError.Code> = [
|
||||
.serverCertificateUntrusted, .serverCertificateHasBadDate, .serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
|
||||
.secureConnectionFailed, .clientCertificateRejected, .clientCertificateRequired,
|
||||
]
|
||||
|
||||
private let unreachableErrors: Set<URLError.Code> = [
|
||||
.cannotFindHost, .cannotConnectToHost, .dnsLookupFailed, .timedOut, .notConnectedToInternet, .networkConnectionLost,
|
||||
]
|
||||
|
||||
func explain(_ error: any Error, _ origin: URL) -> String {
|
||||
let host = origin.host() ?? origin.absoluteString
|
||||
let e = rootError(error)
|
||||
if let api = e as? ApiError {
|
||||
switch api.status {
|
||||
case 401:
|
||||
return "\(host) answered 401, no Tailscale identity: \(api.message). Use byre's ts.net HTTPS address, the one tailscale serve gives it, so the request carries your Tailscale login."
|
||||
case 403:
|
||||
return "\(host) answered 403, the request didn't come through tailscale serve: \(api.message). Use the HTTPS address tailscale serve gives byre."
|
||||
default:
|
||||
return api.message
|
||||
}
|
||||
}
|
||||
if let url = e as? URLError {
|
||||
if tlsErrors.contains(url.code) {
|
||||
return "\(host)'s certificate isn't trusted: \(url.localizedDescription) Use byre's ts.net HTTPS address, whose certificate tailscale serve provides."
|
||||
}
|
||||
if unreachableErrors.contains(url.code) {
|
||||
return "Can't reach \(host): \(url.localizedDescription) Is Tailscale connected?"
|
||||
}
|
||||
return url.localizedDescription
|
||||
}
|
||||
return errorText(e)
|
||||
}
|
||||
|
||||
struct ConnectView: View {
|
||||
@Bindable var connection: Connection
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Server URL")
|
||||
TextField("https://byre.example.ts.net", text: $connection.serverText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit(connect)
|
||||
Button("Connect", action: connect)
|
||||
.disabled(connection.connecting)
|
||||
if let problem = connection.problem {
|
||||
Text(problem)
|
||||
.foregroundStyle(Palette.danger)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(width: 460)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
Task {
|
||||
await connection.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FatalView: View {
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
Text(message)
|
||||
.foregroundStyle(Palette.danger)
|
||||
.textSelection(.enabled)
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
struct RootView: View {
|
||||
@Environment(Connection.self) private var connection
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch connection.phase {
|
||||
case .connect:
|
||||
ConnectView(connection: connection)
|
||||
case .starting:
|
||||
Color.clear
|
||||
case .failed(let message):
|
||||
FatalView(message: message)
|
||||
case .running(let store):
|
||||
SpikeView()
|
||||
.environment(store)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Palette.text)
|
||||
.background(Palette.bg)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user