Files
byre/mac/Sources/Byre/Startup.swift
T

241 lines
7.5 KiB
Swift

import CoreData
import Foundation
import Observation
import SwiftData
import SwiftUI
@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() {
serverText = ""
resume()
Task {
for await note in NotificationCenter.default.notifications(named: NSPersistentCloudKitContainer.eventChangedNotification) {
if let event = note.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event, event.type == .import, event.endDate != nil {
store?.drafts?.reload()
resume()
}
}
}
}
private func resume() {
guard case .connect = phase, serverText.isEmpty, !connecting, let saved = Self.servers().first?.origin, let origin = parseOrigin(saved) else {
return
}
serverText = saved
phase = .starting
Task {
await start(origin)
}
}
private static func servers() -> [ServerRecord] {
fetchRecords(FetchDescriptor(sortBy: [SortDescriptor(\.saved, order: .reverse)]))
}
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
for server in Self.servers() {
persistence.delete(server)
}
persistence.insert(ServerRecord(origin: origin.absoluteString))
saveRecords()
serverText = origin.absoluteString
phase = .starting
await start(origin)
}
func changeServer() {
generation += 1
if let store {
Task {
await 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)
store.loadDrafts()
try await store.loadFocus()
guard gen == generation else {
await store.stop()
return
}
Task {
await store.startLeadStream()
}
phase = .running(store)
store.startGlobalStream(after: status.lastEventSeq)
resumeClientRuns(store.api)
Notifier.shared.deliver()
} catch {
await 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
@Environment(\.openWindow) private var openWindow
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):
MainView()
.environment(store)
}
}
.foregroundStyle(Palette.text)
.tint(Palette.accent)
.background(Palette.bg)
.onAppear {
let openWindow = openWindow
Notifier.shared.connection = connection
Notifier.shared.showWindow = {
openWindow(id: "main")
}
Notifier.shared.deliver()
}
}
}