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 device you use.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) ServerColorPicker() heading("Font size") Text("The size of body text and of code, on every device you use. Other text grows and shrinks with them.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) FontSizeStepper(label: "Text", size: FontSize.shared.body) { n in FontSize.shared.set(body: n, code: FontSize.shared.code) } FontSizeStepper(label: "Code", size: FontSize.shared.code) { n in FontSize.shared.set(body: FontSize.shared.body, code: n) } 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("Saved commands") Text("Each is a button in the Terminal tab of every task on this server. It opens a new shell in the task's worktree and runs the command there.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) SavedCommands() heading("iCloud sync") SyncState() #if os(macOS) 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) #endif } .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 { let label: String let size: Int let set: (Int) -> Void var body: some View { HStack(spacing: 8) { Text(label) .frame(width: 48, alignment: .leading) Button("−") { set(size - 1) } .buttonStyle(WebButtonStyle()) .disabled(size <= FontSize.range.lowerBound) Text("\(size) pt") .monospacedDigit() Button("+") { set(size + 1) } .buttonStyle(WebButtonStyle()) .disabled(size >= 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 \(deviceKind) shares its servers, window colors, font sizes, drafts and task visits through iCloud. Last synced \(ago(sync.at)).") .fixedSize(horizontal: false, vertical: true) } else { Text("iCloud sync is failing on this \(deviceKind), so changes made here aren't reaching your other devices. 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(touchOnly ? "Notifications are blocked for byre in Settings." : "Notifications are blocked for byre in System Settings.").foregroundStyle(Palette.muted) case .authorized, .provisional: Text(touchOnly ? "This iPhone shows notifications while byre is open." : "This Mac shows notifications while byre is running.") default: VStack(alignment: .leading, spacing: 8) { Text("This \(deviceKind) doesn't show notifications from byre.") Button("Turn on for this \(deviceKind)") { 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: appBecameActive)) { _ 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 SavedCommands: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var list: [SavedCommand] = [] @ViewState private var editing: String? @ViewState private var name = "" @ViewState private var command = "" @ViewState private var off: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 0) { ForEach(list, id: \.id) { c in if editing == c.id { form(c.id) } else { HStack(alignment: .firstTextBaseline, spacing: 12) { VStack(alignment: .leading, spacing: 2) { Text(c.name) Text(c.command) .font(Fonts.font(12, mono: true)) .foregroundStyle(Palette.muted) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) Button("Edit") { editing = c.id name = c.name command = c.command } .buttonStyle(SmallLinkButtonStyle()) Button("Delete") { delete(c) } .buttonStyle(SmallLinkButtonStyle(color: Palette.danger)) } .padding(.vertical, 6) .overlay(alignment: .top) { Rectangle().fill(Palette.border).frame(height: 1) } } } if editing == nil { form(nil) } } .padding(.top, 8) .onAppear { off = store.events.add { ev in if ev.kind == "command.saved" || ev.kind == "command.deleted" { load() } } load() } .onDisappear { off?() off = nil } } private func form(_ id: String?) -> some View { let valid = !name.trimmingCharacters(in: .whitespaces).isEmpty && !command.trimmingCharacters(in: .whitespaces).isEmpty return HStack(spacing: 6) { TextField("Name", text: $name) .modifier(FieldStyle()) .frame(width: touchOnly ? 100 : 180) .onSubmit { save(id, valid) } TextField("Command", text: $command) .font(Fonts.font(13, mono: true)) .modifier(FieldStyle()) .onSubmit { save(id, valid) } Button(id == nil ? "Add" : "Save") { save(id, valid) } .buttonStyle(WebButtonStyle()) .disabled(!valid) if id != nil { Button("Cancel") { editing = nil name = "" command = "" } .buttonStyle(WebButtonStyle()) } } .padding(.vertical, 6) } private func load() { Task { do { list = try await store.api.client.listSavedCommands().ok.body.json } catch { overlays.showAlert(errorText(error)) } } } private func save(_ id: String?, _ valid: Bool) { if !valid { return } let body = Components.Schemas.SavedCommandSave(name: name, command: command) Task { do { if let id { _ = try await store.api.client.updateSavedCommand(path: .init(commandId: id), body: .json(body)).ok.body.json } else { _ = try await store.api.client.createSavedCommand(body: .json(body)).created.body.json } editing = nil name = "" command = "" } catch { overlays.showAlert(errorText(error)) } } } private func delete(_ c: SavedCommand) { Task { do { _ = try await store.api.client.deleteSavedCommand(path: .init(commandId: c.id)).noContent } catch { overlays.showAlert(errorText(error)) } } } } 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(Fonts.font(9, weight: 600)) } .foregroundStyle(Palette.text) .modifier(FieldStyle()) .contentShape(Rectangle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() .help(help) .padding(.top, 8) } }