Files
byre/mac/Sources/Byre/Startup.swift
T
2026-10-18 22:17:54 -07:00

532 lines
16 KiB
Swift

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<String> = []
@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<ServerRecord>(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<ServerRecord>()) where parseOrigin(record.origin)?.absoluteString == origin {
persistence.delete(record)
}
saveRecords()
load()
}
func setColor(_ origin: String, _ color: Int) {
for record in fetchRecords(FetchDescriptor<ServerRecord>()) 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<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 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)
}
}