Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2882485
Add external event hooks for quota and provider state changes
jychp Jul 9, 2026
d7130ae
Harden hooks per review: minimal env, scoped rate limiting, redacted …
jychp Jul 9, 2026
fa11d2d
Decouple quota hooks from notifications; skip refresh on hook edits
jychp Jul 9, 2026
247bcb7
Drive quota_low hooks by each rule's own usage threshold
jychp Jul 9, 2026
d410ddd
Merge remote-tracking branch 'origin/main' into feature/external-even…
jychp Jul 9, 2026
24d4e96
Send hooks a coarse refresh_failed status instead of raw error text
jychp Jul 9, 2026
a0104e0
Merge remote-tracking branch 'origin/main' into feature/external-even…
jychp Jul 13, 2026
d4faec8
Re-decouple quota_reached hook from notification preferences
jychp Jul 13, 2026
b14629b
Scope quota_low hook crossing history by account
jychp Jul 13, 2026
60748f0
Use a composite identity, not just email, for quota_low hook account …
jychp Jul 14, 2026
0937096
Merge remote-tracking branch 'origin/main' into feature/external-even…
jychp Jul 14, 2026
fda54a2
Merge remote-tracking branch 'origin/main' into feature/external-even…
steipete Jul 17, 2026
43cbcbc
fix: harden external event hooks
steipete Jul 17, 2026
1ae3e88
test: use neutral hook environment fixture
steipete Jul 17, 2026
875fe8f
fix: bound and stabilize hook execution
steipete Jul 17, 2026
ff6fb83
fix: invalidate stale quota hook state
steipete Jul 17, 2026
14fc1f3
Merge remote-tracking branch 'origin/main' into feature/external-even…
steipete Jul 17, 2026
9ba61a9
Merge remote-tracking branch 'origin/main' into feature/external-even…
steipete Jul 17, 2026
1e0cf75
fix: align hook test failure status
steipete Jul 17, 2026
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
4 changes: 3 additions & 1 deletion Sources/CodexBar/CodexbarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

private func ensureStatusController() {
if self.statusController != nil { return }
if self.statusController != nil {
return
}

if let store,
let settings,
Expand Down
196 changes: 196 additions & 0 deletions Sources/CodexBar/PreferencesHooksPane.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import CodexBarCore
import SwiftUI

@MainActor
struct HooksPane: View {
@Bindable var settings: SettingsStore

var body: some View {
Form {
Section {
Toggle(isOn: self.enabledBinding) {
SettingsRowLabel(L("hooks_enable_title"), subtitle: L("hooks_enable_subtitle"))
}
Label(L("hooks_trust_warning"), systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} header: {
Text(L("tab_hooks"))
}

Section {
if self.settings.hookRules.isEmpty {
Text(L("hooks_empty"))
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(self.settings.hookRules) { rule in
HookRuleRow(
rule: self.binding(for: rule),
onDelete: { self.settings.removeHookRule(id: rule.id) })
}
}

Button {
self.settings.addHookRule(HookRule(event: .quotaReached, executable: ""))
} label: {
Label(L("hooks_add_rule"), systemImage: "plus")
}
.disabled(!HookEditorValidation.canAddRule(count: self.settings.hookRules.count))
} header: {
Text(L("hooks_rules_header"))
}
}
.formStyle(.grouped)
}

private var enabledBinding: Binding<Bool> {
Binding(
get: { self.settings.hooksEnabled },
set: { self.settings.setHooksEnabled($0) })
}

private func binding(for rule: HookRule) -> Binding<HookRule> {
Binding(
get: { self.settings.hookRules.first(where: { $0.id == rule.id }) ?? rule },
set: { self.settings.updateHookRule($0) })
}
}

@MainActor
private struct HookRuleRow: View {
@Binding var rule: HookRule
let onDelete: () -> Void
@State private var argumentRows: [ArgumentRow]

init(rule: Binding<HookRule>, onDelete: @escaping () -> Void) {
self._rule = rule
self.onDelete = onDelete
self._argumentRows = State(initialValue: rule.wrappedValue.arguments.map(ArgumentRow.init(value:)))
}

var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Toggle(L("hooks_rule_enabled"), isOn: self.$rule.enabled)
.labelsHidden()
.toggleStyle(.switch)
.controlSize(.mini)

Picker(L("hooks_event"), selection: self.$rule.event) {
ForEach(HookEventType.allCases, id: \.self) { event in
Text(event.rawValue).tag(event)
}
}
.labelsHidden()

Picker(L("hooks_provider"), selection: self.providerBinding) {
Text(L("hooks_any_provider")).tag(String?.none)
ForEach(UsageProvider.allCases, id: \.self) { provider in
Text(ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName)
.tag(String?.some(provider.rawValue))
}
}
.labelsHidden()

Spacer()

Button(role: .destructive, action: self.onDelete) {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.accessibilityLabel(L("hooks_delete_rule"))
}

if self.rule.event == .quotaLow {
HStack {
Text(L("hooks_threshold"))
.foregroundStyle(.secondary)
TextField(L("hooks_threshold_placeholder"), value: self.thresholdPercentBinding, format: .number)
.frame(width: 60)
Text(verbatim: "%")
.foregroundStyle(.secondary)
}
.font(.caption)
}

TextField(L("hooks_executable_placeholder"), text: self.$rule.executable)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))

VStack(alignment: .leading, spacing: 4) {
HStack {
Text(L("hooks_arguments_placeholder"))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button {
self.argumentRows.append(ArgumentRow(value: ""))
} label: {
Label(L("hooks_add_argument"), systemImage: "plus")
}
.buttonStyle(.borderless)
.controlSize(.small)
.disabled(!HookEditorValidation.canAddArgument(count: self.argumentRows.count))
}

ForEach(self.$argumentRows) { $argument in
HStack {
TextField(L("hooks_argument_placeholder"), text: $argument.value)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
Button {
self.argumentRows.removeAll(where: { $0.id == argument.id })
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.borderless)
.accessibilityLabel(L("hooks_delete_argument"))
}
}
}
}
.padding(.vertical, 4)
.onChange(of: self.argumentRows.map(\.value)) { _, arguments in
if self.rule.arguments != arguments {
self.rule.arguments = arguments
}
}
.onChange(of: self.rule.arguments) { _, arguments in
if self.argumentRows.map(\.value) != arguments {
self.argumentRows = arguments.map(ArgumentRow.init(value:))
}
}
}

