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
2026-10-18 20:58:56 -07:00
final class Servers {
static let shared = Servers ()
2026-10-16 22:46:54 -07:00
2026-10-18 20:58:56 -07:00
private ( set ) var entries : [( origin : String , record : ServerRecord )] = []
private ( set ) var connections : [ String : Connection ] = [:]
2026-10-18 23:10:31 -07:00
var current : String ?
@ ObservationIgnored var openWindow : (() -> Void )?
2026-10-16 22:46:54 -07:00
2026-10-18 20:58:56 -07:00
private init () {
load ()
2026-10-18 23:10:31 -07:00
current = origins . first
2026-10-17 10:13:26 -07:00
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 {
2026-10-18 20:58:56 -07:00
for connection in connections . values {
connection . store ?. drafts ?. reload ()
}
load ()
2026-10-17 10:13:26 -07:00
}
2026-10-16 22:46:54 -07:00
}
}
}
2026-10-18 20:58:56 -07:00
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 ))
}
2026-10-17 10:13:26 -07:00
}
2026-10-18 20:58:56 -07:00
entries = loaded
2026-10-18 23:10:31 -07:00
if let current , record ( current ) == nil {
self . current = origins . first
}
2026-10-18 20:58:56 -07:00
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 ()
}
2026-10-17 10:13:26 -07:00
}
}
2026-10-18 20:58:56 -07:00
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 ()
}
2026-10-18 21:40:27 -07:00
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 ]
}
2026-10-18 23:10:31 -07:00
func show ( _ origin : String ) {
if record ( origin ) != nil {
current = origin
2026-10-18 20:58:56 -07:00
}
2026-10-18 23:10:31 -07:00
openWindow ?()
2026-10-18 20:58:56 -07:00
}
}
2026-10-18 23:10:31 -07:00
func serverName ( _ origin : String ) -> String {
let host = URL ( string : origin )?. host () ?? origin
return host . split ( separator : "." ). first . map ( String . init ) ?? host
2026-10-18 21:55:53 -07:00
}
2026-10-18 20:58:56 -07:00
@ MainActor
@ Observable
final class Connection {
enum Phase {
case starting
case failed ( String )
case running ( Store )
}
let origin : URL
2026-10-18 23:10:31 -07:00
let overlays = Overlays ()
2026-10-18 20:58:56 -07:00
var phase : Phase = . starting
@ ObservationIgnored private var stopped = false
2026-10-19 09:51:42 -07:00
@ ObservationIgnored private var restarting = false
2026-10-18 20:58:56 -07:00
init ( origin : URL ) {
self . origin = origin
Task {
await start ()
}
2026-10-17 10:13:26 -07:00
}
2026-10-16 22:46:54 -07:00
var store : Store ? {
if case . running ( let store ) = phase {
return store
}
return nil
}
2026-10-18 20:58:56 -07:00
func stop () async {
stopped = true
if let store {
await store . stop ()
}
}
private func start () async {
2026-10-19 09:51:42 -07:00
let old = store
while ! stopped , ! ( await attempt ( old )) {
2026-10-18 21:54:54 -07:00
try ? await Task . sleep ( for : . seconds ( 2 ))
}
2026-10-19 09:51:42 -07:00
if let old {
await old . stop ()
}
}
private func reconnected ( _ startedAt : Date ) {
guard ! restarting else {
return
}
Task {
guard let status = try ? await API ( origin : origin ). client . getStatus (). ok . body . json , status . startedAt != startedAt , ! stopped , ! restarting else {
return
}
log . notice ( " \( self . origin . absoluteString , privacy : . public ) restarted, reloading" )
restarting = true
await start ()
restarting = false
}
2026-10-18 21:54:54 -07:00
}
2026-10-19 09:51:42 -07:00
private func attempt ( _ old : Store ?) async -> Bool {
let store = Store ( api : API ( origin : origin ), images : old ?. images ?? ImageDrafts ())
2026-10-18 20:58:56 -07:00
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 )
try await store . loadFocus ()
guard ! stopped else {
await store . stop ()
2026-10-18 21:54:54 -07:00
return true
2026-10-18 20:58:56 -07:00
}
2026-10-19 09:51:42 -07:00
store . loadDrafts ( from : old )
2026-10-18 20:58:56 -07:00
Task {
await store . startLeadStream ()
}
phase = . running ( store )
2026-10-19 09:51:42 -07:00
store . startGlobalStream ( after : status . lastEventSeq ) { [ weak self ] in
self ?. reconnected ( status . startedAt )
}
2026-10-18 20:58:56 -07:00
store . remote . start ()
Notifier . shared . deliver ()
2026-10-18 21:54:54 -07:00
return true
2026-10-18 20:58:56 -07:00
} catch {
await store . stop ()
if ! stopped {
phase = . failed ( "byre failed to start: \( errorText ( error )) " )
}
2026-10-18 21:54:54 -07:00
return false
2026-10-18 20:58:56 -07:00
}
}
}
@ MainActor
@ Observable
final class ConnectForm {
var serverText = ""
var problem : String ?
2026-10-18 22:17:54 -07:00
var missing : [ Check ] = []
2026-10-18 20:58:56 -07:00
var connecting = false
func connect () async -> URL ? {
2026-10-16 22:46:54 -07:00
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."
2026-10-18 20:58:56 -07:00
return nil
2026-10-16 22:46:54 -07:00
}
connecting = true
problem = nil
do {
_ = try await API ( origin : origin ). client . getStatus (). ok
} catch {
2026-10-18 20:58:56 -07:00
connecting = false
problem = explain ( error , origin )
return nil
2026-10-16 22:46:54 -07:00
}
connecting = false
2026-10-18 20:58:56 -07:00
Servers . shared . add ( origin )
return origin
2026-10-16 22:46:54 -07:00
}
2026-10-18 20:58:56 -07:00
func installHere () async -> URL ? {
2026-10-18 16:14:03 -07:00
connecting = true
problem = nil
2026-10-18 22:17:54 -07:00
missing = []
2026-10-18 16:14:03 -07:00
do {
2026-10-18 22:17:54 -07:00
let checks = try await Server . checks ()
if ! checks . ready {
missing = checks . checks . filter { ! $0 . ok }
connecting = false
return nil
}
2026-10-18 16:14:03 -07:00
let origin = try await Server . turnOn ()
serverText = origin . absoluteString
try await waitForServer ( origin )
} catch {
2026-10-18 20:58:56 -07:00
connecting = false
problem = errorText ( error )
return nil
2026-10-18 16:14:03 -07:00
}
2026-10-18 20:58:56 -07:00
return await connect ()
2026-10-18 16:14:03 -07:00
}
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
}
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 {
2026-10-18 20:58:56 -07:00
@ Bindable var form : ConnectForm
let connected : ( URL ) -> Void
2026-10-16 22:46:54 -07:00
var body : some View {
2026-10-18 22:17:54 -07:00
GeometryReader { g in
ScrollView {
fields
. padding (. vertical , 24 )
. frame ( maxWidth : . infinity , minHeight : g . size . height )
}
}
}
private var fields : some View {
2026-10-16 22:46:54 -07:00
VStack ( alignment : . leading , spacing : 10 ) {
Text ( "Server URL" )
2026-10-18 20:58:56 -07:00
TextField ( "https://byre.example.ts.net" , text : $ form . serverText )
2026-10-16 22:46:54 -07:00
. 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 )
}
2026-10-18 20:58:56 -07:00
. disabled ( form . connecting )
if let problem = form . problem {
2026-10-16 22:46:54 -07:00
Text ( problem )
. foregroundStyle ( Palette . danger )
. textSelection (. enabled )
. fixedSize ( horizontal : false , vertical : true )
}
2026-10-18 22:17:54 -07:00
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 )
}
2026-10-16 22:46:54 -07:00
}
. frame ( width : 460 )
}
private func connect () {
Task {
2026-10-18 20:58:56 -07:00
if let origin = await form . connect () {
connected ( origin )
}
2026-10-16 22:46:54 -07:00
}
}
2026-10-18 16:14:03 -07:00
private func installHere () {
Task {
2026-10-18 20:58:56 -07:00
if let origin = await form . installHere () {
connected ( origin )
}
2026-10-18 16:14:03 -07:00
}
}
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 )
}
}
2026-10-18 20:58:56 -07:00
struct ServerFocus {
let store : Store ?
let overlays : Overlays
2026-10-18 23:10:31 -07:00
let forget : () -> Void
2026-10-18 20:58:56 -07:00
}
extension FocusedValues {
@ Entry var serverWindow : ServerFocus ?
}
struct ServerWindow : View {
@ ViewState private var form = ConnectForm ()
2026-10-17 08:24:22 -07:00
@ Environment ( \ . openWindow ) private var openWindow
2026-10-16 22:46:54 -07:00
2026-10-18 20:58:56 -07:00
private var connection : Connection ? {
2026-10-18 23:10:31 -07:00
Servers . shared . current . flatMap { Servers . shared . connections [ $0 ] }
2026-10-18 20:58:56 -07:00
}
2026-10-18 23:10:31 -07:00
private var focus : ServerFocus ? {
connection . map { ServerFocus ( store : $0 . store , overlays : $0 . overlays , forget : forget ) }
2026-10-18 20:58:56 -07:00
}
private var frameColor : Color {
connection . map { Servers . shared . color ( $0 . origin . absoluteString ) } ?? Palette . bg
}
@ ViewBuilder
private var content : some View {
if let connection {
2026-10-16 22:46:54 -07:00
switch connection . phase {
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 )
2026-10-18 23:10:31 -07:00
. environment ( connection . overlays )
2026-10-19 09:51:42 -07:00
. id ( ObjectIdentifier ( store ))
2026-10-16 22:46:54 -07:00
}
2026-10-18 20:58:56 -07:00
} else {
ConnectView ( form : form ) { connected in
2026-10-18 23:10:31 -07:00
form . serverText = ""
Servers . shared . current = connected . absoluteString
2026-10-18 20:58:56 -07:00
}
}
}
private var framed : some View {
content
. foregroundStyle ( Palette . text )
. tint ( Palette . accent )
. background ( Palette . bg )
2026-10-18 21:29:18 -07:00
. padding ( connection == nil ? 0 : 3 )
2026-10-18 20:58:56 -07:00
. background ( frameColor )
. font ( Fonts . font ( 13 ))
. frame ( minWidth : 640 , minHeight : 400 )
}
var body : some View {
framed
2026-10-18 23:10:31 -07:00
. toolbar {
ToolbarItem ( placement : . principal ) {
ServerPicker ()
}
2026-10-19 09:51:42 -07:00
. sharedBackgroundVisibility (. hidden )
2026-10-18 21:40:27 -07:00
}
2026-10-18 23:10:31 -07:00
. toolbar ( removing : . title )
2026-10-19 10:19:16 -07:00
. toolbarBackgroundVisibility (. visible , for : . windowToolbar )
2026-10-18 20:58:56 -07:00
. 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
2026-10-18 23:10:31 -07:00
Servers . shared . openWindow = {
openWindow ( id : "main" )
2026-10-18 20:58:56 -07:00
}
Notifier . shared . deliver ()
}
private func adopt ( _ candidates : [ String ]) {
2026-10-18 23:10:31 -07:00
guard Servers . shared . current == nil , form . serverText . isEmpty , ! form . connecting , let next = candidates . first else {
2026-10-18 20:58:56 -07:00
return
}
2026-10-18 23:10:31 -07:00
Servers . shared . current = next
2026-10-18 20:58:56 -07:00
}
private func forget () {
2026-10-18 23:10:31 -07:00
guard let current = Servers . shared . current else {
2026-10-18 20:58:56 -07:00
return
}
form . serverText = current
2026-10-18 23:10:31 -07:00
Servers . shared . current = nil
2026-10-18 20:58:56 -07:00
Servers . shared . remove ( current )
2026-10-16 22:46:54 -07:00
}
}
2026-10-18 23:10:31 -07:00
struct ServerPicker : View {
var body : some View {
let servers = Servers . shared
2026-10-19 09:06:08 -07:00
HStack ( spacing : 4 ) {
2026-10-18 23:10:31 -07:00
ForEach ( servers . origins , id : \ . self ) { origin in
2026-10-19 09:06:08 -07:00
ServerChip ( origin : origin )
2026-10-18 23:10:31 -07:00
}
2026-10-19 09:06:08 -07:00
Button ( "+" ) {
2026-10-18 23:10:31 -07:00
servers . current = nil
}
2026-10-19 09:06:08 -07:00
. buttonStyle (. plain )
. foregroundStyle ( servers . current == nil ? Palette . text : Palette . muted )
. padding (. horizontal , 8 )
. padding (. vertical , 3 )
. help ( "Add Server" )
}
. font ( Fonts . font ( 13 ))
2026-10-19 11:31:31 -07:00
. fixedSize ()
2026-10-19 09:06:08 -07:00
}
}
private struct ServerChip : View {
let origin : String
@ ViewState private var hover = false
var body : some View {
let servers = Servers . shared
let store = servers . connections [ origin ]?. store
let questions = store ?. inbox . count ?? 0
let summaries = store ?. unreadSummaries ?? 0
let current = servers . current == origin
Button {
servers . current = origin
2026-10-18 23:10:31 -07:00
} label : {
HStack ( spacing : 6 ) {
2026-10-19 09:06:08 -07:00
Circle ()
. fill ( store == nil ? . clear : servers . color ( origin ))
. stroke ( servers . color ( origin ))
. frame ( width : 8 , height : 8 )
Text ( serverName ( origin ))
2026-10-19 16:59:00 -07:00
. fontWeight ( current ? . bold : . regular )
2026-10-19 09:06:08 -07:00
if questions > 0 {
Badge ( count : questions )
. help ( "open questions" )
}
2026-10-19 09:51:42 -07:00
Text ( "99" )
. font ( Fonts . font ( 11 , weight : 700 ))
. hidden ()
. overlay ( alignment : . leading ) {
if summaries > 0 {
Text ( " \( summaries ) " )
. font ( Fonts . font ( 11 , weight : 700 ))
. foregroundStyle ( Palette . accent )
. fixedSize ()
. help ( "Turn summaries you haven't read" )
}
}
2026-10-18 23:10:31 -07:00
}
2026-10-19 09:06:08 -07:00
. foregroundStyle ( hover || current ? Palette . text : questions > 0 ? Palette . warn : Palette . muted )
. padding (. horizontal , 8 )
. padding (. vertical , 3 )
. background ( questions > 0 ? Palette . warnTint : current ? Palette . surface : . clear , in : RoundedRectangle ( cornerRadius : 6 ))
2026-10-19 16:59:00 -07:00
. overlay ( alignment : . bottom ) {
Rectangle ()
. fill ( current ? servers . color ( origin ) : . clear )
. frame ( height : 2 )
. padding (. horizontal , 8 )
}
2026-10-19 09:06:08 -07:00
. contentShape ( Rectangle ())
2026-10-18 23:10:31 -07:00
}
2026-10-19 09:06:08 -07:00
. buttonStyle (. plain )
. help ( store == nil ? " \( serverName ( origin )) : not connected" : serverName ( origin ))
. onHover { hover = $0 }
2026-10-18 23:10:31 -07:00
}
}