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) { #if os(iOS) heading("This iPhone's name") Text("byre shows this iPhone by this name, and names its tailnet node after it. A new name takes effect the next time byre opens.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) DeviceNameField() #endif 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("Action button workflow") Text("The iPhone's Action button, set to byre's Run workflow shortcut, opens a page to type a prompt and start this workflow with it.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) QuickRunSetting() 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 and read its protected folders. 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) } } #if os(iOS) private struct DeviceNameField: View { @ViewState private var name = UserDefaults.standard.string(forKey: deviceNameKey) ?? deviceKind @ViewState private var saved = UserDefaults.standard.string(forKey: deviceNameKey) ?? deviceKind var body: some View { let trimmed = name.trimmingCharacters(in: .whitespaces) let valid = !slug(trimmed).isEmpty && trimmed != saved HStack(spacing: 6) { TextField("Name", text: $name) .modifier(FieldStyle()) .onSubmit { save(trimmed, valid) } Button("Save") { save(trimmed, valid) } .buttonStyle(WebButtonStyle()) .disabled(!valid) } .padding(.top, 8) } private func save(_ trimmed: String, _ valid: Bool) { if !valid { return } UserDefaults.standard.set(trimmed, forKey: deviceNameKey) saved = trimmed name = trimmed } } #endif 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) } }