diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d51711e..113540cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Menu: add an opt-in setting to refresh provider usage whenever the menu opens without changing the periodic refresh clock. Thanks @dstier-git! +- Quota warnings: add an optional centered on-screen text alert that stays click-through and does not steal focus. Thanks @SAASEmpiree! ### Fixed - Claude web usage: bound stale requests so Auto can reach CLI fallback instead of hanging indefinitely. diff --git a/Sources/CodexBar/QuotaWarningAlertOverlayController.swift b/Sources/CodexBar/QuotaWarningAlertOverlayController.swift new file mode 100644 index 000000000..564fb09df --- /dev/null +++ b/Sources/CodexBar/QuotaWarningAlertOverlayController.swift @@ -0,0 +1,178 @@ +import AppKit +import CodexBarCore +import SwiftUI + +struct QuotaWarningAlertPresentationState { + struct Presentation: Equatable { + let generation: UInt + let title: String + let message: String + } + + private(set) var current: Presentation? + private var nextGeneration: UInt = 0 + + mutating func present(title: String, message: String) -> Presentation { + self.nextGeneration &+= 1 + let presentation = Presentation( + generation: self.nextGeneration, + title: title, + message: message) + self.current = presentation + return presentation + } + + mutating func dismiss(generation: UInt) -> Bool { + guard self.current?.generation == generation else { return false } + self.current = nil + return true + } + + mutating func dismiss() { + self.current = nil + } +} + +/// Presents a transient, centered text alert when a quota warning threshold is crossed. +/// +/// Modeled after ``ScreenConfettiOverlayController``: it shows a borderless, click-through +/// panel above all spaces and auto-dismisses after a short lifetime, so it never steals focus +/// or blocks the user's work. +@MainActor +final class QuotaWarningAlertOverlayController { + private static let overlayLifetime: TimeInterval = 4.5 + + private let logger = CodexBarLog.logger(LogCategories.sessionQuotaNotifications) + private var presentationState = QuotaWarningAlertPresentationState() + private var window: NSWindow? + private var dismissalTask: Task? + + func show(title: String, message: String) { + self.dismiss() + + guard let screen = NSScreen.main ?? NSScreen.screens.first else { + self.logger.error("Cannot present quota warning overlay because no screens were found") + return + } + + let presentation = self.presentationState.present(title: title, message: message) + + let frame = screen.frame + let contentView = QuotaWarningAlertOverlayView(title: title, message: message) + .allowsHitTesting(false) + let hostingView = NSHostingView(rootView: contentView) + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + + let window = ClickThroughAlertPanel( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false, + screen: screen) + window.contentView = hostingView + window.level = .statusBar + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle, .stationary] + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = false + window.ignoresMouseEvents = true + window.acceptsMouseMovedEvents = false + window.isMovable = false + window.isReleasedWhenClosed = false + window.canHide = false + window.hidesOnDeactivate = false + window.becomesKeyOnlyIfNeeded = false + window.isExcludedFromWindowsMenu = true + window.setFrame(frame, display: false) + window.orderFrontRegardless() + self.window = window + + self.logger.info("Presenting quota warning overlay") + + self.dismissalTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(Self.overlayLifetime)) + guard !Task.isCancelled else { return } + guard let self, self.presentationState.dismiss(generation: presentation.generation) else { return } + self.closeWindow() + } + } + + func dismiss() { + self.dismissalTask?.cancel() + self.dismissalTask = nil + self.presentationState.dismiss() + self.closeWindow() + } + + private func closeWindow() { + guard let window = self.window else { return } + window.orderOut(nil) + window.close() + self.window = nil + } +} + +private final class ClickThroughAlertPanel: NSPanel { + override var canBecomeKey: Bool { + false + } + + override var canBecomeMain: Bool { + false + } + + override var acceptsFirstResponder: Bool { + false + } +} + +private struct QuotaWarningAlertOverlayView: View { + let title: String + let message: String + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var appeared = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.title2) + .foregroundStyle(.orange) + + VStack(alignment: .leading, spacing: 4) { + Text(self.title) + .font(.headline) + Text(self.message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + .frame(maxWidth: 420, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08))) + .shadow(color: .black.opacity(0.25), radius: 24, y: 8) + .scaleEffect(self.reduceMotion || self.appeared ? 1 : 0.92) + .opacity(self.reduceMotion || self.appeared ? 1 : 0) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(40) + .allowsHitTesting(false) + .accessibilityElement(children: .combine) + .accessibilityLabel(self.title) + .accessibilityValue(self.message) + .task { + guard !self.reduceMotion else { + self.appeared = true + return + } + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + self.appeared = true + } + } + } +} diff --git a/Sources/CodexBar/QuotaWarningSettingsViews.swift b/Sources/CodexBar/QuotaWarningSettingsViews.swift index ce37b40f1..7aa578c30 100644 --- a/Sources/CodexBar/QuotaWarningSettingsViews.swift +++ b/Sources/CodexBar/QuotaWarningSettingsViews.swift @@ -35,6 +35,12 @@ struct GlobalQuotaWarningSettingsView: View { .font(.footnote) } .toggleStyle(.checkbox) + + Toggle(isOn: self.$settings.quotaWarningOnScreenAlertEnabled) { + Text(L("quota_warning_onscreen_alert")) + .font(.footnote) + } + .toggleStyle(.checkbox) } .padding(.leading, 20) } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index cf3f9482a..07761c2d2 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -454,6 +454,7 @@ "quota_warning_warn_at" = "التحذير في"; "quota_warning_global_threshold_subtitle" = "النسب المتبقية للجلسات والفترات الأسبوعية ما لم يتجاوزها مقدم الخدمة."; "quota_warning_sound" = "تشغيل صوت الإشعار"; +"quota_warning_onscreen_alert" = "إظهار تنبيه نصي على الشاشة"; "quota_warning_provider_inherits" = "يستخدم إعدادات تحذير الحصص العامة إلا إذا تم تخصيص نافذة هنا."; "quota_warning_customize_thresholds" = "تخصيص عتبات %@"; "quota_warning_enable_warnings" = "تفعيل تحذيرات %@"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 211e9b5d3..8072f8f39 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -445,6 +445,7 @@ "quota_warning_warn_at" = "Aviseu al"; "quota_warning_global_threshold_subtitle" = "Percentatges restants per a les finestres de sessió i setmanal, llevat que un proveïdor els substitueixi."; "quota_warning_sound" = "Reprodueix el so de notificació"; +"quota_warning_onscreen_alert" = "Mostra una alerta de text a la pantalla"; "quota_warning_provider_inherits" = "Fa servir la configuració global d'avís de quota llevat que es personalitzi una finestra aquí."; "quota_warning_customize_thresholds" = "Personalitza els llindars de %@"; "quota_warning_enable_warnings" = "Activeu els avisos de %@"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 69941bbe5..5d6ffb016 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -454,6 +454,7 @@ "quota_warning_warn_at" = "Warnen Sie vor"; "quota_warning_global_threshold_subtitle" = "Verbleibende Prozentsätze für Sitzungs- und Wochenfenster, es sei denn, ein Anbieter überschreibt sie."; "quota_warning_sound" = "Benachrichtigungston abspielen"; +"quota_warning_onscreen_alert" = "Bildschirm-Textwarnung anzeigen"; "quota_warning_provider_inherits" = "Verwendet die globalen Einstellungen für Kontingentwarnungen, es sei denn, hier wird ein Fenster angepasst."; "quota_warning_customize_thresholds" = "Passen Sie die Schwellenwerte für %@ an"; "quota_warning_enable_warnings" = "Aktivieren Sie %@-Warnungen"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 28aa8fced..1259c9eca 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -454,6 +454,7 @@ "quota_warning_warn_at" = "Warn at"; "quota_warning_global_threshold_subtitle" = "Remaining percentages for session and weekly windows unless a provider overrides them."; "quota_warning_sound" = "Play notification sound"; +"quota_warning_onscreen_alert" = "Show on-screen text alert"; "quota_warning_provider_inherits" = "Uses the global quota warning settings unless a window is customized here."; "quota_warning_customize_thresholds" = "Customize %@ thresholds"; "quota_warning_enable_warnings" = "Enable %@ warnings"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index e95ae373a..df8966d71 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -445,6 +445,7 @@ "quota_warning_warn_at" = "Avisar al"; "quota_warning_global_threshold_subtitle" = "Porcentajes restantes para las ventanas de sesión y semanal, salvo que un proveedor los anule."; "quota_warning_sound" = "Reproducir sonido de notificación"; +"quota_warning_onscreen_alert" = "Mostrar alerta de texto en pantalla"; "quota_warning_provider_inherits" = "Usa los ajustes globales de aviso de cuota salvo que se personalice una ventana aquí."; "quota_warning_customize_thresholds" = "Personalizar umbrales de %@"; "quota_warning_enable_warnings" = "Activar avisos de %@"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 6b155b59d..99faf9f03 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -454,6 +454,7 @@ "quota_warning_warn_at" = "هشدار در"; "quota_warning_global_threshold_subtitle" = "درصدهای باقی مانده برای جلسات و بازه های هفتگی مگر اینکه ارائه دهنده آن ها را لغو کند."; "quota_warning_sound" = "صدای اعلان پخش کن"; +"quota_warning_onscreen_alert" = "نمایش هشدار متنی روی صفحه"; "quota_warning_provider_inherits" = "از تنظیمات هشدار سهمیه جهانی استفاده می کند مگر اینکه پنجره ای اینجا سفارشی شده باشد."; "quota_warning_customize_thresholds" = "آستانه های %@ شخصی سازی کنید"; "quota_warning_enable_warnings" = "فعال کردن هشدارهای %@"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 460852803..c2c44fde2 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -454,6 +454,7 @@ "quota_warning_warn_at" = "Avertir à"; "quota_warning_global_threshold_subtitle" = "Pourcentages restants pour les fenêtres de session et hebdomadaires, sauf si un fournisseur les remplace."; "quota_warning_sound" = "Lire un son de notification"; +"quota_warning_onscreen_alert" = "Afficher une alerte textuelle à l’écran"; "quota_warning_provider_inherits" = "Utilise les réglages globaux d'alerte de quota, sauf personnalisation de cette fenêtre."; "quota_warning_customize_thresholds" = "Personnaliser les seuils %@"; "quota_warning_enable_warnings" = "Activer les alertes %@"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index e37de31a5..5c60b470b 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -456,6 +456,7 @@ "quota_warning_warn_at" = "Peringatkan pada"; "quota_warning_global_threshold_subtitle" = "Persentase sisa untuk jendela sesi dan mingguan kecuali penyedia menimpanya."; "quota_warning_sound" = "Mainkan suara notifikasi"; +"quota_warning_onscreen_alert" = "Tampilkan peringatan teks di layar"; "quota_warning_provider_inherits" = "Menggunakan pengaturan peringatan kuota global kecuali jendela dikustomisasi di sini."; "quota_warning_customize_thresholds" = "Kustomisasi ambang batas %@"; "quota_warning_enable_warnings" = "Aktifkan peringatan %@"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index a7f8a81e5..d21b25cd1 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -456,6 +456,7 @@ "quota_warning_warn_at" = "Avvisa a"; "quota_warning_global_threshold_subtitle" = "Percentuali residue per le finestre di sessione e settimanale, salvo override del provider."; "quota_warning_sound" = "Riproduci suono di notifica"; +"quota_warning_onscreen_alert" = "Mostra avviso di testo sullo schermo"; "quota_warning_provider_inherits" = "Usa le impostazioni globali di avviso quota, salvo personalizzazione di una finestra qui."; "quota_warning_customize_thresholds" = "Personalizza soglie %@"; "quota_warning_enable_warnings" = "Abilita avvisi %@"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 5b9199625..eab39c27e 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -451,6 +451,7 @@ "quota_warning_warn_at" = "警告する残量"; "quota_warning_global_threshold_subtitle" = "プロバイダ側で上書きされない限り、セッションおよび週間ウインドウの残量パーセントに適用されます。"; "quota_warning_sound" = "通知音を再生"; +"quota_warning_onscreen_alert" = "画面上にテキストアラートを表示"; "quota_warning_provider_inherits" = "ここでウインドウをカスタマイズしない限り、グローバルのクォータ警告設定を使用します。"; "quota_warning_customize_thresholds" = "%@ のしきい値をカスタマイズ"; "quota_warning_enable_warnings" = "%@ の警告を有効にする"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index a9abdcf85..f620235b3 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -451,6 +451,7 @@ "quota_warning_warn_at" = "경고 기준"; "quota_warning_global_threshold_subtitle" = "공급자가 재정의하지 않는 한 세션 및 주간 범위의 잔여 백분율입니다."; "quota_warning_sound" = "알림 소리 재생"; +"quota_warning_onscreen_alert" = "화면에 텍스트 알림 표시"; "quota_warning_provider_inherits" = "여기서 범위를 사용자 설정하지 않는 한 전역 할당량 경고 설정을 사용합니다."; "quota_warning_customize_thresholds" = "%@ 임곗값 사용자 설정"; "quota_warning_enable_warnings" = "%@ 경고 사용"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 9318d2d41..72b140836 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -454,6 +454,7 @@ "quota_warning_warn_at" = "Waarschuw bij"; "quota_warning_global_threshold_subtitle" = "Resterende percentages voor sessie- en wekelijkse vensters, tenzij een provider deze overschrijft."; "quota_warning_sound" = "Meldingsgeluid afspelen"; +"quota_warning_onscreen_alert" = "Tekstwaarschuwing op het scherm tonen"; "quota_warning_provider_inherits" = "Gebruikt de algemene instellingen voor quotawaarschuwingen, tenzij hier een venster wordt aangepast."; "quota_warning_customize_thresholds" = "Pas %@ drempels aan"; "quota_warning_enable_warnings" = "Schakel %@ waarschuwingen in"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 1523dbddc..67d5d7db2 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -456,6 +456,7 @@ "quota_warning_warn_at" = "Ostrzegaj przy"; "quota_warning_global_threshold_subtitle" = "Procent pozostałego limitu dla okien sesji i tygodnia, chyba że dostawca ma nadpisanie."; "quota_warning_sound" = "Odtwarzaj dźwięk powiadomienia"; +"quota_warning_onscreen_alert" = "Pokaż alert tekstowy na ekranie"; "quota_warning_provider_inherits" = "Używa globalnych ustawień ostrzeżeń limitu, chyba że to okno jest tutaj dostosowane."; "quota_warning_customize_thresholds" = "Dostosuj progi dla %@"; "quota_warning_enable_warnings" = "Włącz ostrzeżenia dla %@"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index a21e68f13..bff238f1f 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -451,6 +451,7 @@ "quota_warning_warn_at" = "Alertar em"; "quota_warning_global_threshold_subtitle" = "Percentuais restantes para as janelas de sessão e semanal, a menos que um provedor defina valores próprios."; "quota_warning_sound" = "Reproduzir som de notificação"; +"quota_warning_onscreen_alert" = "Mostrar alerta de texto na tela"; "quota_warning_provider_inherits" = "Usa as configurações globais de alerta de cota, a menos que uma janela seja personalizada aqui."; "quota_warning_customize_thresholds" = "Personalizar limites de %@"; "quota_warning_enable_warnings" = "Ativar alertas de %@"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index d9ca02068..9dced37eb 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -454,6 +454,7 @@ "quota_warning_warn_at" = "Varna vid"; "quota_warning_global_threshold_subtitle" = "Återstående procent för sessions- och veckofönster, om inte en leverantör åsidosätter dem."; "quota_warning_sound" = "Spela aviseringsljud"; +"quota_warning_onscreen_alert" = "Visa textavisering på skärmen"; "quota_warning_provider_inherits" = "Använder de globala kvotvarningsinställningarna om inte ett fönster anpassas här."; "quota_warning_customize_thresholds" = "Anpassa trösklar för %@"; "quota_warning_enable_warnings" = "Aktivera varningar för %@"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index c6de5c3cb..852eff87a 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -454,6 +454,7 @@ "quota_warning_warn_at" = "เตือนที่"; "quota_warning_global_threshold_subtitle" = "เปอร์เซ็นต์ที่เหลืออยู่สําหรับกรอบเวลาเซสชันและรายสัปดาห์ เว้นแต่ผู้ให้บริการจะแทนที่"; "quota_warning_sound" = "เล่นเสียงแจ้งเตือน"; +"quota_warning_onscreen_alert" = "แสดงการแจ้งเตือนข้อความบนหน้าจอ"; "quota_warning_provider_inherits" = "ใช้การตั้งค่าคําเตือนโควต้าส่วนกลาง เว้นแต่จะมีการกําหนดหน้าต่างเองที่นี่"; "quota_warning_customize_thresholds" = "ปรับแต่งเกณฑ์ %@"; "quota_warning_enable_warnings" = "เปิดใช้งานคําเตือน %@"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index ff28249a6..525a0676a 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -454,6 +454,7 @@ "quota_warning_warn_at" = "Uyarı eşikleri"; "quota_warning_global_threshold_subtitle" = "Bir sağlayıcı geçersiz kılmadıkça oturum ve haftalık pencereler için kalan yüzdelerval."; "quota_warning_sound" = "Bildirim sesi çal"; +"quota_warning_onscreen_alert" = "Ekranda metin uyarısı göster"; "quota_warning_provider_inherits" = "Bir pencere burada özelleştirilmediği sürece genel kota uyarı ayarlarını kullanır."; "quota_warning_customize_thresholds" = "%@ eşiklerini özelleştir"; "quota_warning_enable_warnings" = "%@ uyarılarını etkinleştir"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index d18a2e481..8d3b548d4 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -454,6 +454,7 @@ "quota_warning_warn_at" = "Попередити при"; "quota_warning_global_threshold_subtitle" = "Відсотки, що залишилися для вікон сесії та тижня, якщо постачальник не замінить їх."; "quota_warning_sound" = "Відтворити звук сповіщення"; +"quota_warning_onscreen_alert" = "Показувати текстове сповіщення на екрані"; "quota_warning_provider_inherits" = "Використовує глобальні параметри попередження про квоту, якщо тут не налаштовано вікно."; "quota_warning_customize_thresholds" = "Налаштувати порогові значення %@"; "quota_warning_enable_warnings" = "Увімкнути %@ попереджень"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 429543148..bceca2f61 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -450,6 +450,7 @@ "quota_warning_warn_at" = "Cảnh báo ở"; "quota_warning_global_threshold_subtitle" = "Tỷ lệ phần trăm còn lại cho phiên và thời lượng hàng tuần trừ khi Nhà cung cấp ghi đè chúng."; "quota_warning_sound" = "Phát âm thanh thông báo"; +"quota_warning_onscreen_alert" = "Hiển thị cảnh báo văn bản trên màn hình"; "quota_warning_provider_inherits" = "Sử dụng cảnh báo Hạn mức toàn cầu Cài đặt trừ khi một cửa sổ được tùy chỉnh tại đây."; "quota_warning_customize_thresholds" = "Tùy chỉnh %@ ngưỡng"; "quota_warning_enable_warnings" = "Bật %@ cảnh báo"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 58d6ff96a..8477fc24c 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -451,6 +451,7 @@ "quota_warning_warn_at" = "预警阈值"; "quota_warning_global_threshold_subtitle" = "会话和每周窗口的剩余百分比,除非提供商单独覆盖。"; "quota_warning_sound" = "播放通知声音"; +"quota_warning_onscreen_alert" = "显示屏幕文字提醒"; "quota_warning_provider_inherits" = "默认使用全局配额预警设置,除非在这里自定义窗口。"; "quota_warning_customize_thresholds" = "自定义 %@ 阈值"; "quota_warning_enable_warnings" = "启用 %@ 预警"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 3ff80a9ec..94c4ef5f7 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -458,6 +458,7 @@ "quota_warning_warn_at" = "提醒門檻"; "quota_warning_global_threshold_subtitle" = "工作階段和每週時段的剩餘百分比,除非提供者另有設定。"; "quota_warning_sound" = "播放通知音效"; +"quota_warning_onscreen_alert" = "顯示螢幕文字提醒"; "quota_warning_provider_inherits" = "預設使用全域配額提醒設定,除非在此自訂時段。"; "quota_warning_customize_thresholds" = "自訂 %@ 門檻"; "quota_warning_enable_warnings" = "啟用 %@ 提醒"; diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 91fe133cf..ce789f906 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -199,12 +199,17 @@ extension UsageStore { @MainActor protocol SessionQuotaNotifying: AnyObject { func post(transition: SessionQuotaTransition, provider: UsageProvider, badge: NSNumber?) - func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool) + func postQuotaWarning( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool) } @MainActor final class SessionQuotaNotifier: SessionQuotaNotifying { private let logger = CodexBarLog.logger(LogCategories.sessionQuotaNotifications) + private lazy var alertOverlay = QuotaWarningAlertOverlayController() init() {} @@ -224,7 +229,12 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body, badge: badge) } - func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool = true) { + func postQuotaWarning( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool = true, + onScreenAlertEnabled: Bool = false) + { let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let threshold = event.threshold let copy = QuotaWarningNotificationLogic.notificationCopy( @@ -238,6 +248,9 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { if soundEnabled { (NSSound(named: "Glass") ?? NSSound(named: "Ping"))?.play() } + if onScreenAlertEnabled { + self.alertOverlay.show(title: copy.title, message: copy.body) + } NotificationCenter.default.post( name: .codexbarQuotaWarningDidPost, object: QuotaWarningPostedEvent( diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 91ec82278..666da74e6 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -183,6 +183,14 @@ extension SettingsStore { } } + var quotaWarningOnScreenAlertEnabled: Bool { + get { self.defaultsState.quotaWarningOnScreenAlertEnabled } + set { + self.defaultsState.quotaWarningOnScreenAlertEnabled = newValue + self.userDefaults.set(newValue, forKey: "quotaWarningOnScreenAlertEnabled") + } + } + var quotaWarningMarkersVisible: Bool { get { self.defaultsState.quotaWarningMarkersVisible } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 7ef202e10..a22f411e3 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -18,6 +18,7 @@ extension SettingsStore { _ = self.quotaWarningWindowEnabled(.session) _ = self.quotaWarningWindowEnabled(.weekly) _ = self.quotaWarningSoundEnabled + _ = self.quotaWarningOnScreenAlertEnabled _ = self.quotaWarningMarkersVisible _ = self.weeklyProgressWorkDays _ = self.usageBarsShowUsed diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index a579de7eb..a9a598693 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -382,11 +382,7 @@ extension SettingsStore { let debugLoadingPatternRaw = userDefaults.string(forKey: "debugLoadingPattern") let debugKeepCLISessionsAlive = userDefaults.object(forKey: "debugKeepCLISessionsAlive") as? Bool ?? false let statusChecksEnabled = userDefaults.object(forKey: "statusChecksEnabled") as? Bool ?? true - let sessionQuotaDefault = userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") as? Bool - let sessionQuotaNotificationsEnabled = sessionQuotaDefault ?? true - if Self.isRunningTests, sessionQuotaDefault == nil { - userDefaults.set(true, forKey: "sessionQuotaNotificationsEnabled") - } + let sessionQuotaNotificationsEnabled = Self.loadSessionQuotaNotificationsDefault(userDefaults: userDefaults) let quotaWarnings = Self.loadQuotaWarningDefaults(userDefaults: userDefaults) let quotaWarningMarkersVisibleDefault = userDefaults.object(forKey: "quotaWarningMarkersVisible") as? Bool let quotaWarningMarkersVisible = quotaWarningMarkersVisibleDefault ?? true @@ -474,6 +470,7 @@ extension SettingsStore { quotaWarningSessionEnabled: quotaWarnings.sessionEnabled, quotaWarningWeeklyEnabled: quotaWarnings.weeklyEnabled, quotaWarningSoundEnabled: quotaWarnings.soundEnabled, + quotaWarningOnScreenAlertEnabled: quotaWarnings.onScreenAlertEnabled, quotaWarningMarkersVisible: quotaWarningMarkersVisible, weeklyProgressWorkDays: weeklyProgressWorkDays, usageBarsShowUsed: usageBarsShowUsed, @@ -606,6 +603,15 @@ extension SettingsStore { var sessionEnabled: Bool var weeklyEnabled: Bool var soundEnabled: Bool + var onScreenAlertEnabled: Bool + } + + private static func loadSessionQuotaNotificationsDefault(userDefaults: UserDefaults) -> Bool { + let stored = userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") as? Bool + if Self.isRunningTests, stored == nil { + userDefaults.set(true, forKey: "sessionQuotaNotificationsEnabled") + } + return stored ?? true } private static func loadQuotaWarningDefaults(userDefaults: UserDefaults) -> LoadedQuotaWarningDefaults { @@ -644,6 +650,12 @@ extension SettingsStore { userDefaults.set(true, forKey: "quotaWarningSoundEnabled") } + let onScreenAlertDefault = userDefaults.object(forKey: "quotaWarningOnScreenAlertEnabled") as? Bool + let onScreenAlertEnabled = onScreenAlertDefault ?? false + if Self.isRunningTests, onScreenAlertDefault == nil { + userDefaults.set(false, forKey: "quotaWarningOnScreenAlertEnabled") + } + return LoadedQuotaWarningDefaults( notificationsEnabled: notificationsEnabled, thresholdsRaw: thresholdsRaw, @@ -651,7 +663,8 @@ extension SettingsStore { weeklyThresholdsRaw: weeklyThresholdsRaw, sessionEnabled: sessionEnabled, weeklyEnabled: weeklyEnabled, - soundEnabled: soundEnabled) + soundEnabled: soundEnabled, + onScreenAlertEnabled: onScreenAlertEnabled) } } diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 3a145731b..e72d6d794 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -19,6 +19,7 @@ struct SettingsDefaultsState { var quotaWarningSessionEnabled: Bool var quotaWarningWeeklyEnabled: Bool var quotaWarningSoundEnabled: Bool + var quotaWarningOnScreenAlertEnabled: Bool var quotaWarningMarkersVisible: Bool var weeklyProgressWorkDays: Int? var usageBarsShowUsed: Bool diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index a7354eb02..7763db7b3 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -792,7 +792,8 @@ final class UsageStore { self.sessionQuotaNotifier.postQuotaWarning( event: event, provider: provider, - soundEnabled: self.settings.quotaWarningSoundEnabled) + soundEnabled: self.settings.quotaWarningSoundEnabled, + onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled) } func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) { diff --git a/Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift b/Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift index a718f5037..618aa5281 100644 --- a/Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift @@ -163,7 +163,12 @@ struct CommandCodeQuotaTransitionTests { self.posts.append((transition, provider)) } - func postQuotaWarning(event: QuotaWarningEvent, provider _: UsageProvider, soundEnabled _: Bool) { + func postQuotaWarning( + event: QuotaWarningEvent, + provider _: UsageProvider, + soundEnabled _: Bool, + onScreenAlertEnabled _: Bool) + { self.quotaWarningPosts.append(event) } } diff --git a/Tests/CodexBarTests/QuotaWarningAlertPresentationStateTests.swift b/Tests/CodexBarTests/QuotaWarningAlertPresentationStateTests.swift new file mode 100644 index 000000000..d4eadbc49 --- /dev/null +++ b/Tests/CodexBarTests/QuotaWarningAlertPresentationStateTests.swift @@ -0,0 +1,26 @@ +import Testing +@testable import CodexBar + +struct QuotaWarningAlertPresentationStateTests { + @Test + func `replacement alert ignores stale dismissal`() { + var state = QuotaWarningAlertPresentationState() + let session = state.present(title: "Session quota low", message: "20% left") + let weekly = state.present(title: "Weekly quota low", message: "10% left") + + #expect(state.dismiss(generation: session.generation) == false) + #expect(state.current == weekly) + #expect(state.dismiss(generation: weekly.generation) == true) + #expect(state.current == nil) + } + + @Test + func `manual dismissal clears current alert`() { + var state = QuotaWarningAlertPresentationState() + state.present(title: "Session quota low", message: "20% left") + + state.dismiss() + + #expect(state.current == nil) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 405959cf4..31db735e1 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -683,14 +683,38 @@ struct SettingsStoreTests { #expect(store.quotaWarningWindowEnabled(.session) == true) #expect(store.quotaWarningWindowEnabled(.weekly) == true) #expect(store.quotaWarningSoundEnabled == true) + #expect(store.quotaWarningOnScreenAlertEnabled == false) #expect(store.quotaWarningMarkersVisible == true) #expect(defaults.array(forKey: "quotaWarningThresholds") as? [Int] == [50, 20]) #expect(defaults.object(forKey: "quotaWarningSessionEnabled") as? Bool == true) #expect(defaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool == true) #expect(defaults.bool(forKey: "quotaWarningSoundEnabled") == true) + #expect(defaults.object(forKey: "quotaWarningOnScreenAlertEnabled") as? Bool == false) #expect(defaults.object(forKey: "quotaWarningMarkersVisible") as? Bool == true) } + @Test + func `on-screen quota warning preference persists`() throws { + let suite = "SettingsStoreTests-quota-warning-on-screen-alert" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.quotaWarningOnScreenAlertEnabled = true + + let reloaded = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(reloaded.quotaWarningOnScreenAlertEnabled == true) + } + @Test func `global quota warning windows persist independently`() throws { let suite = "SettingsStoreTests-quota-warning-window-enabled" diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index f6edec761..5a831b2b6 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -21,14 +21,24 @@ struct UsageStoreSessionQuotaTransitionTests { private(set) var quotaWarningPosts: [( event: QuotaWarningEvent, provider: UsageProvider, - soundEnabled: Bool)] = [] + soundEnabled: Bool, + onScreenAlertEnabled: Bool)] = [] func post(transition: SessionQuotaTransition, provider: UsageProvider, badge _: NSNumber?) { self.posts.append((transition: transition, provider: provider)) } - func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool) { - self.quotaWarningPosts.append((event: event, provider: provider, soundEnabled: soundEnabled)) + func postQuotaWarning( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool) + { + self.quotaWarningPosts.append(( + event: event, + provider: provider, + soundEnabled: soundEnabled, + onScreenAlertEnabled: onScreenAlertEnabled)) } } @@ -330,6 +340,7 @@ struct UsageStoreSessionQuotaTransitionTests { settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningOnScreenAlertEnabled = true settings.quotaWarningThresholds = [50, 20] settings.setQuotaWarningWindowEnabled(.session, enabled: true) settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) @@ -379,6 +390,7 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.quotaWarningPosts.first?.event.window == .session) #expect(notifier.quotaWarningPosts.first?.event.threshold == 50) #expect(notifier.quotaWarningPosts.first?.event.accountDisplayName == "person@example.com") + #expect(notifier.quotaWarningPosts.first?.onScreenAlertEnabled == true) } @Test