import CoreData import Foundation import Observation import SwiftData import SwiftUI @MainActor @Observable final class Servers { static let shared = Servers() private(set) var entries: [(origin: String, record: ServerRecord)] = [] private(set) var connections: [String: Connection] = [:] var current: String? @ObservationIgnored var openWindow: (() -> Void)? private init() { load() current = origins.first 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 { for connection in connections.values { connection.store?.drafts?.reload() } load() } } } } var origins: [String] { entries.map(\.origin) } func record(_ origin: String) -> ServerRecord? { entries.first { $0.origin == origin }?.record } func color(_ origin: String) -> Color { taskPalette[(record(origin)?.color ?? 0) % taskPalette.count] } private func load() { var loaded: [(origin: String, record: ServerRecord)] = [] for record in fetchRecords(FetchDescriptor(sortBy: [SortDescriptor(\.saved)])) { if let origin = parseOrigin(record.origin), !loaded.contains(where: { $0.origin == origin.absoluteString }) { loaded.append((origin.absoluteString, record)) } } entries = loaded if let current, record(current) == nil { self.current = origins.first } for (origin, _) in loaded where connections[origin] == nil { connections[origin] = Connection(origin: parseOrigin(origin)!) } for (origin, connection) in connections where !loaded.contains(where: { $0.origin == origin }) { connections[origin] = nil Task { await connection.stop() } } } func add(_ origin: URL) { if record(origin.absoluteString) == nil { let used = Set(entries.map(\.record.color)) let color = taskPalette.indices.first { !used.contains($0) } ?? entries.count % taskPalette.count persistence.insert(ServerRecord(origin: origin.absoluteString, color: color)) saveRecords() } load() } func remove(_ origin: String) { for record in fetchRecords(FetchDescriptor()) where parseOrigin(record.origin)?.absoluteString == origin { persistence.delete(record) } saveRecords() load() } func setColor(_ origin: String, _ color: Int) { for record in fetchRecords(FetchDescriptor()) where parseOrigin(record.origin)?.absoluteString == origin { record.color = color } saveRecords() } func step(from origin: String?, by step: Int) -> String? { guard let i = origin.flatMap({ origins.firstIndex(of: $0) }) else { return origins.first } return origins[(i + step + origins.count) % origins.count] } func show(_ origin: String) { if record(origin) != nil { current = origin } openWindow?() } } func serverName(_ origin: String) -> String { let host = URL(string: origin)?.host() ?? origin return host.split(separator: ".").first.map(String.init) ?? host } @MainActor @Observable final class Connection { enum Phase { case starting case failed(String) case running(Store) } let origin: URL let overlays = Overlays() var phase: Phase = .starting @ObservationIgnored private var stopped = false init(origin: URL) { self.origin = origin Task { await start() } } var store: Store? { if case .running(let store) = phase { return store } return nil } func stop() async { stopped = true if let store { await store.stop() } } private func start() async { while !stopped, !(await attempt()) { try? await Task.sleep(for: .seconds(2)) } } private func attempt() async -> Bool { 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 !stopped else { await store.stop() return true } Task { await store.startLeadStream() } phase = .running(store) store.startGlobalStream(after: status.lastEventSeq) store.remote.start() Notifier.shared.deliver() return true } catch { await store.stop() if !stopped { phase = .failed("byre failed to start: \(errorText(error))") } return false } } } @MainActor @Observable final class ConnectForm { var serverText = "" var problem: String? var missing: [Check] = [] var connecting = false func connect() async -> URL? { 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 nil } connecting = true problem = nil do { _ = try await API(origin: origin).client.getStatus().ok } catch { connecting = false problem = explain(error, origin) return nil } connecting = false Servers.shared.add(origin) return origin } func installHere() async -> URL? { connecting = true problem = nil missing = [] do { let checks = try await Server.checks() if !checks.ready { missing = checks.checks.filter { !$0.ok } connecting = false return nil } let origin = try await Server.turnOn() serverText = origin.absoluteString try await waitForServer(origin) } catch { connecting = false problem = errorText(error) return nil } 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() } } } 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 form: ConnectForm let connected: (URL) -> Void var body: some View { GeometryReader { g in ScrollView { fields .padding(.vertical, 24) .frame(maxWidth: .infinity, minHeight: g.size.height) } } } private var fields: some View { VStack(alignment: .leading, spacing: 10) { Text("Server URL") TextField("https://byre.example.ts.net", text: $form.serverText) .textFieldStyle(.roundedBorder) .onSubmit(connect) HStack(spacing: 10) { Button("Connect", action: connect) Button("Run byre on this Mac", action: installHere) } .disabled(form.connecting) if let problem = form.problem { Text(problem) .foregroundStyle(Palette.danger) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } if !form.missing.isEmpty { Text("byre can't run on this Mac yet. Install what's missing, then click Run byre on this Mac again.") .fixedSize(horizontal: false, vertical: true) .padding(.top, 6) ChecksView(checks: form.missing) } } .frame(width: 460) } private func connect() { Task { if let origin = await form.connect() { connected(origin) } } } private func installHere() { Task { if let origin = await form.installHere() { connected(origin) } } } } 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 ServerFocus { let store: Store? let overlays: Overlays let forget: () -> Void } extension FocusedValues { @Entry var serverWindow: ServerFocus? } struct ServerWindow: View { @ViewState private var form = ConnectForm() @Environment(\.openWindow) private var openWindow private var connection: Connection? { Servers.shared.current.flatMap { Servers.shared.connections[$0] } } private var focus: ServerFocus? { connection.map { ServerFocus(store: $0.store, overlays: $0.overlays, forget: forget) } } private var frameColor: Color { connection.map { Servers.shared.color($0.origin.absoluteString) } ?? Palette.bg } @ViewBuilder private var content: some View { if let connection { switch connection.phase { case .starting: Color.clear case .failed(let message): FatalView(message: message) case .running(let store): MainView() .environment(store) .environment(connection.overlays) .id(connection.origin) } } else { ConnectView(form: form) { connected in form.serverText = "" Servers.shared.current = connected.absoluteString } } } private var framed: some View { content .foregroundStyle(Palette.text) .tint(Palette.accent) .background(Palette.bg) .padding(connection == nil ? 0 : 3) .background(frameColor) .font(Fonts.font(13)) .frame(minWidth: 640, minHeight: 400) } var body: some View { framed .toolbar { ToolbarItem(placement: .principal) { ServerPicker() } } .toolbar(removing: .title) .focusedSceneValue(\.serverWindow, focus) .onChange(of: Servers.shared.origins) { old, new in adopt(new.filter { !old.contains($0) }) } .onAppear(perform: appeared) } private func appeared() { let openWindow = openWindow Servers.shared.openWindow = { openWindow(id: "main") } Notifier.shared.deliver() } private func adopt(_ candidates: [String]) { guard Servers.shared.current == nil, form.serverText.isEmpty, !form.connecting, let next = candidates.first else { return } Servers.shared.current = next } private func forget() { guard let current = Servers.shared.current else { return } form.serverText = current Servers.shared.current = nil Servers.shared.remove(current) } } struct ServerPicker: View { var body: some View { let servers = Servers.shared Menu { ForEach(servers.origins, id: \.self) { origin in Button(serverName(origin)) { servers.current = origin } } Divider() Button("Add Server…") { servers.current = nil } } label: { HStack(spacing: 6) { if let current = servers.current { Circle() .fill(servers.color(current)) .frame(width: 8, height: 8) Text(serverName(current)) } else { Text("Add Server") } } .font(Fonts.font(13)) } } }