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] = [:] @ObservationIgnored var shown: Set = [] @ObservationIgnored var windows: [String: WindowSlot] = [:] @ObservationIgnored var launched = false @ObservationIgnored var openWindow: ((String?) -> Void)? private init() { load() 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 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 arrange(_ group: NSWindowTabGroup?) { guard let group else { return } let selected = group.selectedWindow let ordered = origins.compactMap { windows[$0]?.window }.filter { group.windows.contains($0) } for (i, window) in ordered.enumerated() where group.windows[i] !== window { group.insertWindow(window, at: i) } group.selectedWindow = selected } func openAll() { if origins.isEmpty { openWindow?(nil) } for origin in origins { openWindow?(origin) } } } @MainActor final class WindowSlot { weak var window: NSWindow? } @MainActor @Observable final class Connection { enum Phase { case starting case failed(String) case running(Store) } let origin: URL 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 origin: String? let store: Store? let overlays: Overlays let forget: (() -> Void)? } extension FocusedValues { @Entry var serverWindow: ServerFocus? } struct ServerWindow: View { @Binding var origin: String? @ViewState private var overlays = Overlays() @ViewState private var form = ConnectForm() @ViewState private var slot = WindowSlot() @Environment(\.openWindow) private var openWindow @Environment(\.dismiss) private var dismissWindow private var connection: Connection? { origin.flatMap { Servers.shared.connections[$0] } } private var focus: ServerFocus { var forgetting: (() -> Void)? if connection != nil { forgetting = forget } return ServerFocus(origin: connection?.origin.absoluteString, store: connection?.store, overlays: overlays, forget: forgetting) } 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) } } else { ConnectView(form: form) { connected in let key = connected.absoluteString if Servers.shared.shown.contains(key) { openWindow(id: "main", value: key) dismissWindow() return } origin = key } } } private var framed: some View { content .foregroundStyle(Palette.text) .tint(Palette.accent) .background(Palette.bg) .padding(connection == nil ? 0 : 3) .background(frameColor) .environment(overlays) .font(Fonts.font(13)) .frame(minWidth: 640, minHeight: 400) } var body: some View { framed .navigationTitle(connection?.origin.host() ?? "byre") .background { WindowReader(found: joinTabs) } .focusedSceneValue(\.serverWindow, focus) .onChange(of: origin, initial: true) { old, new in moved(old, new) } .onChange(of: Servers.shared.origins) { old, new in adopt(new.filter { !old.contains($0) }) } .onAppear(perform: appeared) .onDisappear { if let origin { Servers.shared.shown.remove(origin) release(origin) } } } private func moved(_ old: String?, _ new: String?) { let servers = Servers.shared if let old { servers.shown.remove(old) release(old) } if let new { servers.shown.insert(new) servers.windows[new] = slot } servers.arrange(slot.window?.tabGroup) } private func release(_ origin: String) { if Servers.shared.windows[origin] === slot { Servers.shared.windows[origin] = nil } } private func appeared() { let servers = Servers.shared let openWindow = openWindow servers.openWindow = { origin in if let origin { openWindow(id: "main", value: origin) } else { openWindow(id: "main") } } if !servers.launched { servers.launched = true adopt(servers.origins) for o in servers.origins where !servers.shown.contains(o) { openWindow(id: "main", value: o) } } Notifier.shared.deliver() } private func joinTabs(_ window: NSWindow) { window.tabbingMode = .preferred slot.window = window DispatchQueue.main.async { let host = NSApp.windows.first { $0 !== window && $0.isVisible && $0.tabbingIdentifier == window.tabbingIdentifier } if let host, !(host.tabbedWindows ?? []).contains(window) { host.addTabbedWindow(window, ordered: .above) } Servers.shared.arrange(window.tabGroup) } } private func adopt(_ candidates: [String]) { guard origin == nil, form.serverText.isEmpty, !form.connecting, let next = candidates.first(where: { !Servers.shared.shown.contains($0) }) else { return } origin = next Servers.shared.shown.insert(next) } private func forget() { guard let current = origin else { return } form.serverText = current origin = nil Servers.shared.remove(current) } }