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 installHere() async { connecting = true problem = nil let gen = generation do { let origin = try await Server.turnOn() serverText = origin.absoluteString try await waitForServer(origin) } catch { if gen == generation { connecting = false problem = errorText(error) } return } guard gen == generation else { return } await connect() } private func waitForServer(_ origin: URL) async throws { let api = API(origin: origin) try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { while (try? await api.client.getStatus().ok) == nil { try await Task.sleep(for: .milliseconds(500)) } } group.addTask { try await Task.sleep(for: .seconds(10)) throw ProcessFailure(errorDescription: "byre was installed but \(origin.absoluteString) didn't answer within 10 s.") } defer { group.cancelAll() } try await group.next() } } 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) store.remote.start() Notifier.shared.deliver() } catch { await store.stop() if gen == generation { phase = .failed("byre failed to start: \(errorText(error))") } } } } private let tlsErrors: Set = [ .serverCertificateUntrusted, .serverCertificateHasBadDate, .serverCertificateNotYetValid, .serverCertificateHasUnknownRoot, .secureConnectionFailed, .clientCertificateRejected, .clientCertificateRequired, ] private let unreachableErrors: Set = [ .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) HStack(spacing: 10) { Button("Connect", action: connect) Button("Run byre on this Mac", action: installHere) } .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() } } private func installHere() { Task { await connection.installHere() } } } 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() } } }