451 lines
14 KiB
Swift
451 lines
14 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 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 let connection = connections[origin.absoluteString], case .failed = connection.phase {
|
|
connections[origin.absoluteString] = nil
|
|
}
|
|
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 openAll() {
|
|
if origins.isEmpty {
|
|
openWindow?(nil)
|
|
}
|
|
for origin in origins {
|
|
openWindow?(origin)
|
|
}
|
|
}
|
|
}
|
|
|
|
@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 {
|
|
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
|
|
}
|
|
Task {
|
|
await store.startLeadStream()
|
|
}
|
|
phase = .running(store)
|
|
store.startGlobalStream(after: status.lastEventSeq)
|
|
store.remote.start()
|
|
Notifier.shared.deliver()
|
|
} catch {
|
|
await store.stop()
|
|
if !stopped {
|
|
phase = .failed("byre failed to start: \(errorText(error))")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class ConnectForm {
|
|
var serverText = ""
|
|
var problem: String?
|
|
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
|
|
do {
|
|
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 {
|
|
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)
|
|
}
|
|
}
|
|
.frame(width: 460)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
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 {
|
|
@Binding var origin: String?
|
|
@ViewState private var overlays = Overlays()
|
|
@ViewState private var form = ConnectForm()
|
|
@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(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")
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func moved(_ old: String?, _ new: String?) {
|
|
if let old {
|
|
Servers.shared.shown.remove(old)
|
|
}
|
|
if let new {
|
|
Servers.shared.shown.insert(new)
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|