2026-10-17 10:13:26 -07:00
import CoreData
2026-10-16 22:46:54 -07:00
import Foundation
import Observation
2026-10-17 10:13:26 -07:00
import SwiftData
2026-10-16 22:46:54 -07:00
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 () {
2026-10-17 10:13:26 -07:00
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 ()
}
2026-10-16 22:46:54 -07:00
}
}
}
2026-10-17 10:13:26 -07:00
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 )]))
}
2026-10-16 22:46:54 -07:00
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
2026-10-17 10:13:26 -07:00
for server in Self . servers () {
persistence . delete ( server )
}
persistence . insert ( ServerRecord ( origin : origin . absoluteString ))
saveRecords ()
2026-10-16 22:46:54 -07:00
serverText = origin . absoluteString
phase = . starting
await start ( origin )
}
2026-10-18 16:14:03 -07:00
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 ()
}
}
2026-10-16 22:46:54 -07:00
func changeServer () {
generation += 1
2026-10-16 23:30:57 -07:00
if let store {
Task {
await store . stop ()
}
}
2026-10-16 22:46:54 -07:00
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 )
2026-10-16 23:30:57 -07:00
store . loadDrafts ()
2026-10-16 22:46:54 -07:00
try await store . loadFocus ()
guard gen == generation else {
2026-10-16 23:30:57 -07:00
await store . stop ()
2026-10-16 22:46:54 -07:00
return
}
Task {
await store . startLeadStream ()
}
phase = . running ( store )
store . startGlobalStream ( after : status . lastEventSeq )
2026-10-17 23:45:31 -07:00
store . remote . start ()
2026-10-17 08:24:22 -07:00
Notifier . shared . deliver ()
2026-10-16 22:46:54 -07:00
} catch {
2026-10-16 23:30:57 -07:00
await store . stop ()
2026-10-16 22:46:54 -07:00
if gen == generation {
phase = . failed ( "byre failed to start: \( errorText ( error )) " )
}
}
}
}
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 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 )
2026-10-18 16:14:03 -07:00
HStack ( spacing : 10 ) {
Button ( "Connect" , action : connect )
Button ( "Run byre on this Mac" , action : installHere )
}
. disabled ( connection . connecting )
2026-10-16 22:46:54 -07:00
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 ()
}
}
2026-10-18 16:14:03 -07:00
private func installHere () {
Task {
await connection . installHere ()
}
}
2026-10-16 22:46:54 -07:00
}
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
2026-10-17 08:24:22 -07:00
@ Environment ( \ . openWindow ) private var openWindow
2026-10-16 22:46:54 -07:00
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 ):
2026-10-16 23:30:57 -07:00
MainView ()
2026-10-16 22:46:54 -07:00
. environment ( store )
}
}
. foregroundStyle ( Palette . text )
2026-10-16 23:30:57 -07:00
. tint ( Palette . accent )
2026-10-16 22:46:54 -07:00
. background ( Palette . bg )
2026-10-17 08:24:22 -07:00
. onAppear {
let openWindow = openWindow
Notifier . shared . connection = connection
Notifier . shared . showWindow = {
openWindow ( id : "main" )
}
Notifier . shared . deliver ()
}
2026-10-16 22:46:54 -07:00
}
}