private var providerBinding: Binding<String?> {
Binding(get: { self.rule.provider }, set: { self.rule.provider = $0 })
}

/// Threshold stored as a 0...1 fraction, edited as a 0...100 percentage.
private var thresholdPercentBinding: Binding<Double?> {
Binding(
get: { self.rule.threshold.map { $0 * 100 } },
set: { self.rule.threshold = HookEditorValidation.thresholdFraction(percent: $0) })
}

private struct ArgumentRow: Identifiable {
let id = UUID()
var value: String
}
}

enum HookEditorValidation {
static func canAddRule(count: Int) -> Bool {
count < HooksConfig.maximumRuleCount
}

static func canAddArgument(count: Int) -> Bool {
count < HookRule.maximumArgumentCount
}

static func thresholdFraction(percent: Double?) -> Double? {
percent.map { min(max($0, 1), 100) / 100 }
}
}
2 changes: 2 additions & 0 deletions Sources/CodexBar/PreferencesSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ extension SettingsPane {
case .menuBar: "menuBar"
case .menu: "menu"
case .advanced: "advanced"
case .hooks: "hooks"
case .about: "about"
case .debug: "debug"
case let .provider(provider): "provider:\(provider.rawValue)"
Expand All @@ -26,6 +27,7 @@ extension SettingsPane {
case "display": self = .menuBar
case "menu": self = .menu
case "advanced": self = .advanced
case "hooks": self = .hooks
case "about": self = .about
case "debug": self = .debug
default:
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/PreferencesSidebar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ struct SettingsSidebarView: View {
SettingsSidebarPaneRow(pane: .menuBar, systemImage: "menubar.rectangle", color: .blue)
SettingsSidebarPaneRow(pane: .menu, systemImage: "filemenu.and.selection", color: .teal)
SettingsSidebarPaneRow(pane: .advanced, systemImage: "slider.horizontal.3", color: .purple)
SettingsSidebarPaneRow(pane: .hooks, systemImage: "bolt.horizontal.circle.fill", color: .orange)
SettingsSidebarAboutRow()
if self.settings.debugMenuEnabled {
SettingsSidebarPaneRow(pane: .debug, systemImage: "ladybug.fill", color: .red)
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ enum SettingsPane: Hashable {
case menuBar
case menu
case advanced
case hooks
case about
case debug
case provider(UsageProvider)
Expand All @@ -27,6 +28,7 @@ enum SettingsPane: Hashable {
case .menuBar: L("tab_menu_bar")
case .menu: L("tab_menu")
case .advanced: L("tab_advanced")
case .hooks: L("tab_hooks")
case .about: L("tab_about")
case .debug: L("tab_debug")
case let .provider(provider):
Expand Down Expand Up @@ -123,6 +125,8 @@ struct PreferencesView: View {
MenuPane(settings: self.settings, store: self.store)
case .advanced:
AdvancedPane(settings: self.settings, store: self.store)
case .hooks:
HooksPane(settings: self.settings)
case .about:
AboutPane(updater: self.updater)
case .debug:
Expand Down
21 changes: 21 additions & 0 deletions Sources/CodexBar/Resources/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,28 @@
"tab_menu_bar" = "شريط القوائم";
"tab_menu" = "القائمة";
"tab_advanced" = "متقدمة";
"tab_hooks" = "الخطافات";
"tab_about" = "حول";

/* Hooks Pane */
"hooks_enable_title" = "تفعيل الخطافات";
"hooks_enable_subtitle" = "تشغيل أوامر خارجية عند وقوع أحداث الحصة أو المزود.";
"hooks_trust_warning" = "يمكن للخطافات تنفيذ أوامر محلية على جهاز Mac. اضبط فقط الأوامر التي تثق بها.";
"hooks_rules_header" = "القواعد";
"hooks_empty" = "لا توجد خطافات مُهيأة.";
"hooks_add_rule" = "إضافة قاعدة";
"hooks_delete_rule" = "حذف القاعدة";
"hooks_rule_enabled" = "مُفعّل";
"hooks_event" = "الحدث";
"hooks_provider" = "المزود";
"hooks_any_provider" = "أي مزود";
"hooks_threshold" = "التشغيل عند الاستخدام ≥";
"hooks_threshold_placeholder" = "90";
"hooks_executable_placeholder" = "/usr/local/bin/my-command";
"hooks_arguments_placeholder" = "الوسائط";
"hooks_argument_placeholder" = "الوسيطة";
"hooks_add_argument" = "إضافة وسيطة";
"hooks_delete_argument" = "حذف الوسيطة";
"tab_debug" = "تصحيح الأخطاء";

/* Providers Pane */
Expand Down
21 changes: 21 additions & 0 deletions Sources/CodexBar/Resources/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,28 @@
"tab_menu_bar" = "Barra de menús";
"tab_menu" = "Menú";
"tab_advanced" = "Avançat";
"tab_hooks" = "Hooks";
"tab_about" = "Quant a";

/* Hooks Pane */
"hooks_enable_title" = "Activa els hooks";
"hooks_enable_subtitle" = "Executa ordres externes quan es produeixen esdeveniments de quota o de proveïdor.";
"hooks_trust_warning" = "Els hooks poden executar ordres locals al teu Mac. Configura només ordres en què confiïs.";
"hooks_rules_header" = "Regles";
"hooks_empty" = "No hi ha cap hook configurat.";
"hooks_add_rule" = "Afegeix una regla";
"hooks_delete_rule" = "Elimina la regla";
"hooks_rule_enabled" = "Activat";
"hooks_event" = "Esdeveniment";
"hooks_provider" = "Proveïdor";
"hooks_any_provider" = "Qualsevol proveïdor";
"hooks_threshold" = "Activa amb ús ≥";
"hooks_threshold_placeholder" = "90";
"hooks_executable_placeholder" = "/usr/local/bin/my-command";
"hooks_arguments_placeholder" = "Arguments";
"hooks_argument_placeholder" = "Argument";
"hooks_add_argument" = "Afegeix un argument";
"hooks_delete_argument" = "Elimina l'argument";
"tab_debug" = "Depuració";

/* Providers Pane */
Expand Down
20 changes: 20 additions & 0 deletions Sources/CodexBar/Resources/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
/* English localization for CodexBar (base/fallback) */

"tab_hooks" = "Hooks";
"hooks_enable_title" = "Hooks aktivieren";
"hooks_enable_subtitle" = "Externe Befehle bei Kontingent- oder Anbieterereignissen ausführen.";
"hooks_trust_warning" = "Hooks können lokale Befehle auf deinem Mac ausführen. Konfiguriere nur vertrauenswürdige Befehle.";
"hooks_rules_header" = "Regeln";
"hooks_empty" = "Keine Hooks konfiguriert.";
"hooks_add_rule" = "Regel hinzufügen";
"hooks_delete_rule" = "Regel löschen";
"hooks_rule_enabled" = "Aktiviert";
"hooks_event" = "Ereignis";
"hooks_provider" = "Anbieter";
"hooks_any_provider" = "Beliebiger Anbieter";
"hooks_threshold" = "Auslösen bei Nutzung ≥";
"hooks_threshold_placeholder" = "90";
"hooks_executable_placeholder" = "/usr/local/bin/my-command";
"hooks_arguments_placeholder" = "Argumente";
"hooks_argument_placeholder" = "Argument";
"hooks_add_argument" = "Argument hinzufügen";
"hooks_delete_argument" = "Argument löschen";

"ollama_safari_cookie_access_hint" = "Safari-Cookies benötigen vollen Festplattenzugriff für CodexBar (Systemeinstellungen > Datenschutz & Sicherheit).";
"ollama_browser_cookie_decryption_denied" = "Die Entschlüsselung der %@-Cookies wurde im Schlüsselbund abgelehnt; versuchen Sie es mit einer manuellen Aktualisierung erneut.";
"ollama_browser_cookie_decryption_disabled" = "Die Entschlüsselung der %@-Cookies ist in CodexBar deaktiviert; aktivieren Sie den Schlüsselbundzugriff und aktualisieren Sie.";
Expand Down
21 changes: 21 additions & 0 deletions Sources/CodexBar/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -510,9 +510,30 @@
"tab_menu_bar" = "Menu Bar";
"tab_menu" = "Menu";
"tab_advanced" = "Advanced";
"tab_hooks" = "Hooks";
"tab_about" = "About";
"tab_debug" = "Debug";

/* Hooks Pane */
"hooks_enable_title" = "Enable hooks";
"hooks_enable_subtitle" = "Run external commands when quota or provider events occur.";
"hooks_trust_warning" = "Hooks can execute local commands on your Mac. Only configure commands you trust.";
"hooks_rules_header" = "Rules";
"hooks_empty" = "No hooks configured.";
"hooks_add_rule" = "Add rule";
"hooks_delete_rule" = "Delete rule";
"hooks_rule_enabled" = "Enabled";
"hooks_event" = "Event";
"hooks_provider" = "Provider";
"hooks_any_provider" = "Any provider";
"hooks_threshold" = "Fire at usage ≥";
"hooks_threshold_placeholder" = "90";
"hooks_executable_placeholder" = "/usr/local/bin/my-command";
"hooks_arguments_placeholder" = "Arguments";
"hooks_argument_placeholder" = "Argument";
"hooks_add_argument" = "Add argument";
"hooks_delete_argument" = "Delete argument";

/* Providers Pane */
"select_a_provider" = "Select a provider";
"cancel" = "Cancel";
Expand Down
20 changes: 20 additions & 0 deletions Sources/CodexBar/Resources/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
/* Spanish localization for CodexBar */

"tab_hooks" = "Hooks";
"hooks_enable_title" = "Activar hooks";
"hooks_enable_subtitle" = "Ejecuta comandos externos cuando ocurran eventos de cuota o proveedor.";
"hooks_trust_warning" = "Los hooks pueden ejecutar comandos locales en tu Mac. Configura solo comandos de confianza.";
"hooks_rules_header" = "Reglas";
"hooks_empty" = "No hay hooks configurados.";
"hooks_add_rule" = "Añadir regla";
"hooks_delete_rule" = "Eliminar regla";
"hooks_rule_enabled" = "Activado";
"hooks_event" = "Evento";
"hooks_provider" = "Proveedor";
"hooks_any_provider" = "Cualquier proveedor";
"hooks_threshold" = "Ejecutar con uso ≥";
"hooks_threshold_placeholder" = "90";
"hooks_executable_placeholder" = "/usr/local/bin/my-command";
"hooks_arguments_placeholder" = "Argumentos";
"hooks_argument_placeholder" = "Argumento";
"hooks_add_argument" = "Añadir argumento";
"hooks_delete_argument" = "Eliminar argumento";

"ollama_safari_cookie_access_hint" = "Las cookies de Safari necesitan acceso total al disco para CodexBar (Ajustes del Sistema > Privacidad y seguridad).";
"ollama_browser_cookie_decryption_denied" = "Se rechazó en el Llavero el descifrado de las cookies de %@; vuelve a intentarlo con una actualización manual.";
"ollama_browser_cookie_decryption_disabled" = "El descifrado de las cookies de %@ está desactivado en CodexBar; activa el acceso al Llavero y actualiza.";
Expand Down
Loading