import ByreAPI import SwiftUI import UserNotifications typealias NotificationPrefs = Components.Schemas.NotificationPrefs @MainActor private let kinds: [(key: WritableKeyPath, label: String)] = [ (\.questions, "Questions"), (\.ready, "Ready to merge"), (\.discard, "Suggests discard"), (\.mergeProblems, "Merge problems"), (\.workflows, "Workflow finished"), (\.errors, "Session errors"), (\.rateLimits, "Rate limits"), (\.reports, "Turn summaries"), ] struct SettingsView: View { @Environment(Store.self) private var store var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { heading("Window color") Text("Windows for this server have a border in this color, on every Mac you use.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) ServerColorPicker() heading("Font size") Text("The size of body text, on every Mac you use. Other text grows and shrinks with it.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) FontSizeStepper() heading("Notifications") DeviceNotifications() PrefsBox() heading("Default model") Text("New tasks you start from byre run on this model.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) ModelMenu(current: store.defaultModel, help: "Model for new tasks") { m in try await store.api.client.updateDefaultModel(body: .json(.init(model: m))).ok.body.json } heading("iCloud sync") SyncState() heading("This Mac's permissions") Text("The macOS privacy grants byre's app needs for agents to see and control this Mac. Checking asks macOS for any that are missing.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) PermissionsCheck(run: checkLocalPermissions) } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 16) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Palette.bg) } private func heading(_ text: String) -> some View { Text(text) .font(Fonts.font(13, weight: 700)) .padding(.top, 16) .padding(.bottom, 6) } } 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 FontSizeStepper: View { var body: some View { let fonts = FontSize.shared HStack(spacing: 8) { Button("−") { fonts.set(fonts.body - 1) } .buttonStyle(WebButtonStyle()) .disabled(fonts.body <= FontSize.range.lowerBound) Text("\(fonts.body) pt") .monospacedDigit() Button("+") { fonts.set(fonts.body + 1) } .buttonStyle(WebButtonStyle()) .disabled(fonts.body >= FontSize.range.upperBound) } .padding(.top, 8) } } private struct SyncState: View { var body: some View { TimelineView(.periodic(from: .now, by: 30)) { _ in if let sync = Servers.shared.sync { if sync.ok { Text("This Mac shares its servers, window colors, font size, drafts and task visits through iCloud. Last synced \(ago(sync.at)).") .fixedSize(horizontal: false, vertical: true) } else { Text("iCloud sync is failing on this Mac, so changes made here aren't reaching your other Macs. Failed \(ago(sync.at)): \(sync.error ?? "")") .foregroundStyle(Palette.danger) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } } else { Text("No iCloud sync has finished since byre started.").foregroundStyle(Palette.muted) } } } } private struct DeviceNotifications: View { @ViewState private var status: UNAuthorizationStatus? @ViewState private var asking = false var body: some View { Group { switch status { case .none: EmptyView() case .denied: Text("Notifications are blocked for byre in System Settings.").foregroundStyle(Palette.muted) case .authorized, .provisional: Text("This Mac shows notifications while byre is running.") default: VStack(alignment: .leading, spacing: 8) { Text("This Mac doesn't show notifications from byre.") Button("Turn on for this Mac") { asking = true Task { _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) await read() asking = false } } .buttonStyle(WebButtonStyle()) .disabled(asking) } } } .padding(.bottom, 8) .task { await read() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in Task { await read() } } } private func read() async { status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus } } private struct PrefsBox: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays var body: some View { if let me = store.me { VStack(alignment: .leading, spacing: 4) { Text("Notify me about") .foregroundStyle(Palette.muted) .padding(.horizontal, 4) .background(Palette.bg) .offset(y: -18) .padding(.bottom, -18) ForEach(kinds, id: \.label) { kind in Toggle(kind.label, isOn: Binding(get: { me.notificationPrefs[keyPath: kind.key] }, set: { on in var prefs = me.notificationPrefs prefs[keyPath: kind.key] = on Task { do { store.me = try await store.api.client.updateNotificationPrefs(body: .json(prefs)).ok.body.json } catch { overlays.showAlert(errorText(error)) } } })) .toggleStyle(.checkbox) .tint(Palette.accent) } } .padding(.horizontal, 12) .padding(.top, 8) .padding(.bottom, 10) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 8).stroke(Palette.border)) .padding(.top, 16) } } } private struct ModelMenu: View { let current: String let help: String let pick: (String) async throws -> User @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays var body: some View { Menu { ForEach(models, id: \.self) { m in Button(m) { Task { do { store.me = try await pick(m) } catch { overlays.showAlert(errorText(error)) } } } } } label: { HStack(spacing: 8) { Text(current) Image(systemName: "chevron.up.chevron.down") .font(.system(size: 9, weight: .semibold)) } .foregroundStyle(Palette.text) .modifier(FieldStyle()) .contentShape(Rectangle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() .help(help) .padding(.top, 8) } }