Connect to several servers, one coloured window each
This commit is contained in:
@@ -9,7 +9,6 @@ typealias ViewState = State
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
var connection: Connection?
|
||||
private var quitting = false
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
@@ -21,8 +20,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
false
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
if flag {
|
||||
return true
|
||||
}
|
||||
Servers.shared.openAll()
|
||||
return false
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard let store = connection?.store, !quitting else {
|
||||
let stores = Servers.shared.connections.values.compactMap(\.store)
|
||||
guard !stores.isEmpty, !quitting else {
|
||||
return .terminateNow
|
||||
}
|
||||
quitting = true
|
||||
@@ -34,7 +42,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
}
|
||||
Task {
|
||||
await store.stop()
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for store in stores {
|
||||
group.addTask {
|
||||
await store.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
reply()
|
||||
}
|
||||
Task {
|
||||
@@ -48,49 +62,66 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
@main
|
||||
struct ByreApp: App {
|
||||
@NSApplicationDelegateAdaptor private var delegate: AppDelegate
|
||||
@ViewState private var connection = Connection()
|
||||
@ViewState private var overlays = Overlays()
|
||||
|
||||
var body: some Scene {
|
||||
Window("byre", id: "main") {
|
||||
RootView()
|
||||
.environment(connection)
|
||||
.environment(overlays)
|
||||
.font(Fonts.font(13))
|
||||
.frame(minWidth: 640, minHeight: 400)
|
||||
.onAppear {
|
||||
delegate.connection = connection
|
||||
}
|
||||
WindowGroup(id: "main", for: String.self) { $origin in
|
||||
ServerWindow(origin: $origin)
|
||||
}
|
||||
.defaultSize(width: 1280, height: 820)
|
||||
.restorationBehavior(.disabled)
|
||||
.defaultLaunchBehavior(.presented)
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates…") {
|
||||
Updater.shared.check()
|
||||
}
|
||||
Button("Change Server…") {
|
||||
connection.changeServer()
|
||||
ByreCommands()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ByreCommands: Commands {
|
||||
@FocusedValue(\.serverWindow) private var focus
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some Commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates…") {
|
||||
Updater.shared.check()
|
||||
}
|
||||
Button("Forget Server") {
|
||||
focus?.forget?()
|
||||
}
|
||||
.disabled(focus?.forget == nil)
|
||||
}
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Server Window") {
|
||||
openWindow(id: "main")
|
||||
}
|
||||
.keyboardShortcut("n")
|
||||
Menu("Open Server") {
|
||||
ForEach(Servers.shared.origins, id: \.self) { origin in
|
||||
Button(URL(string: origin)?.host() ?? origin) {
|
||||
openWindow(id: "main", value: origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
PasteboardCommands()
|
||||
CommandMenu("Go") {
|
||||
goButton("Switch Task", "k")
|
||||
goButton("Project", "p")
|
||||
goButton("Lead", "l")
|
||||
goButton("Merge", "s")
|
||||
Divider()
|
||||
goButton("Keyboard Shortcuts", "/")
|
||||
}
|
||||
.disabled(Servers.shared.origins.isEmpty)
|
||||
}
|
||||
PasteboardCommands()
|
||||
CommandMenu("Go") {
|
||||
goButton("Switch Task", "k")
|
||||
goButton("Project", "p")
|
||||
goButton("Lead", "l")
|
||||
goButton("Merge", "s")
|
||||
Divider()
|
||||
goButton("Keyboard Shortcuts", "/")
|
||||
}
|
||||
}
|
||||
|
||||
private func goButton(_ label: String, _ key: String) -> some View {
|
||||
Button(label) {
|
||||
if let store = connection.store {
|
||||
if let store = focus?.store, let overlays = focus?.overlays {
|
||||
runGo(key, store, overlays)
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(KeyEquivalent(Character(key)))
|
||||
.disabled(connection.store == nil || overlays.dialogOpen)
|
||||
.disabled(focus?.store == nil || focus?.overlays.dialogOpen == true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,10 @@ private let emptyText: [ChangeMode: String] = [
|
||||
.step: "This step changed nothing.",
|
||||
]
|
||||
|
||||
@MainActor
|
||||
private enum ChangesMemory {
|
||||
static var open: Set<String> = []
|
||||
static var range = ChangeMode.main
|
||||
static var pinRange = ChangeMode.step
|
||||
struct ChangesMemory {
|
||||
var open: Set<String> = []
|
||||
var range = ChangeMode.main
|
||||
var pinRange = ChangeMode.step
|
||||
}
|
||||
|
||||
func untilCancelled() async {
|
||||
@@ -43,9 +42,9 @@ final class ChangesModel {
|
||||
private(set) var changes: TaskChanges?
|
||||
private(set) var error = ""
|
||||
private(set) var diffs: [String: Result<String, ApiError>] = [:]
|
||||
private(set) var open = ChangesMemory.open
|
||||
private(set) var range = ChangesMemory.range
|
||||
private(set) var pinRange = ChangesMemory.pinRange
|
||||
private(set) var open: Set<String>
|
||||
private(set) var range: ChangeMode
|
||||
private(set) var pinRange: ChangeMode
|
||||
@ObservationIgnored private var loading = false
|
||||
@ObservationIgnored private var again = false
|
||||
@ObservationIgnored private var timer: Task<Void, Never>?
|
||||
@@ -56,6 +55,9 @@ final class ChangesModel {
|
||||
init(_ store: Store, compact: Bool) {
|
||||
self.store = store
|
||||
self.compact = compact
|
||||
open = store.changesMemory.open
|
||||
range = store.changesMemory.range
|
||||
pinRange = store.changesMemory.pinRange
|
||||
}
|
||||
|
||||
var pinned: Int? {
|
||||
@@ -161,7 +163,7 @@ final class ChangesModel {
|
||||
return
|
||||
}
|
||||
range = r
|
||||
ChangesMemory.range = r
|
||||
store.changesMemory.range = r
|
||||
retarget()
|
||||
}
|
||||
|
||||
@@ -170,7 +172,7 @@ final class ChangesModel {
|
||||
return
|
||||
}
|
||||
pinRange = r
|
||||
ChangesMemory.pinRange = r
|
||||
store.changesMemory.pinRange = r
|
||||
retarget()
|
||||
}
|
||||
|
||||
@@ -180,7 +182,7 @@ final class ChangesModel {
|
||||
} else {
|
||||
open.remove(path)
|
||||
}
|
||||
ChangesMemory.open = open
|
||||
store.changesMemory.open = open
|
||||
}
|
||||
|
||||
func shows(_ a: LineAnchor) -> Bool {
|
||||
|
||||
@@ -26,6 +26,7 @@ final class ChatModel {
|
||||
@ObservationIgnored private var focusStart = true
|
||||
@ObservationIgnored private var visibleSummaries: Set<Int64> = []
|
||||
@ObservationIgnored private var activeObserver: NotificationCenter.ObservationToken?
|
||||
@ObservationIgnored private var keyObserver: NotificationCenter.ObservationToken?
|
||||
@ObservationIgnored private var started = false
|
||||
|
||||
init(_ kind: ChatKind, _ store: Store) {
|
||||
@@ -150,6 +151,9 @@ final class ChatModel {
|
||||
activeObserver = NotificationCenter.default.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in
|
||||
self?.markShown()
|
||||
}
|
||||
keyObserver = NotificationCenter.default.addObserver(of: NSWindow.self, for: .didBecomeKey) { [weak self] _ in
|
||||
self?.markShown()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
@@ -161,6 +165,10 @@ final class ChatModel {
|
||||
NotificationCenter.default.removeObserver(activeObserver)
|
||||
}
|
||||
activeObserver = nil
|
||||
if let keyObserver {
|
||||
NotificationCenter.default.removeObserver(keyObserver)
|
||||
}
|
||||
keyObserver = nil
|
||||
unmergedTimer?.cancel()
|
||||
unmergedTimer = nil
|
||||
builder.stop()
|
||||
@@ -214,7 +222,7 @@ final class ChatModel {
|
||||
}
|
||||
|
||||
private func markShown() {
|
||||
guard let taskId, NSApp.isActive else {
|
||||
guard let taskId, store.paneFocus.windowKey else {
|
||||
return
|
||||
}
|
||||
for seq in visibleSummaries {
|
||||
|
||||
@@ -57,6 +57,7 @@ final class Overlays {
|
||||
@ObservationIgnored var pickerFrame: CGRect = .zero
|
||||
@ObservationIgnored var menuHover = false
|
||||
var paneFrame: CGRect = .zero
|
||||
@ObservationIgnored weak var window: NSWindow?
|
||||
@ObservationIgnored private var monitor: Any?
|
||||
@ObservationIgnored private var mouseMonitor: Any?
|
||||
|
||||
@@ -134,17 +135,22 @@ final class Overlays {
|
||||
return
|
||||
}
|
||||
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
let win = event.window.map(ObjectIdentifier.init)
|
||||
let code = event.keyCode
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
let chars = event.charactersIgnoringModifiers ?? ""
|
||||
let taken = MainActor.assumeIsolated {
|
||||
self?.key(code, flags, chars, ctrl) ?? false
|
||||
guard let self, let window = self.window, win == ObjectIdentifier(window) else {
|
||||
return false
|
||||
}
|
||||
return self.key(code, flags, chars, ctrl)
|
||||
}
|
||||
return taken ? nil : event
|
||||
}
|
||||
mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in
|
||||
let win = event.window.map(ObjectIdentifier.init)
|
||||
MainActor.assumeIsolated {
|
||||
if let self, self.menu != nil, !self.menuHover {
|
||||
if let self, let window = self.window, win == ObjectIdentifier(window), self.menu != nil, !self.menuHover {
|
||||
self.menu = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ func worktreeImageSource(_ path: String, _ taskId: String?, dir: String = "") ->
|
||||
|
||||
@MainActor
|
||||
final class ImageCache {
|
||||
static let shared = ImageCache()
|
||||
private var done: [ImageSource: NSImage] = [:]
|
||||
private var loading: [ImageSource: Task<NSImage, any Error>] = [:]
|
||||
|
||||
@@ -99,7 +98,7 @@ struct ByreImage: View {
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = image ?? ImageCache.shared.cached(source) {
|
||||
if let image = image ?? store.imageCache.cached(source) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
@@ -122,7 +121,7 @@ struct ByreImage: View {
|
||||
return
|
||||
}
|
||||
do {
|
||||
image = try await ImageCache.shared.load(source, store.api)
|
||||
image = try await store.imageCache.load(source, store.api)
|
||||
} catch {
|
||||
log.error("load image \(src, privacy: .public): \(errorText(error), privacy: .public)")
|
||||
failed = true
|
||||
|
||||
@@ -4,28 +4,26 @@ import UserNotifications
|
||||
@MainActor
|
||||
final class Notifier: NSObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = Notifier()
|
||||
weak var connection: Connection?
|
||||
var showWindow: (() -> Void)?
|
||||
private var pending: String?
|
||||
private var pending: (origin: String, taskId: String)?
|
||||
|
||||
func install() {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
private func open(_ taskId: String) {
|
||||
private func open(_ origin: String, _ taskId: String) {
|
||||
NSApp.activate()
|
||||
showWindow?()
|
||||
pending = taskId
|
||||
Servers.shared.openWindow?(origin)
|
||||
pending = (origin, taskId)
|
||||
deliver()
|
||||
}
|
||||
|
||||
func deliver() {
|
||||
guard let taskId = pending, let store = connection?.store else {
|
||||
guard let p = pending, let store = Servers.shared.connections[p.origin]?.store else {
|
||||
return
|
||||
}
|
||||
pending = nil
|
||||
if !taskId.isEmpty {
|
||||
store.focusTask(taskId)
|
||||
if !p.taskId.isEmpty {
|
||||
store.focusTask(p.taskId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +32,11 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
|
||||
let taskId = response.notification.request.content.userInfo["task_id"] as? String ?? ""
|
||||
let info = response.notification.request.content.userInfo
|
||||
let origin = info["origin"] as? String ?? ""
|
||||
let taskId = info["task_id"] as? String ?? ""
|
||||
await MainActor.run {
|
||||
open(taskId)
|
||||
open(origin, taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ private func show(_ store: Store, _ taskId: String, _ tag: String, _ text: Strin
|
||||
content.title = store.findTask(taskId).map { "byre · \($0.title)" } ?? "byre"
|
||||
content.body = line.count > 200 ? "\(line.prefix(199))…" : line
|
||||
content.sound = .default
|
||||
content.userInfo = ["task_id": taskId]
|
||||
content.userInfo = ["task_id": taskId, "origin": store.api.origin.absoluteString]
|
||||
let request = UNNotificationRequest(identifier: tag, content: content, trigger: nil)
|
||||
enqueue {
|
||||
let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
|
||||
@@ -92,7 +92,7 @@ func notifyError(_ store: Store, _ ev: ByreEvent) {
|
||||
if !(limited ? me.notificationPrefs.rateLimits : me.notificationPrefs.errors) {
|
||||
return
|
||||
}
|
||||
show(store, taskId, "error-\(ev.seq)", limited ? "Rate limited" : "Error: \(str(ev.payload.value, "error") ?? "")")
|
||||
show(store, taskId, "error-\(ev.seq)@\(store.api.origin.absoluteString)", limited ? "Rate limited" : "Error: \(str(ev.payload.value, "error") ?? "")")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -109,7 +109,7 @@ func clearInbox(_ item: InboxItem) {
|
||||
func clearTurns(_ taskId: String, through seq: Int64) {
|
||||
enqueue {
|
||||
let ids = await UNUserNotificationCenter.current().deliveredNotifications().map(\.request).filter { r in
|
||||
guard r.content.userInfo["task_id"] as? String == taskId, r.identifier.hasPrefix("turn-"), let s = Int64(r.identifier.dropFirst(5)) else {
|
||||
guard r.content.userInfo["task_id"] as? String == taskId, r.identifier.hasPrefix("turn-"), let s = Int64(r.identifier.dropFirst(5).prefix { $0 != "@" }) else {
|
||||
return false
|
||||
}
|
||||
return s <= seq
|
||||
@@ -123,5 +123,5 @@ func notifyTurn(_ store: Store, _ ev: ByreEvent) {
|
||||
guard let me = store.me, me.notificationPrefs.reports, ev.payload.value["summary"] as? Bool == true, let taskId = ev.taskId, !taskId.isEmpty else {
|
||||
return
|
||||
}
|
||||
show(store, taskId, "turn-\(ev.seq)", str(ev.payload.value, "result") ?? "")
|
||||
show(store, taskId, "turn-\(ev.seq)@\(store.api.origin.absoluteString)", str(ev.payload.value, "result") ?? "")
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ final class PaneFocus {
|
||||
window = nil
|
||||
}
|
||||
|
||||
var windowKey: Bool {
|
||||
(window?.isKeyWindow ?? false) && NSApp.isActive
|
||||
}
|
||||
|
||||
func setFrame(_ frame: CGRect) {
|
||||
paneFrame = frame
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ struct MainView: View {
|
||||
})
|
||||
.background {
|
||||
WindowReader { window in
|
||||
overlays.window = window
|
||||
store.paneFocus.attach(window)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import SwiftData
|
||||
final class ServerRecord {
|
||||
var origin = ""
|
||||
var saved = Date.distantPast
|
||||
var color = 0
|
||||
|
||||
init(origin: String) {
|
||||
init(origin: String, color: Int) {
|
||||
self.origin = origin
|
||||
self.color = color
|
||||
saved = Date()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,12 @@ private enum SnapshotPatches {
|
||||
static var patches: [String: Task<String?, any Error>] = [:]
|
||||
|
||||
static func patch(_ api: API, _ taskId: String, _ n: Int, _ path: String) async throws -> String? {
|
||||
let key = "\(taskId)/\(n)/\(path)"
|
||||
let key = "\(api.origin.absoluteString)/\(taskId)/\(n)/\(path)"
|
||||
if let cached = patches[key] {
|
||||
return try await cached.value
|
||||
}
|
||||
let fetched = Task<String?, any Error> {
|
||||
let ck = "\(taskId)/\(n)"
|
||||
let ck = "\(api.origin.absoluteString)/\(taskId)/\(n)"
|
||||
let pending = changes[ck] ?? Task {
|
||||
try await api.client.getTaskChanges(path: .init(taskId: taskId), query: .init(snapshot: n)).ok.body.json
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
heading("Window colour")
|
||||
Text("Windows for this server have a border in this colour, on every Mac you use.")
|
||||
.foregroundStyle(Palette.muted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
ServerColorPicker()
|
||||
heading("Notifications")
|
||||
DeviceNotifications()
|
||||
PrefsBox()
|
||||
@@ -53,6 +58,31 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ServerColorPicker: View {
|
||||
@Environment(Store.self) private var store
|
||||
|
||||
var body: some View {
|
||||
let origin = store.api.origin.absoluteString
|
||||
let current = Servers.shared.record(origin)?.color
|
||||
HStack(spacing: 4) {
|
||||
ForEach(taskPalette.indices, id: \.self) { i in
|
||||
Button {
|
||||
Servers.shared.setColor(origin, i)
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(taskPalette[i])
|
||||
.frame(width: 18, height: 18)
|
||||
.padding(3)
|
||||
.overlay(Circle().stroke(i == current ? Palette.text : .clear, lineWidth: 1.5))
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DeviceNotifications: View {
|
||||
@ViewState private var status: UNAuthorizationStatus?
|
||||
@ViewState private var asking = false
|
||||
|
||||
+285
-124
@@ -6,46 +6,116 @@ import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Connection {
|
||||
enum Phase {
|
||||
case connect
|
||||
case starting
|
||||
case failed(String)
|
||||
case running(Store)
|
||||
}
|
||||
final class Servers {
|
||||
static let shared = Servers()
|
||||
|
||||
var phase: Phase = .connect
|
||||
var serverText: String
|
||||
var problem: String?
|
||||
var connecting = false
|
||||
@ObservationIgnored private var generation = 0
|
||||
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)?
|
||||
|
||||
init() {
|
||||
serverText = ""
|
||||
resume()
|
||||
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 {
|
||||
store?.drafts?.reload()
|
||||
resume()
|
||||
for connection in connections.values {
|
||||
connection.store?.drafts?.reload()
|
||||
}
|
||||
load()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resume() {
|
||||
guard case .connect = phase, serverText.isEmpty, !connecting, let saved = Self.servers().first?.origin, let origin = parseOrigin(saved) else {
|
||||
return
|
||||
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))
|
||||
}
|
||||
}
|
||||
serverText = saved
|
||||
phase = .starting
|
||||
Task {
|
||||
await start(origin)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func servers() -> [ServerRecord] {
|
||||
fetchRecords(FetchDescriptor(sortBy: [SortDescriptor(\.saved, order: .reverse)]))
|
||||
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? {
|
||||
@@ -55,56 +125,84 @@ final class Connection {
|
||||
return nil
|
||||
}
|
||||
|
||||
func connect() async {
|
||||
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
|
||||
return nil
|
||||
}
|
||||
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
|
||||
problem = explain(error, origin)
|
||||
return nil
|
||||
}
|
||||
connecting = false
|
||||
for server in Self.servers() {
|
||||
persistence.delete(server)
|
||||
}
|
||||
persistence.insert(ServerRecord(origin: origin.absoluteString))
|
||||
saveRecords()
|
||||
serverText = origin.absoluteString
|
||||
phase = .starting
|
||||
await start(origin)
|
||||
Servers.shared.add(origin)
|
||||
return origin
|
||||
}
|
||||
|
||||
func installHere() async {
|
||||
func installHere() async -> URL? {
|
||||
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
|
||||
connecting = false
|
||||
problem = errorText(error)
|
||||
return nil
|
||||
}
|
||||
guard gen == generation else {
|
||||
return
|
||||
}
|
||||
await connect()
|
||||
return await connect()
|
||||
}
|
||||
|
||||
private func waitForServer(_ origin: URL) async throws {
|
||||
@@ -125,51 +223,6 @@ final class Connection {
|
||||
try await group.next()
|
||||
}
|
||||
}
|
||||
|
||||
func changeServer() {
|
||||
generation += 1
|
||||
if let store {
|
||||
Task {
|
||||
await store.stop()
|
||||
}
|
||||
}
|
||||
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)
|
||||
store.loadDrafts()
|
||||
try await store.loadFocus()
|
||||
guard gen == generation 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 gen == generation {
|
||||
phase = .failed("byre failed to start: \(errorText(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let tlsErrors: Set<URLError.Code> = [
|
||||
@@ -207,20 +260,21 @@ func explain(_ error: any Error, _ origin: URL) -> String {
|
||||
}
|
||||
|
||||
struct ConnectView: View {
|
||||
@Bindable var connection: Connection
|
||||
@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: $connection.serverText)
|
||||
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(connection.connecting)
|
||||
if let problem = connection.problem {
|
||||
.disabled(form.connecting)
|
||||
if let problem = form.problem {
|
||||
Text(problem)
|
||||
.foregroundStyle(Palette.danger)
|
||||
.textSelection(.enabled)
|
||||
@@ -233,13 +287,17 @@ struct ConnectView: View {
|
||||
|
||||
private func connect() {
|
||||
Task {
|
||||
await connection.connect()
|
||||
if let origin = await form.connect() {
|
||||
connected(origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func installHere() {
|
||||
Task {
|
||||
await connection.installHere()
|
||||
if let origin = await form.installHere() {
|
||||
connected(origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,15 +314,43 @@ struct FatalView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct RootView: View {
|
||||
@Environment(Connection.self) private var connection
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
struct ServerFocus {
|
||||
let store: Store?
|
||||
let overlays: Overlays
|
||||
let forget: (() -> Void)?
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
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 .connect:
|
||||
ConnectView(connection: connection)
|
||||
case .starting:
|
||||
Color.clear
|
||||
case .failed(let message):
|
||||
@@ -273,17 +359,92 @@ struct RootView: View {
|
||||
MainView()
|
||||
.environment(store)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Palette.text)
|
||||
.tint(Palette.accent)
|
||||
.background(Palette.bg)
|
||||
.onAppear {
|
||||
let openWindow = openWindow
|
||||
Notifier.shared.connection = connection
|
||||
Notifier.shared.showWindow = {
|
||||
openWindow(id: "main")
|
||||
} 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
|
||||
}
|
||||
Notifier.shared.deliver()
|
||||
}
|
||||
}
|
||||
|
||||
private var framed: some View {
|
||||
content
|
||||
.foregroundStyle(Palette.text)
|
||||
.tint(Palette.accent)
|
||||
.background(Palette.bg)
|
||||
.padding(connection == nil ? 0 : 4)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ final class Store {
|
||||
var pickedMembers: [String: String] = [:]
|
||||
private(set) var drafts: Drafts?
|
||||
let images = ImageDrafts()
|
||||
let imageCache = ImageCache()
|
||||
let events = Listeners<ByreEvent>()
|
||||
let paneFocus: PaneFocus
|
||||
let remote: Remote
|
||||
@@ -60,6 +61,7 @@ final class Store {
|
||||
@ObservationIgnored private var globalStream: EventStream?
|
||||
@ObservationIgnored private var leadStream: EventStream?
|
||||
@ObservationIgnored private var stopped = false
|
||||
@ObservationIgnored var changesMemory = ChangesMemory()
|
||||
|
||||
init(api: API) {
|
||||
self.api = api
|
||||
|
||||
Reference in New Issue
Block a user