Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
344 changes: 305 additions & 39 deletions Sources/CodexBar/AppNotifications.swift

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Sources/CodexBar/CodexbarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationDidFinishLaunching(_ notification: Notification) {
AppNotifications.shared.requestAuthorizationOnStartup()
AppNotifications.shared.requestAuthorizationOnStartup(
notificationsEnabled: self.settings?.notificationsEnabled ?? true)
self.ensureStatusController()
KeyboardShortcuts.onKeyUp(for: .openMenu) { [weak self] in
Task { @MainActor [weak self] in
Expand Down
247 changes: 247 additions & 0 deletions Sources/CodexBar/NotificationSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import AppKit
import Foundation

struct NotificationDeliverySettings: Equatable, Sendable {
var enabled: Bool
var sound: NotificationSoundOption
var hookCallURL: String
var shortcutName: String

static let localDefault = NotificationDeliverySettings(
enabled: true,
sound: .systemDefault,
hookCallURL: "",
shortcutName: "")

var normalized: NotificationDeliverySettings {
NotificationDeliverySettings(
enabled: self.enabled,
sound: self.sound,
hookCallURL: self.hookCallURL.trimmingCharacters(in: .whitespacesAndNewlines),
shortcutName: self.shortcutName.trimmingCharacters(in: .whitespacesAndNewlines))
}
}

enum NotificationSoundOption: String, CaseIterable, Identifiable, Sendable {
case none
case systemDefault
case basso
case blow
case bottle
case frog
case funk
case glass
case hero
case morse
case ping
case pop
case purr
case sosumi
case submarine
case tink

var id: String {
self.rawValue
}

var label: String {
switch self {
case .none:
"None"
case .systemDefault:
"System Default"
case .basso:
"Basso"
case .blow:
"Blow"
case .bottle:
"Bottle"
case .frog:
"Frog"
case .funk:
"Funk"
case .glass:
"Glass"
case .hero:
"Hero"
case .morse:
"Morse"
case .ping:
"Ping"
case .pop:
"Pop"
case .purr:
"Purr"
case .sosumi:
"Sosumi"
case .submarine:
"Submarine"
case .tink:
"Tink"
}
}

var systemSoundName: String? {
switch self {
case .none, .systemDefault:
nil
case .basso:
"Basso"
case .blow:
"Blow"
case .bottle:
"Bottle"
case .frog:
"Frog"
case .funk:
"Funk"
case .glass:
"Glass"
case .hero:
"Hero"
case .morse:
"Morse"
case .ping:
"Ping"
case .pop:
"Pop"
case .purr:
"Purr"
case .sosumi:
"Sosumi"
case .submarine:
"Submarine"
case .tink:
"Tink"
}
}
}

@MainActor
enum NotificationSoundPlayer {
@discardableResult
static func playPreview(_ sound: NotificationSoundOption, volume: Double) -> Bool {
switch sound {
case .none:
return false
case .systemDefault:
NSSound.beep()
return true
default:
return self.play(sound, volume: volume)
}
}

@discardableResult
static func play(_ sound: NotificationSoundOption, volume: Double = 1.0) -> Bool {
guard let name = sound.systemSoundName else { return false }
guard let sound = NSSound(named: NSSound.Name(name)) else { return false }
sound.stop()
sound.volume = Float(min(max(volume, 0.0), 1.0))
return sound.play()
}
}

enum AppNotificationEvent: String, CaseIterable, Identifiable, Sendable {
case sessionQuotaDepleted
case sessionQuotaRestored
case providerLogin
case augmentSessionExpired

var id: String {
self.rawValue
}

var settingsTitle: String {
switch self {
case .sessionQuotaDepleted:
"Session quota depleted"
case .sessionQuotaRestored:
"Session quota restored"
case .providerLogin:
"Provider login successful"
case .augmentSessionExpired:
"Augment session expired"
}
}

var settingsSubtitle: String {
switch self {
case .sessionQuotaDepleted:
"When a tracked provider hits 0% remaining in the current 5-hour session."
case .sessionQuotaRestored:
"When a tracked provider becomes available again after a depleted session."
case .providerLogin:
"After a provider login flow launched from CodexBar completes successfully."
case .augmentSessionExpired:
"When Augment recovery still needs a manual browser login."
}
}

var hookPlaceholder: String {
switch self {
case .sessionQuotaDepleted:
"https://example.com/hooks/session-depleted"
case .sessionQuotaRestored:
"https://example.com/hooks/session-restored"
case .providerLogin:
"https://example.com/hooks/provider-login"
case .augmentSessionExpired:
"https://example.com/hooks/augment-expired"
}
}

var recommendedShortcutName: String {
switch self {
case .sessionQuotaDepleted:
"CodexBar Session Quota Depleted"
case .sessionQuotaRestored:
"CodexBar Session Quota Restored"
case .providerLogin:
"CodexBar Provider Login"
case .augmentSessionExpired:
"CodexBar Augment Session Expired"
}
}

var shortcutPlaceholder: String {
self.recommendedShortcutName
}

var defaultSound: NotificationSoundOption {
switch self {
case .sessionQuotaDepleted:
.basso
case .sessionQuotaRestored:
.glass
case .providerLogin:
.hero
case .augmentSessionExpired:
.submarine
}
}

var defaultSettings: NotificationDeliverySettings {
NotificationDeliverySettings(
enabled: true,
sound: self.defaultSound,
hookCallURL: "",
shortcutName: "")
}

var enabledDefaultsKey: String {
"notification.\(self.rawValue).enabled"
}

var soundDefaultsKey: String {
"notification.\(self.rawValue).sound"
}

var hookCallURLDefaultsKey: String {
"notification.\(self.rawValue).hookCallURL"
}

var shortcutNameDefaultsKey: String {
"notification.\(self.rawValue).shortcutName"
}
}
72 changes: 72 additions & 0 deletions Sources/CodexBar/PreferencesComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,78 @@ struct PreferenceToggleRow: View {
}
}

@MainActor
struct NotificationSettingsRow: View {
let title: String
let subtitle: String
let hookPlaceholder: String
let shortcutPlaceholder: String
let globalEnabled: Bool
let onSoundChange: @MainActor (NotificationSoundOption) -> Void
@Binding var isEnabled: Bool
@Binding var sound: NotificationSoundOption
@Binding var hookCallURL: String
@Binding var shortcutName: String

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Toggle(isOn: self.$isEnabled) {
Text(self.title)
.font(.body)
}
.toggleStyle(.checkbox)

Text(self.subtitle)
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)

VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text("Sound")
.font(.footnote)
.foregroundStyle(.secondary)
Picker("Sound", selection: self.$sound) {
ForEach(NotificationSoundOption.allCases) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.menu)
.labelsHidden()
}

VStack(alignment: .leading, spacing: 4) {
Text("Hook URL")
.font(.footnote)
.foregroundStyle(.secondary)
TextField(self.hookPlaceholder, text: self.$hookCallURL)
.textFieldStyle(.roundedBorder)
Text("Use {provider} anywhere in the URL to insert the provider name.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}

VStack(alignment: .leading, spacing: 4) {
Text("Shortcut")
.font(.footnote)
.foregroundStyle(.secondary)
TextField(self.shortcutPlaceholder, text: self.$shortcutName)
.textFieldStyle(.roundedBorder)
Text("Shortcuts receive JSON input with a provider field, for example {\"provider\":\"Codex\"}.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
.disabled(!self.globalEnabled || !self.isEnabled)
}
.onChange(of: self.sound) { _, newValue in
self.onSoundChange(newValue)
}
}
}

@MainActor
struct SettingsSection<Content: View>: View {
let title: String?
Expand Down
Loading