From 2a53f1a8b4848070033161beaea6f1c3dd8b27e9 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Thu, 12 Mar 2026 14:01:36 +0400 Subject: [PATCH 01/11] feat: refactor usage service to use subscriber pattern for events - Replace UsageUpdates channel with callback-based subscription - Support multiple subscribers for protection pause/resume events - Add OnUsageUpdated, OnLLMDailySummaryReady, OnProtectionPause, and OnProtectionResumed methods - Update main.go to use new event subscription pattern - Relocate config/log directory to ~/.focusd --- internal/updater/updater.go | 4 +- internal/usage/insights_daily_summary.go | 19 ++++++-- internal/usage/protection.go | 26 ++++++++-- internal/usage/protection_test.go | 34 ++++++------- internal/usage/service.go | 21 ++++---- internal/usage/service_events.go | 29 +++++++++++ internal/usage/service_option.go | 21 -------- internal/usage/service_usage.go | 18 ++++--- internal/usage/service_usage_test.go | 9 ++-- main.go | 61 +++++++++--------------- 10 files changed, 138 insertions(+), 104 deletions(-) create mode 100644 internal/usage/service_events.go diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 772ccac..5bb368e 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -27,8 +27,8 @@ const ( ) var allowedDownloadHosts = map[string]bool{ - "github.com": true, - "objects.githubusercontent.com": true, + "github.com": true, + "objects.githubusercontent.com": true, "github-releases.githubusercontent.com": true, } diff --git a/internal/usage/insights_daily_summary.go b/internal/usage/insights_daily_summary.go index b8df449..d789c82 100644 --- a/internal/usage/insights_daily_summary.go +++ b/internal/usage/insights_daily_summary.go @@ -123,8 +123,12 @@ func (s *Service) generateDailySummaryForDate(ctx context.Context, date time.Tim slog.Info("daily summary generated", "date", dateStr, "headline", summary.Headline) - if s.onLLMDailySummaryReady != nil && summary.DayVibe != "insufficient-data" { - s.onLLMDailySummaryReady(summary) + if summary.DayVibe != "insufficient-data" { + s.eventsMu.RLock() + for _, fn := range s.onLLMDailySummaryReady { + fn(summary) + } + s.eventsMu.RUnlock() } return nil @@ -145,7 +149,7 @@ func (s *Service) computeLLMDaySummaryInput(date time.Time) (LLMDaySummaryInput, var ( productiveSecs int distractiveSecs int - contextSwitches int // productive↔distracting transitions + contextSwitches int // productive↔distracting transitions prevClass Classification appProductiveSecs = make(map[string]int) @@ -155,7 +159,7 @@ func (s *Service) computeLLMDaySummaryInput(date time.Time) (LLMDaySummaryInput, hourProductiveSecs = make(map[int]int) hourDistractSecs = make(map[int]int) - deep deepWorkTracker // emits sessions ≥ 25 min + deep deepWorkTracker // emits sessions ≥ 25 min focus focusStretchTracker cascade cascadeTracker ) @@ -229,6 +233,13 @@ func (s *Service) computeLLMDaySummaryInput(date time.Time) (LLMDaySummaryInput, return input, nil } +// OnLLMDailySummaryReady subscribes a callback to the daily summary ready event. +func (s *Service) OnLLMDailySummaryReady(fn func(summary LLMDailySummary)) { + s.eventsMu.Lock() + defer s.eventsMu.Unlock() + s.onLLMDailySummaryReady = append(s.onLLMDailySummaryReady, fn) +} + func (s *Service) enrichWithDBStats(input *LLMDaySummaryInput, date time.Time) { blockMode := TerminationModeBlock blockedUsages, err := s.GetUsageList(GetUsageListOptions{Date: &date, TerminationMode: &blockMode}) diff --git a/internal/usage/protection.go b/internal/usage/protection.go index 82b6605..d78ca31 100644 --- a/internal/usage/protection.go +++ b/internal/usage/protection.go @@ -55,9 +55,11 @@ func (s *Service) PauseProtection(durationSeconds int, reason string) (Protectio return ProtectionPause{}, err } - if s.onProtectionPaused != nil { - s.onProtectionPaused(protectionPause) + s.eventsMu.RLock() + for _, fn := range s.onProtectionPaused { + fn(protectionPause) } + s.eventsMu.RUnlock() return protectionPause, nil } @@ -99,13 +101,29 @@ func (s *Service) ResumeProtection(reason string) (ProtectionPause, error) { return ProtectionPause{}, err } - if s.onProtectionResumed != nil { - s.onProtectionResumed(protectionPause) + s.eventsMu.RLock() + for _, fn := range s.onProtectionResumed { + fn(protectionPause) } + s.eventsMu.RUnlock() return protectionPause, nil } +// OnProtectionPause subscribes a callback to the protection paused event. +func (s *Service) OnProtectionPause(fn func(pause ProtectionPause)) { + s.eventsMu.Lock() + defer s.eventsMu.Unlock() + s.onProtectionPaused = append(s.onProtectionPaused, fn) +} + +// OnProtectionResumed subscribes a callback to the protection resumed event. +func (s *Service) OnProtectionResumed(fn func(pause ProtectionPause)) { + s.eventsMu.Lock() + defer s.eventsMu.Unlock() + s.onProtectionResumed = append(s.onProtectionResumed, fn) +} + // GetProtectionStatus retrieves the current protection pause status. // // It queries for an active ProtectionPause record where ResumedAt is greater than diff --git a/internal/usage/protection_test.go b/internal/usage/protection_test.go index df8bbab..2c951e7 100644 --- a/internal/usage/protection_test.go +++ b/internal/usage/protection_test.go @@ -79,23 +79,23 @@ func TestProtection_PauseProtectionEventsFired(t *testing.T) { onProtectionResumedCalled = false ) - service, _ := setUpService( - t, - usage.WithProtectionPaused(func(pause usage.ProtectionPause) { - onProtectionPausedCalled = true - require.NotEqual(t, int64(0), pause.ResumedAt) - // this will still be initial requested duration - require.Equal(t, 10, pause.ActualDurationSeconds) - require.Equal(t, "protection paused for 10s expired", pause.ResumedReason) - }), - usage.WithProtectionResumed(func(pause usage.ProtectionPause) { - onProtectionResumedCalled = true - require.NotEqual(t, int64(0), pause.ResumedAt) - // this is the actual duration calculated after the resume - require.Equal(t, 3, pause.ActualDurationSeconds) - require.Equal(t, "just because", pause.ResumedReason) - }), - ) + service, _ := setUpService(t) + + service.OnProtectionPause(func(pause usage.ProtectionPause) { + onProtectionPausedCalled = true + require.NotEqual(t, int64(0), pause.ResumedAt) + // this will still be initial requested duration + require.Equal(t, 10, pause.ActualDurationSeconds) + require.Equal(t, "protection paused for 10s expired", pause.ResumedReason) + }) + + service.OnProtectionResumed(func(pause usage.ProtectionPause) { + onProtectionResumedCalled = true + require.NotEqual(t, int64(0), pause.ResumedAt) + // this is the actual duration calculated after the resume + require.Equal(t, 3, pause.ActualDurationSeconds) + require.Equal(t, "just because", pause.ResumedReason) + }) _, err := service.PauseProtection(10, "test") diff --git a/internal/usage/service.go b/internal/usage/service.go index e12d955..1b61769 100644 --- a/internal/usage/service.go +++ b/internal/usage/service.go @@ -24,16 +24,15 @@ type Service struct { appBlocker func(appName, title, reason string, tags []string, browserURL *string) // events - onProtectionPaused func(pause ProtectionPause) - onProtectionResumed func(pause ProtectionPause) - onLLMDailySummaryReady func(summary LLMDailySummary) + eventsMu sync.RWMutex + onProtectionPaused []func(pause ProtectionPause) + onProtectionResumed []func(pause ProtectionPause) + onLLMDailySummaryReady []func(summary LLMDailySummary) + onUsageUpdated []func(usage *ApplicationUsage) // mu serializes title change processing to prevent race conditions // when multiple events fire concurrently mu sync.Mutex - - // channel to receive usage updates - UsageUpdates chan *ApplicationUsage } func NewService(ctx context.Context, db *gorm.DB, options ...Option) (*Service, error) { @@ -50,8 +49,7 @@ func NewService(ctx context.Context, db *gorm.DB, options ...Option) (*Service, } service := &Service{ - db: db, - UsageUpdates: make(chan *ApplicationUsage, 10), // buffer of 10 to prevent blocking + db: db, } for _, option := range options { @@ -95,3 +93,10 @@ func (s *Service) removeOldSandboxExecutionLogs(ctx context.Context) error { return s.db.Where("created_at < ?", sevenDaysAgo).Delete(&SandboxExecutionLog{}).Error } + +// OnUsageUpdated subscribes a callback to the usage updated event. +func (s *Service) OnUsageUpdated(fn func(usage *ApplicationUsage)) { + s.eventsMu.Lock() + defer s.eventsMu.Unlock() + s.onUsageUpdated = append(s.onUsageUpdated, fn) +} diff --git a/internal/usage/service_events.go b/internal/usage/service_events.go new file mode 100644 index 0000000..3c589c4 --- /dev/null +++ b/internal/usage/service_events.go @@ -0,0 +1,29 @@ +package usage + +// OnUsageUpdated subscribes a callback to the usage updated event. +func (s *Service) OnUsageUpdated(fn func(usage *ApplicationUsage)) { + s.eventsMu.Lock() + defer s.eventsMu.Unlock() + s.onUsageUpdated = append(s.onUsageUpdated, fn) +} + +// OnLLMDailySummaryReady subscribes a callback to the daily summary ready event. +func (s *Service) OnLLMDailySummaryReady(fn func(summary LLMDailySummary)) { + s.eventsMu.Lock() + defer s.eventsMu.Unlock() + s.onLLMDailySummaryReady = append(s.onLLMDailySummaryReady, fn) +} + +// OnProtectionPause subscribes a callback to the protection paused event. +func (s *Service) OnProtectionPause(fn func(pause ProtectionPause)) { + s.eventsMu.Lock() + defer s.eventsMu.Unlock() + s.onProtectionPaused = append(s.onProtectionPaused, fn) +} + +// OnProtectionResumed subscribes a callback to the protection resumed event. +func (s *Service) OnProtectionResumed(fn func(pause ProtectionPause)) { + s.eventsMu.Lock() + defer s.eventsMu.Unlock() + s.onProtectionResumed = append(s.onProtectionResumed, fn) +} diff --git a/internal/usage/service_option.go b/internal/usage/service_option.go index bd01b49..b4aa123 100644 --- a/internal/usage/service_option.go +++ b/internal/usage/service_option.go @@ -31,24 +31,3 @@ func WithGenaiClient(genaiClient *genai.Client) Option { s.genaiClient = genaiClient } } - -// WithProtectionPaused configures the Service with a function to call when protection is paused. -func WithProtectionPaused(onProtectionPaused func(pause ProtectionPause)) Option { - return func(s *Service) { - s.onProtectionPaused = onProtectionPaused - } -} - -// WithProtectionResumed configures the Service with a function to call when protection is resumed. -func WithProtectionResumed(onProtectionResumed func(pause ProtectionPause)) Option { - return func(s *Service) { - s.onProtectionResumed = onProtectionResumed - } -} - -// WithLLMDailySummaryReady configures the Service with a function to call when a daily LLM summary is generated. -func WithLLMDailySummaryReady(fn func(summary LLMDailySummary)) Option { - return func(s *Service) { - s.onLLMDailySummaryReady = fn - } -} diff --git a/internal/usage/service_usage.go b/internal/usage/service_usage.go index 5395358..62d7429 100644 --- a/internal/usage/service_usage.go +++ b/internal/usage/service_usage.go @@ -127,9 +127,11 @@ func (s *Service) TitleChanged(ctx context.Context, executablePath, windowTitle, Application: application, } - if s.UsageUpdates != nil { - s.UsageUpdates <- &applicationUsage + s.eventsMu.RLock() + for _, fn := range s.onUsageUpdated { + fn(&applicationUsage) } + s.eventsMu.RUnlock() // save the application usage if err := s.db.Save(&applicationUsage).Error; err != nil { @@ -203,9 +205,11 @@ func (s *Service) TitleChanged(ctx context.Context, executablePath, windowTitle, s.appBlocker(applicationUsage.Application.Name, applicationUsage.WindowTitle, termReason, tags, applicationUsage.BrowserURL) } - if s.UsageUpdates != nil { - s.UsageUpdates <- &applicationUsage + s.eventsMu.RLock() + for _, fn := range s.onUsageUpdated { + fn(&applicationUsage) } + s.eventsMu.RUnlock() return nil } @@ -414,9 +418,11 @@ func (s *Service) closeApplicationUsage(app *ApplicationUsage) error { return fmt.Errorf("failed to update application usage: %w", err) } - if s.UsageUpdates != nil { - s.UsageUpdates <- app + s.eventsMu.RLock() + for _, fn := range s.onUsageUpdated { + fn(app) } + s.eventsMu.RUnlock() return nil } diff --git a/internal/usage/service_usage_test.go b/internal/usage/service_usage_test.go index a6a84bd..a405dcc 100644 --- a/internal/usage/service_usage_test.go +++ b/internal/usage/service_usage_test.go @@ -295,9 +295,12 @@ func TestService_TitleChanged_PropogateClassificationFromLLM(t *testing.T) { require.NoError(t, db.Preload("Tags").Where("ended_at IS NULL").First(&readApplicationUsage).Error) require.Equal(t, usage.ClassificationProductive, readApplicationUsage.Classification) - require.Equal(t, usage.ClassificationSourceCloudLLMGemini, readApplicationUsage.ClassificationSource) - require.Equal(t, "Productive work communication", readApplicationUsage.ClassificationReasoning) - require.Equal(t, float32(0.95), readApplicationUsage.ClassificationConfidence) + require.NotNil(t, readApplicationUsage.ClassificationSource) + require.Equal(t, usage.ClassificationSourceCloudLLMGemini, *readApplicationUsage.ClassificationSource) + require.NotNil(t, readApplicationUsage.ClassificationReasoning) + require.Equal(t, "Productive work communication", *readApplicationUsage.ClassificationReasoning) + require.NotNil(t, readApplicationUsage.ClassificationConfidence) + require.Equal(t, float32(0.95), *readApplicationUsage.ClassificationConfidence) // Verify tags were propagated require.Len(t, readApplicationUsage.Tags, 2) diff --git a/main.go b/main.go index 81cedf6..d49a273 100644 --- a/main.go +++ b/main.go @@ -127,22 +127,8 @@ func main() { slog.Error("failed to create genai client", "error", err) } - var wailsAppPtr *application.App - usageService, err := usage.NewService( ctx, db, - usage.WithProtectionPaused(func(pause usage.ProtectionPause) { - slog.Info("protection has been paused", "reason", pause.Reason) - if wailsAppPtr != nil { - wailsAppPtr.Event.Emit("protection:status", pause) - } - }), - usage.WithProtectionResumed(func(pause usage.ProtectionPause) { - slog.Info("protection has been resumed", "reason", pause.Reason) - if wailsAppPtr != nil { - wailsAppPtr.Event.Emit("protection:status", pause) - } - }), usage.WithAppBlocker(func(appName, title, reason string, tags []string, browserURL *string) { client := extension.HasClient(appName) @@ -169,15 +155,6 @@ func main() { }), usage.WithGenaiClient(genaiClient), usage.WithSettingsService(settingsService), - usage.WithLLMDailySummaryReady(func(summary usage.LLMDailySummary) { - slog.Info("daily LLM summary ready", "date", summary.Date, "headline", summary.Headline) - if wailsAppPtr != nil { - wailsAppPtr.Event.Emit("daily-summary:ready", summary) - } - exec.Command("osascript", "-e", - fmt.Sprintf(`display notification "%s" with title "Focusd" subtitle "Daily Summary"`, - summary.Headline)).Run() - }), ) if err != nil { log.Fatal("failed to create usage service: %w", err) @@ -254,22 +231,28 @@ func main() { }, }) - wailsApp.OnShutdown(cancel) + usageService.OnProtectionPause(func(pause usage.ProtectionPause) { + wailsApp.Event.Emit("protection:status", pause) + }) - wailsAppPtr = wailsApp + usageService.OnProtectionResumed(func(pause usage.ProtectionPause) { + wailsApp.Event.Emit("protection:status", pause) + }) - go func() { - for { - select { - case <-ctx.Done(): - return - case usage := <-usageService.UsageUpdates: - if wailsAppPtr != nil { - wailsAppPtr.Event.Emit("usage:update", *usage) - } - } - } - }() + usageService.OnLLMDailySummaryReady(func(summary usage.LLMDailySummary) { + wailsApp.Event.Emit("daily-summary:ready", summary) + + // TODO: use proper system api to send a notification + exec.Command("osascript", "-e", + fmt.Sprintf(`display notification "%s" with title "Focusd" subtitle "Daily Summary"`, + summary.Headline)).Run() + }) + + wailsApp.OnShutdown(cancel) + + usageService.OnUsageUpdated(func(appUsage *usage.ApplicationUsage) { + wailsApp.Event.Emit("usage:update", *appUsage) + }) native.OnIdleChange(func(idleSeconds float64) { usageService.IdleChanged(ctx, idleSeconds > 120) @@ -391,11 +374,11 @@ func setupLogging() (io.Closer, error) { if _, err := os.Stat("go.mod"); err == nil { logPath = logName } else { - configDir, err := os.UserConfigDir() + configDir, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("failed to get user config dir: %w", err) } - appDir := filepath.Join(configDir, "focusd") + appDir := filepath.Join(configDir, ".focusd") if err := os.MkdirAll(appDir, 0755); err != nil { return nil, fmt.Errorf("failed to create app config dir: %w", err) } From e1d4841666245bfe9889dac5cd4f9e4ca62657c2 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Thu, 12 Mar 2026 23:40:41 +0400 Subject: [PATCH 02/11] feat: refactor model selection and event subscriber pattern - Migrated GenAI client initialization to usage service - Implemented subscriber pattern for protection and usage events - Added specialized Slack classifier and application prompt templates - Introduced internal/settings package for centralized configuration - Updated main.go to use refactored services and settings --- cmd/serve/serve.go | 443 +++++++++++++----- frontend/src/components/app-sidebar.tsx | 4 +- frontend/src/components/custom-rules.tsx | 68 +-- .../components/settings/about-settings.tsx | 4 +- frontend/src/stores/settings-store.ts | 134 +++--- go.mod | 30 +- go.sum | 68 ++- .../settings/build_dev.go | 2 +- .../settings/build_prod.go | 2 +- internal/settings/config.go | 42 ++ internal/settings/service.go | 156 +++--- internal/usage/classifier_custom_rules.go | 16 +- .../usage/classifier_custom_rules_test.go | 55 +-- internal/usage/classifier_llm.go | 190 +++++++- internal/usage/classifier_llm_apps.go | 80 +--- internal/usage/classifier_llm_apps_prompt.go | 92 ++++ internal/usage/classifier_llm_slack.go | 72 +++ internal/usage/classifier_llm_test.go | 300 ------------ internal/usage/classifier_llm_website.go | 177 ++----- internal/usage/insights_daily_summary.go | 189 ++++++-- internal/usage/protection.go | 32 +- internal/usage/protection_test.go | 14 +- internal/usage/service.go | 16 +- internal/usage/service_option.go | 22 - internal/usage/service_usage_test.go | 61 --- internal/usage/types_usage.go | 23 +- main.go | 43 +- 27 files changed, 1182 insertions(+), 1153 deletions(-) rename build_dev.go => internal/settings/build_dev.go (77%) rename build_prod.go => internal/settings/build_prod.go (76%) create mode 100644 internal/settings/config.go create mode 100644 internal/usage/classifier_llm_apps_prompt.go create mode 100644 internal/usage/classifier_llm_slack.go delete mode 100644 internal/usage/classifier_llm_test.go diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index 067e0d4..8eca76f 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -84,13 +84,98 @@ var Command = &cli.Command{ mux.HandleFunc(webhookPath, api.NewPolarWebhookHandler(apiService)) slog.Info("serving polar webhook handler", "path", webhookPath) - // Gemini API proxy endpoint + geminiProxyConfig := llmProxyConfig{ + Provider: "gemini", + BaseURL: "https://generativelanguage.googleapis.com", + PathPrefix: "/api/v1/gemini", + SetupRequest: func(_ *http.Request, targetURL *url.URL, _ *http.Request) error { + apiKey := os.Getenv("GEMINI_API_KEY") + if apiKey == "" { + return fmt.Errorf("missing GEMINI_API_KEY") + } + + query := targetURL.Query() + query.Set("key", apiKey) + targetURL.RawQuery = query.Encode() + return nil + }, + ExtractUsage: extractGeminiUsageMetadata, + } + + openAIProxyConfig := llmProxyConfig{ + Provider: "openai", + BaseURL: "https://api.openai.com", + PathPrefix: "/api/v1/openai", + SetupRequest: func(_ *http.Request, _ *url.URL, proxyReq *http.Request) error { + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + return fmt.Errorf("missing OPENAI_API_KEY") + } + + proxyReq.Header.Set("Authorization", "Bearer "+apiKey) + return nil + }, + ExtractUsage: extractOpenAIUsageMetadata, + } + + anthropicProxyConfig := llmProxyConfig{ + Provider: "anthropic", + BaseURL: "https://api.anthropic.com", + PathPrefix: "/api/v1/anthropic", + SetupRequest: func(_ *http.Request, _ *url.URL, proxyReq *http.Request) error { + apiKey := os.Getenv("ANTHROPIC_API_KEY") + if apiKey == "" { + return fmt.Errorf("missing ANTHROPIC_API_KEY") + } + + version := os.Getenv("ANTHROPIC_VERSION") + if version == "" { + version = "2023-06-01" + } + + proxyReq.Header.Set("x-api-key", apiKey) + proxyReq.Header.Set("anthropic-version", version) + if proxyReq.Header.Get("Content-Type") == "" { + proxyReq.Header.Set("Content-Type", "application/json") + } + return nil + }, + ExtractUsage: extractAnthropicUsageMetadata, + } + + grokProxyConfig := llmProxyConfig{ + Provider: "grok", + BaseURL: "https://api.x.ai", + PathPrefix: "/api/v1/grok", + SetupRequest: func(_ *http.Request, _ *url.URL, proxyReq *http.Request) error { + apiKey := os.Getenv("GROK_API_KEY") + if apiKey == "" { + return fmt.Errorf("missing GROK_API_KEY") + } + + proxyReq.Header.Set("Authorization", "Bearer "+apiKey) + return nil + }, + ExtractUsage: extractGrokUsageMetadata, + } + + // LLM API proxy endpoints geminiProxyPath := "/api/v1/gemini/" - mux.HandleFunc(geminiProxyPath, func(w http.ResponseWriter, r *http.Request) { - geminiProxyHandler(w, r, gormDB) - }) + mux.HandleFunc(geminiProxyPath, newLLMProxyHandler(gormDB, geminiProxyConfig)) slog.Info("serving gemini proxy handler", "path", geminiProxyPath) + openAIProxyPath := "/api/v1/openai/" + mux.HandleFunc(openAIProxyPath, newLLMProxyHandler(gormDB, openAIProxyConfig)) + slog.Info("serving openai proxy handler", "path", openAIProxyPath) + + anthropicProxyPath := "/api/v1/anthropic/" + mux.HandleFunc(anthropicProxyPath, newLLMProxyHandler(gormDB, anthropicProxyConfig)) + slog.Info("serving anthropic proxy handler", "path", anthropicProxyPath) + + grokProxyPath := "/api/v1/grok/" + mux.HandleFunc(grokProxyPath, newLLMProxyHandler(gormDB, grokProxyConfig)) + slog.Info("serving grok proxy handler", "path", grokProxyPath) + slog.Info("serving rpc handler for api v1 service", "path", apiPath) h2Handler := h2c.NewHandler(mux, &http2.Server{}) @@ -153,152 +238,172 @@ func setupDatabase(url, token string) (*gorm.DB, error) { // geminiProxyHandler proxies requests to Google's Generative Language API // Requests to /api/v1/gemini/* are forwarded to https://generativelanguage.googleapis.com/* -func geminiProxyHandler(w http.ResponseWriter, r *http.Request, db *gorm.DB) { - const geminiBaseURL = "https://generativelanguage.googleapis.com" - - // Fast-fail: Authenticate - authHeader := r.Header.Get("Authorization") - token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) - if token == "" { - http.Error(w, "Unauthorized: missing token", http.StatusUnauthorized) - return - } +type llmProxyConfig struct { + Provider string + BaseURL string + PathPrefix string + SetupRequest func(incomingReq *http.Request, targetURL *url.URL, proxyReq *http.Request) error + ExtractUsage func(body []byte) (input int, output int, total int) +} - claims, err := api.ValidateToken(token) - if err != nil { - http.Error(w, "Unauthorized: invalid token", http.StatusUnauthorized) - return - } +// newLLMProxyHandler proxies requests to a configured upstream LLM provider. +func newLLMProxyHandler(db *gorm.DB, config llmProxyConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { - // Rate Limiting - var requestCount int64 - todayUnix := time.Now().Add(-24 * time.Hour).Unix() - if err := db.Model(&api.LLMProxyUsage{}).Where("user_id = ? AND created_at >= ?", claims.UserID, todayUnix).Count(&requestCount).Error; err != nil { - slog.Error("failed to query proxy usage count", "error", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } + // Fast-fail: Authenticate + authHeader := r.Header.Get("Authorization") + token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + if token == "" { + http.Error(w, "Unauthorized: missing token", http.StatusUnauthorized) + return + } - if requestCount >= 5000 { - slog.Warn("user exceeded daily proxy limit", "user_id", claims.UserID) - http.Error(w, "Too Many Requests", http.StatusTooManyRequests) - return - } + claims, err := api.ValidateToken(token) + if err != nil { + http.Error(w, "Unauthorized: invalid token", http.StatusUnauthorized) + return + } - // Strip the proxy prefix to get the target path - targetPath := strings.TrimPrefix(r.URL.Path, "/api/v1/gemini") - if targetPath == "" { - targetPath = "/" - } + // Rate Limiting + var requestCount int64 + todayUnix := time.Now().Add(-24 * time.Hour).Unix() + if err := db.Model(&api.LLMProxyUsage{}).Where("user_id = ? AND created_at >= ?", claims.UserID, todayUnix).Count(&requestCount).Error; err != nil { + slog.Error("failed to query proxy usage count", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } - // Build the target URL - targetURL, err := url.Parse(geminiBaseURL + targetPath) - if err != nil { - slog.Error("failed to parse target URL", "error", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } + if requestCount >= 5000 { + slog.Warn("user exceeded daily proxy limit", "user_id", claims.UserID) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } - // Preserve query parameters and append API key - query := targetURL.Query() - if r.URL.RawQuery != "" { - query, _ = url.ParseQuery(r.URL.RawQuery) - } - query.Set("key", os.Getenv("GEMINI_API_KEY")) - targetURL.RawQuery = query.Encode() + // Strip the proxy prefix to get the target path + targetPath := strings.TrimPrefix(r.URL.Path, config.PathPrefix) + if targetPath == "" { + targetPath = "/" + } + if !strings.HasPrefix(targetPath, "/") { + targetPath = "/" + targetPath + } - slog.Info("proxying request to Gemini API", "method", r.Method, "target", targetURL.String()) + // Build the target URL + targetURL, err := url.Parse(config.BaseURL + targetPath) + if err != nil { + slog.Error("failed to parse target URL", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } - // Create the proxy request - // Create the proxy request using a detached context so the upstream request - // to Google isn't canceled if the client disconnects mid-flight. - proxyReq, err := http.NewRequestWithContext(context.WithoutCancel(r.Context()), r.Method, targetURL.String(), r.Body) - if err != nil { - slog.Error("failed to create proxy request", "error", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } + // Preserve query parameters + targetURL.RawQuery = r.URL.RawQuery - // Copy headers from original request - for key, values := range r.Header { - for _, value := range values { - proxyReq.Header.Add(key, value) + slog.Info("proxying request to LLM API", "provider", config.Provider, "method", r.Method, "target", targetURL.String()) + + // Create the proxy request + // Create the proxy request using a detached context so the upstream request + // to Google isn't canceled if the client disconnects mid-flight. + proxyReq, err := http.NewRequestWithContext(context.WithoutCancel(r.Context()), r.Method, targetURL.String(), r.Body) + if err != nil { + slog.Error("failed to create proxy request", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return } - } - // Remove hop-by-hop headers - proxyReq.Header.Del("Connection") - proxyReq.Header.Del("Keep-Alive") - proxyReq.Header.Del("Proxy-Authenticate") - proxyReq.Header.Del("Proxy-Authorization") - proxyReq.Header.Del("Te") - proxyReq.Header.Del("Trailers") - proxyReq.Header.Del("Transfer-Encoding") - proxyReq.Header.Del("Upgrade") - proxyReq.Header.Del("Authorization") - - // Execute the proxy request - client := &http.Client{Timeout: 120 * time.Second} - resp, err := client.Do(proxyReq) - if err != nil { - slog.Error("failed to execute proxy request", "error", err) - http.Error(w, "Bad Gateway", http.StatusBadGateway) - return - } - defer resp.Body.Close() + // Copy headers from original request + for key, values := range r.Header { + for _, value := range values { + proxyReq.Header.Add(key, value) + } + } - // Copy response headers - for key, values := range resp.Header { - for _, value := range values { - w.Header().Add(key, value) + // Remove hop-by-hop headers + proxyReq.Header.Del("Connection") + proxyReq.Header.Del("Keep-Alive") + proxyReq.Header.Del("Proxy-Authenticate") + proxyReq.Header.Del("Proxy-Authorization") + proxyReq.Header.Del("Te") + proxyReq.Header.Del("Trailers") + proxyReq.Header.Del("Transfer-Encoding") + proxyReq.Header.Del("Upgrade") + proxyReq.Header.Del("Authorization") + + if config.SetupRequest != nil { + if err := config.SetupRequest(r, targetURL, proxyReq); err != nil { + slog.Error("failed to setup provider request", "provider", config.Provider, "error", err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + return + } + proxyReq.URL = targetURL } - } - // Copy the response body - capturedBody, err := io.ReadAll(resp.Body) - if err != nil { - slog.Error("failed to read response body", "error", err) - } + // Execute the proxy request + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(proxyReq) + if err != nil { + slog.Error("failed to execute proxy request", "error", err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + + // Copy the response body + capturedBody, err := io.ReadAll(resp.Body) + if err != nil { + slog.Error("failed to read response body", "error", err) + } - // if the status code is anything >= 400, print an error - if resp.StatusCode >= 400 { - logBody := capturedBody - if resp.Header.Get("Content-Encoding") == "gzip" { - if gr, err := gzip.NewReader(bytes.NewReader(capturedBody)); err == nil { - if decompressed, err := io.ReadAll(gr); err == nil { - logBody = decompressed + // if the status code is anything >= 400, print an error + if resp.StatusCode >= 400 { + logBody := capturedBody + if resp.Header.Get("Content-Encoding") == "gzip" { + if gr, err := gzip.NewReader(bytes.NewReader(capturedBody)); err == nil { + if decompressed, err := io.ReadAll(gr); err == nil { + logBody = decompressed + } } } + slog.Error("proxy request failed", "status code", resp.StatusCode, "body", string(logBody)) } - slog.Error("proxy request failed", "status code", resp.StatusCode, "body", string(logBody)) - } - // Set the status code - w.WriteHeader(resp.StatusCode) + // Set the status code + w.WriteHeader(resp.StatusCode) - // Synchronously parse tokens and save usage - inputTokens, outputTokens, totalTokens := extractUsageMetadata(capturedBody) + // Synchronously parse tokens and save usage + inputTokens, outputTokens, totalTokens := 0, 0, 0 + if config.ExtractUsage != nil { + inputTokens, outputTokens, totalTokens = config.ExtractUsage(capturedBody) + } - usage := api.LLMProxyUsage{ - UserID: claims.UserID, - CreatedAt: time.Now().Unix(), - Provider: "gemini", - InputTokens: inputTokens, - OutputTokens: outputTokens, - TotalTokens: totalTokens, - } + usage := api.LLMProxyUsage{ + UserID: claims.UserID, + CreatedAt: time.Now().Unix(), + Provider: config.Provider, + InputTokens: inputTokens, + OutputTokens: outputTokens, + TotalTokens: totalTokens, + } - if err := db.Create(&usage).Error; err != nil { - slog.Error("failed to save LLM proxy usage log", "error", err) - } + if err := db.Create(&usage).Error; err != nil { + slog.Error("failed to save LLM proxy usage log", "error", err) + } - // Write the captured body back to the client - if _, err := w.Write(capturedBody); err != nil { - slog.Error("failed to write response body", "error", err) + // Write the captured body back to the client + if _, err := w.Write(capturedBody); err != nil { + slog.Error("failed to write response body", "error", err) + } } } -func extractUsageMetadata(body []byte) (input int, output int, total int) { +func extractGeminiUsageMetadata(body []byte) (input int, output int, total int) { type geminiResponse struct { UsageMetadata struct { PromptTokenCount int `json:"promptTokenCount"` @@ -341,3 +446,93 @@ func extractUsageMetadata(body []byte) (input int, output int, total int) { return input, output, total } + +func extractOpenAIUsageMetadata(body []byte) (input int, output int, total int) { + type openAIResponse struct { + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + } + + // Try to parse the whole body as a single JSON object (non-streaming) + var resp openAIResponse + if err := json.Unmarshal(body, &resp); err == nil && resp.Usage.TotalTokens > 0 { + return resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens + } + + // If it failed or has 0 tokens, it might be an SSE stream. + // SSE chunks start with "data: " and end with "\n\n" + scanner := bufio.NewScanner(bytes.NewReader(body)) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if after, ok := strings.CutPrefix(line, "data: "); ok { + jsonStr := strings.TrimSpace(after) + if jsonStr == "" || jsonStr == "[DONE]" { + continue + } + var streamResp openAIResponse + if err := json.Unmarshal([]byte(jsonStr), &streamResp); err == nil { + if streamResp.Usage.TotalTokens > total { + input = streamResp.Usage.PromptTokens + output = streamResp.Usage.CompletionTokens + total = streamResp.Usage.TotalTokens + } + } + } + } + + return input, output, total +} + +func extractAnthropicUsageMetadata(body []byte) (input int, output int, total int) { + type anthropicResponse struct { + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + } + + // Try to parse the whole body as a single JSON object (non-streaming) + var resp anthropicResponse + if err := json.Unmarshal(body, &resp); err == nil { + totalTokens := resp.Usage.InputTokens + resp.Usage.OutputTokens + if totalTokens > 0 { + return resp.Usage.InputTokens, resp.Usage.OutputTokens, totalTokens + } + } + + // If it failed or has 0 tokens, it might be an SSE stream. + scanner := bufio.NewScanner(bytes.NewReader(body)) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "data: ") { + jsonStr := strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + if jsonStr == "" || jsonStr == "[DONE]" { + continue + } + var streamResp anthropicResponse + if err := json.Unmarshal([]byte(jsonStr), &streamResp); err == nil { + streamTotal := streamResp.Usage.InputTokens + streamResp.Usage.OutputTokens + if streamTotal > total { + input = streamResp.Usage.InputTokens + output = streamResp.Usage.OutputTokens + total = streamTotal + } + } + } + } + + return input, output, total +} + +func extractGrokUsageMetadata(body []byte) (input int, output int, total int) { + return extractOpenAIUsageMetadata(body) +} diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index c5755cf..3b1bfec 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -6,7 +6,7 @@ import { } from "@tabler/icons-react"; import { Link, useMatchRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; -import { GetVersion } from "../../bindings/github.com/focusd-so/focusd/internal/settings/service"; +import { GetCurrentVersion } from "../../bindings/github.com/focusd-so/focusd/internal/updater/service"; import { Sidebar, @@ -44,7 +44,7 @@ export function AppSidebar() { const matchRoute = useMatchRoute(); const { data: version } = useQuery({ queryKey: ["app-version"], - queryFn: GetVersion, + queryFn: () => (import.meta.env.DEV ? Promise.resolve("dev") : GetCurrentVersion()), }); return ( diff --git a/frontend/src/components/custom-rules.tsx b/frontend/src/components/custom-rules.tsx index 7c24397..c2194ab 100644 --- a/frontend/src/components/custom-rules.tsx +++ b/frontend/src/components/custom-rules.tsx @@ -7,15 +7,7 @@ import { useAccountStore } from "@/stores/account-store"; import { DeviceHandshakeResponse_AccountTier } from "../../bindings/github.com/focusd-so/focusd/gen/api/v1/models"; import { Browser } from "@wailsio/runtime"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { IconDeviceFloppy, IconHistory, IconFileText, IconTerminal, IconTestPipe, IconCrown } from "@tabler/icons-react"; +import { IconDeviceFloppy, IconFileText, IconTerminal, IconTestPipe, IconCrown } from "@tabler/icons-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { ExecutionLogsSheet } from "@/components/execution-logs"; @@ -315,22 +307,10 @@ export function terminationMode(ctx: Context): TerminationDecision | undefined { } `; -function formatDate(timestamp: number): string { - const date = new Date(timestamp * 1000); - return date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - export function CustomRules() { const { customRules, - customRulesHistory, updateSetting, - fetchCustomRulesHistory, } = useSettingsStore(); const { checkoutLink, fetchAccountTier } = useAccountStore(); @@ -440,17 +420,6 @@ export function CustomRules() { [] ); - const handleHistoryOpen = (open: boolean) => { - if (open) { - fetchCustomRulesHistory(10); - } - }; - - const handleRestoreVersion = (value: string) => { - setDraft(value); - toast.info("Version restored. Click Save to apply changes."); - }; - const handleEditorWillMount = useCallback((monaco: Monaco) => { monacoRef.current = monaco; @@ -513,41 +482,6 @@ export function CustomRules() {
- - - - - - Version History - - {customRulesHistory.length === 0 ? ( - No history available - ) : ( - customRulesHistory.map((version, index) => ( - handleRestoreVersion(version.value)} - className="flex flex-col items-start gap-0.5 py-2" - > -
- Version {version.version} - - {index === 0 && version.value === customRules - ? "CURRENT" - : formatDate(version.created_at)} - -
-
- )) - )} -
-
-
- + {!import.meta.env.DEV && }
diff --git a/internal/native/darwin.go b/internal/native/darwin.go index 659ec99..ee914c0 100644 --- a/internal/native/darwin.go +++ b/internal/native/darwin.go @@ -418,7 +418,7 @@ func getPlatformSerial() (string, error) { return strings.TrimSpace(string(serialOut)), nil } -func BlockURL(targetURL, title, reason string, tags []string, appName string) error { +func BlockURL(targetURL, title string, reason string, tags []string, appName string) error { data := struct { URL string `json:"url"` diff --git a/internal/usage/harness_test.go b/internal/usage/harness_test.go index 18ebbf6..74e5d4c 100644 --- a/internal/usage/harness_test.go +++ b/internal/usage/harness_test.go @@ -29,7 +29,7 @@ type usageHarness struct { db *gorm.DB mu sync.Mutex - usageEvents []usage.ApplicationUsage + usageEvents []*usage.ApplicationUsage pausedEvents []usage.ProtectionPause resumedEvents []usage.ProtectionPause appBlockerEvents []appBlockerEvent @@ -107,7 +107,7 @@ func newUsageHarness(t *testing.T, opts ...usageHarnessOption) *usageHarness { ) require.NoError(t, err) - h.service.OnUsageUpdated(func(appUsage usage.ApplicationUsage) { + h.service.OnUsageUpdated(func(appUsage *usage.ApplicationUsage) { h.mu.Lock() defer h.mu.Unlock() h.usageEvents = append(h.usageEvents, appUsage) @@ -137,7 +137,8 @@ func memoryDSNForHarness(t *testing.T) string { func (h *usageHarness) TitleChanged(appName, windowTitle string, browserURL *string) *usageHarness { h.t.Helper() h.retryLocked(func() error { - return h.service.TitleChanged(context.Background(), appName, windowTitle, appName, "", nil, browserURL, nil) + _, err := h.service.TitleChanged(context.Background(), appName, windowTitle, appName, "", nil, browserURL, nil) + return err }) return h } @@ -151,7 +152,8 @@ func (h *usageHarness) Await(dur time.Duration) *usageHarness { func (h *usageHarness) TitleChangedRaw(executablePath, windowTitle, appName, icon string, bundleID, browserURL, appCategory *string) *usageHarness { h.t.Helper() h.retryLocked(func() error { - return h.service.TitleChanged(context.Background(), executablePath, windowTitle, appName, icon, bundleID, browserURL, appCategory) + _, err := h.service.TitleChanged(context.Background(), executablePath, windowTitle, appName, icon, bundleID, browserURL, appCategory) + return err }) return h } @@ -165,7 +167,8 @@ func (h *usageHarness) EnterIdle() *usageHarness { func (h *usageHarness) IdleChanged(isIdle bool) *usageHarness { h.t.Helper() h.retryLocked(func() error { - return h.service.IdleChanged(context.Background(), isIdle) + _, err := h.service.IdleChanged(context.Background(), isIdle) + return err }) return h } @@ -296,6 +299,16 @@ func (h *usageHarness) AssertBlockerEventsCount(count int) *usageHarness { return h } +func (h *usageHarness) AssertBlockerLastEvent(check ...func(event *appBlockerEvent)) *usageHarness { + h.t.Helper() + + for _, c := range check { + c(&h.appBlockerEvents[len(h.appBlockerEvents)-1]) + } + + return h +} + func (h *usageHarness) AssertUsagesCount(count int) *usageHarness { h.t.Helper() @@ -335,10 +348,10 @@ func (h *usageHarness) retryLocked(fn func() error) { } } -func (h *usageHarness) UsageEvents() []usage.ApplicationUsage { +func (h *usageHarness) UsageEvents() []*usage.ApplicationUsage { h.mu.Lock() defer h.mu.Unlock() - return append([]usage.ApplicationUsage(nil), h.usageEvents...) + return append([]*usage.ApplicationUsage(nil), h.usageEvents...) } func (h *usageHarness) ResetUsageEvents() *usageHarness { diff --git a/internal/usage/insights_basic_test.go b/internal/usage/insights_basic_test.go index 64fbac2..7fbfda9 100644 --- a/internal/usage/insights_basic_test.go +++ b/internal/usage/insights_basic_test.go @@ -9,11 +9,6 @@ import ( "github.com/focusd-so/focusd/internal/usage" ) -// timePtr returns a pointer to the given time. -func timePtr(t time.Time) *time.Time { return &t } - -func terminationModePtr(mode usage.TerminationMode) *usage.TerminationMode { return &mode } - func TestGetUsageList_NoFilters(t *testing.T) { service, db := setUpService(t) @@ -69,7 +64,7 @@ func TestGetUsageList_StartedAtFilter(t *testing.T) { // Filter: startedAt >= 12:00 → should return usages at 14:00 and 18:00 startedAt := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) result, err := service.GetUsageList(usage.GetUsageListOptions{ - StartedAt: timePtr(startedAt), + StartedAt: withPtr(startedAt), }) require.NoError(t, err) require.Len(t, result, 2, "only usages starting at or after 12:00 should be returned") @@ -106,7 +101,7 @@ func TestGetUsageList_EndedAtFilter(t *testing.T) { // Filter: endedAt <= 11:00 → should return u0 (ends 08:30) and u1 (ends 10:45) endedAt := time.Date(2025, 6, 15, 11, 0, 0, 0, time.UTC) result, err := service.GetUsageList(usage.GetUsageListOptions{ - EndedAt: timePtr(endedAt), + EndedAt: withPtr(endedAt), }) require.NoError(t, err) require.Len(t, result, 2, "only usages ending at or before 11:00 should be returned") @@ -146,8 +141,8 @@ func TestGetUsageList_StartedAtAndEndedAtCombined(t *testing.T) { endedAt := time.Date(2025, 6, 15, 15, 0, 0, 0, time.UTC) result, err := service.GetUsageList(usage.GetUsageListOptions{ - StartedAt: timePtr(startedAt), - EndedAt: timePtr(endedAt), + StartedAt: withPtr(startedAt), + EndedAt: withPtr(endedAt), }) require.NoError(t, err) require.Len(t, result, 2, "only usages within the [09:00, 15:00] window should be returned") @@ -175,7 +170,7 @@ func TestGetUsageList_StartedAtExactBoundary(t *testing.T) { // startedAt filter equals the exact start time → should include the usage (>=) result, err := service.GetUsageList(usage.GetUsageListOptions{ - StartedAt: timePtr(startAt), + StartedAt: withPtr(startAt), }) require.NoError(t, err) require.Len(t, result, 1, "usage starting at exact boundary should be included (>=)") @@ -201,7 +196,7 @@ func TestGetUsageList_EndedAtExactBoundary(t *testing.T) { // endedAt filter equals the exact end time → should include the usage (<=) result, err := service.GetUsageList(usage.GetUsageListOptions{ - EndedAt: timePtr(endAt), + EndedAt: withPtr(endAt), }) require.NoError(t, err) require.Len(t, result, 1, "usage ending at exact boundary should be included (<=)") @@ -320,7 +315,7 @@ func TestGetUsageList_DateFilter(t *testing.T) { // Filter by day1 only filterDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC) result, err := service.GetUsageList(usage.GetUsageListOptions{ - Date: timePtr(filterDate), + Date: withPtr(filterDate), }) require.NoError(t, err) require.Len(t, result, 1, "only usages on 2025-06-15 should be returned") @@ -346,7 +341,7 @@ func TestGetUsageList_DateFilterNoMatches(t *testing.T) { // Filter by a date with no usages noMatchDate := time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC) result, err := service.GetUsageList(usage.GetUsageListOptions{ - Date: timePtr(noMatchDate), + Date: withPtr(noMatchDate), }) require.NoError(t, err) require.Len(t, result, 0, "no usages should match a date with no data") @@ -478,8 +473,8 @@ func TestGetUsageList_DateCombinedWithStartedAtAndEndedAt(t *testing.T) { // → only u1 (14:00) should match; u0 is before 10:00 and other-day is excluded by Date startedAt := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC) result, err := service.GetUsageList(usage.GetUsageListOptions{ - Date: timePtr(day), - StartedAt: timePtr(startedAt), + Date: withPtr(day), + StartedAt: withPtr(startedAt), }) require.NoError(t, err) require.Len(t, result, 1) @@ -521,8 +516,8 @@ func TestGetUsageList_NoMatchesWithNarrowWindow(t *testing.T) { endedAt := time.Date(2025, 6, 15, 13, 0, 0, 0, time.UTC) result, err := service.GetUsageList(usage.GetUsageListOptions{ - StartedAt: timePtr(startedAt), - EndedAt: timePtr(endedAt), + StartedAt: withPtr(startedAt), + EndedAt: withPtr(endedAt), }) require.NoError(t, err) require.Len(t, result, 0, "no usages should match a narrow window with no data") @@ -541,7 +536,6 @@ func TestGetUsageList_TerminationModeFilter(t *testing.T) { usage.TerminationModeNone, usage.TerminationModeBlock, usage.TerminationModeAllow, - usage.TerminationModeNone, } dur := 1800 for i := range starts { @@ -558,7 +552,7 @@ func TestGetUsageList_TerminationModeFilter(t *testing.T) { } result, err := service.GetUsageList(usage.GetUsageListOptions{ - TerminationMode: terminationModePtr(usage.TerminationModeBlock), + TerminationMode: withPtr(usage.TerminationModeBlock), }) require.NoError(t, err) require.Len(t, result, 1, "only blocked items should be returned") @@ -583,7 +577,7 @@ func TestGetUsageList_TerminationModeFilterNoMatches(t *testing.T) { require.NoError(t, db.Create(&u).Error) result, err := service.GetUsageList(usage.GetUsageListOptions{ - TerminationMode: terminationModePtr(usage.TerminationModeBlock), + TerminationMode: withPtr(usage.TerminationModeBlock), }) require.NoError(t, err) require.Len(t, result, 0, "no rows should match an unused termination mode") @@ -619,9 +613,9 @@ func TestGetUsageList_TerminationModeFilterCombinedWithDateRange(t *testing.T) { startedAt := time.Date(2025, 6, 15, 9, 0, 0, 0, time.UTC) endedAt := time.Date(2025, 6, 15, 16, 0, 0, 0, time.UTC) result, err := service.GetUsageList(usage.GetUsageListOptions{ - StartedAt: timePtr(startedAt), - EndedAt: timePtr(endedAt), - TerminationMode: terminationModePtr(usage.TerminationModeBlock), + StartedAt: withPtr(startedAt), + EndedAt: withPtr(endedAt), + TerminationMode: withPtr(usage.TerminationModeBlock), }) require.NoError(t, err) require.Len(t, result, 1, "combined filters should return only the matching intersection") diff --git a/internal/usage/protection.go b/internal/usage/protection.go index e5a661d..7779d72 100644 --- a/internal/usage/protection.go +++ b/internal/usage/protection.go @@ -172,6 +172,10 @@ func (s *Service) GetPauseHistory(days int) ([]ProtectionPause, error) { // Side effects: // - Creates a ProtectionWhitelist record in the database with expiration timestamp func (s *Service) Whitelist(appname string, url string, duration time.Duration) error { + if duration <= 0 { + return nil + } + now := time.Now().Unix() expiresAt := now + int64(duration.Seconds()) diff --git a/internal/usage/service.go b/internal/usage/service.go index 5de5f59..7421385 100644 --- a/internal/usage/service.go +++ b/internal/usage/service.go @@ -21,7 +21,7 @@ type Service struct { onProtectionPaused []func(pause ProtectionPause) onProtectionResumed []func(pause ProtectionPause) onLLMDailySummaryReady []func(summary LLMDailySummary) - onUsageUpdated []func(usage ApplicationUsage) + onUsageUpdated []func(usage *ApplicationUsage) // mu serializes title change processing to prevent race conditions // when multiple events fire concurrently diff --git a/internal/usage/service_events.go b/internal/usage/service_events.go index be8456e..3c589c4 100644 --- a/internal/usage/service_events.go +++ b/internal/usage/service_events.go @@ -1,7 +1,7 @@ package usage // OnUsageUpdated subscribes a callback to the usage updated event. -func (s *Service) OnUsageUpdated(fn func(usage ApplicationUsage)) { +func (s *Service) OnUsageUpdated(fn func(usage *ApplicationUsage)) { s.eventsMu.Lock() defer s.eventsMu.Unlock() s.onUsageUpdated = append(s.onUsageUpdated, fn) diff --git a/internal/usage/service_usage.go b/internal/usage/service_usage.go index 31b353f..0398841 100644 --- a/internal/usage/service_usage.go +++ b/internal/usage/service_usage.go @@ -23,43 +23,48 @@ import ( // - if user has been idle and idle triggers again, keep the current idle usage open to ensure idempotency // - if user has not been idle and idle triggers, close the current application usage and open a new idle usage // - if user has been idle and idle stops, no direct usage change is performed here; the next TitleChanged event closes idle -func (s *Service) IdleChanged(ctx context.Context, isIdle bool) error { +func (s *Service) IdleChanged(ctx context.Context, isIdle bool) (*ApplicationUsage, error) { s.mu.Lock() defer s.mu.Unlock() if isIdle { application, err := s.getOrCreateApplication(ctx, IdleApplicationName, "", nil, nil, nil) if err != nil { - return fmt.Errorf("failed to get or create application: %w", err) + return nil, fmt.Errorf("failed to get or create application: %w", err) } return s.usageChanged(ctx, application.NewUsage("", nil)) } - return nil + return nil, nil } // TitleChanged is called when the title of the current application changes, // whether it's a new application or the same application title has changed -func (s *Service) TitleChanged(ctx context.Context, executablePath, windowTitle, appName, icon string, bundleID, url, appCategory *string) error { +func (s *Service) TitleChanged(ctx context.Context, executablePath, windowTitle, appName, icon string, bundleID, browserURL, appCategory *string) (*ApplicationUsage, error) { s.mu.Lock() defer s.mu.Unlock() - parsed, _ := parseURLNormalized(fromPtr(url)) - url = withPtr(parsed.String()) + var normalizedBrowserURL *string + if browserURL != nil { + parsed, _ := parseURLNormalized(fromPtr(browserURL)) + normalizedBrowserURL = withPtr(parsed.String()) + } - application, err := s.getOrCreateApplication(ctx, appName, icon, bundleID, url, appCategory) + application, err := s.getOrCreateApplication(ctx, appName, icon, bundleID, normalizedBrowserURL, appCategory) if err != nil { - return fmt.Errorf("failed to get or create application: %w", err) + return nil, fmt.Errorf("failed to get or create application: %w", err) } - return s.usageChanged(ctx, application.NewUsage(windowTitle, url)) + usage, err := s.usageChanged(ctx, application.NewUsage(windowTitle, normalizedBrowserURL)) + + return usage, err } -func (s *Service) usageChanged(ctx context.Context, usage ApplicationUsage) error { +func (s *Service) usageChanged(ctx context.Context, usage ApplicationUsage) (*ApplicationUsage, error) { currentApplicationUsage, err := s.getCurrentApplicationUsage() if err != nil { - return fmt.Errorf("failed to find current application usage: %w", err) + return nil, fmt.Errorf("failed to find current application usage: %w", err) } // if the current application and new application usage are the same, @@ -68,17 +73,17 @@ func (s *Service) usageChanged(ctx context.Context, usage ApplicationUsage) erro usage = currentApplicationUsage } else { // if new application usage is detected close the current application usage before creating a new one - if err := s.closeApplicationUsage(currentApplicationUsage); err != nil { - return fmt.Errorf("failed to close current application usage: %w", err) + if err := s.closeApplicationUsage(¤tApplicationUsage); err != nil { + return nil, fmt.Errorf("failed to close current application usage: %w", err) } if err := s.saveApplicationUsage(&usage); err != nil { - return fmt.Errorf("failed to save application usage: %w", err) + return nil, fmt.Errorf("failed to save application usage: %w", err) } } if usage.Application.Name == IdleApplicationName { - return nil + return &usage, nil } classification, err := s.classifyApplicationUsage(ctx, &usage) @@ -120,18 +125,16 @@ func (s *Service) usageChanged(ctx context.Context, usage ApplicationUsage) erro usage.TerminationSource = withPtr(terminationMode.Source) if err := s.db.Save(&usage).Error; err != nil { - return fmt.Errorf("failed to save application usage: %w", err) + return nil, fmt.Errorf("failed to save application usage: %w", err) } if usage.TerminationMode == TerminationModeBlock { - termReason := fromPtr(usage.ClassificationReasoning) - tags := ApplicationTagsSlice(usage.Tags).Tags() - usage.EndedAt = withPtr(time.Now().Unix()) - - go func() { s.appBlocker(usage.Application.Name, usage.WindowTitle, termReason, tags, usage.BrowserURL) }() + if err := s.closeApplicationUsage(&usage); err != nil { + return nil, fmt.Errorf("failed to close application usage: %w", err) + } } - return s.saveApplicationUsage(&usage) + return &usage, s.saveApplicationUsage(&usage) } func (s *Service) saveApplicationUsage(applicationUsage *ApplicationUsage) error { @@ -141,7 +144,7 @@ func (s *Service) saveApplicationUsage(applicationUsage *ApplicationUsage) error s.eventsMu.RLock() for _, fn := range s.onUsageUpdated { - fn(*applicationUsage) + fn(applicationUsage) } s.eventsMu.RUnlock() @@ -282,7 +285,7 @@ func (s *Service) getCurrentApplicationUsage() (ApplicationUsage, error) { return application, nil } -func (s *Service) closeApplicationUsage(app ApplicationUsage) error { +func (s *Service) closeApplicationUsage(app *ApplicationUsage) error { if app.ID == 0 { return nil } diff --git a/internal/usage/service_usage_test.go b/internal/usage/service_usage_test.go index 28ce9ea..a8135d6 100644 --- a/internal/usage/service_usage_test.go +++ b/internal/usage/service_usage_test.go @@ -86,7 +86,13 @@ func TestService_ProtectionPauseAndWhitelisting(t *testing.T) { assertTerminationModeSource(t, usage.TerminationModeSourceApplication), ). AssertUpdateEventsCount(2). - AssertBlockerEventsCount(1) + AssertBlockerEventsCount(1). + AssertBlockerLastEvent( + func(event *appBlockerEvent) { + require.Equal(t, "Chrome", event.AppName) + require.Equal(t, "https://www.amazon.com", fromPtr(event.BrowserURL)) + }, + ) // user pauses all protection and opens amazon and linkedin, should not be blocked h. @@ -211,4 +217,87 @@ func TestService_ProtectionPauseAndWhitelisting(t *testing.T) { assertTerminationMode(t, usage.TerminationModeBlock), assertTerminationModeSource(t, usage.TerminationModeSourceApplication), ) + + // 6. Pause Expiry While Whitelist Is Still Active + h. + Await(250*time.Millisecond). + ResetBlockerEvents(). + ResetUsageEvents(). + Pause(3, "pause shorter than whitelist"). + Whitelist("Chrome", "https://www.amazon.com", 7*time.Second). + TitleChanged("Chrome", "Amazon", withPtr("https://www.amazon.com")). + AssertLastUsage( + assertTerminationMode(t, usage.TerminationModeAllow), + assertTerminationModeSource(t, usage.TerminationModeSourcePaused), + ). + AssertBlockerEventsCount(0). + Await(4*time.Second). + TitleChanged("Chrome", "Amazon", withPtr("https://www.amazon.com")). + AssertLastUsage( + assertTerminationMode(t, usage.TerminationModeAllow), + assertTerminationModeSource(t, usage.TerminationModeSourceWhitelist), + ). + AssertBlockerEventsCount(0) + + // 7. Whitelist Expiry While Pause Is Still Active + h. + ResetBlockerEvents(). + ResetUsageEvents(). + Pause(8, "pause longer than whitelist"). + Whitelist("Chrome", "https://www.amazon.com", 2*time.Second). + Await(3*time.Second). + TitleChanged("Chrome", "Amazon", withPtr("https://www.amazon.com")). + AssertLastUsage( + assertTerminationMode(t, usage.TerminationModeAllow), + assertTerminationModeSource(t, usage.TerminationModeSourcePaused), + ). + AssertBlockerEventsCount(0). + Await(6*time.Second). + TitleChanged("Chrome", "Amazon", withPtr("https://www.amazon.com")). + AssertLastUsage( + assertTerminationMode(t, usage.TerminationModeBlock), + assertTerminationModeSource(t, usage.TerminationModeSourceApplication), + ). + AssertBlockerEventsCount(1) + + // 8. Manual Resume Does Not Clear Active Whitelist + h. + Await(250*time.Millisecond). + ResetBlockerEvents(). + ResetUsageEvents(). + Whitelist("Chrome", "https://www.amazon.com", 10*time.Second). + Pause(10, "manual resume should preserve whitelist"). + Resume("user resumed protection manually"). + TitleChanged("Chrome", "Amazon", withPtr("https://www.amazon.com")). + AssertLastUsage( + assertTerminationMode(t, usage.TerminationModeAllow), + assertTerminationModeSource(t, usage.TerminationModeSourceWhitelist), + ). + AssertBlockerEventsCount(0). + TitleChanged("Chrome", "Linkedin", withPtr("https://www.linkedin.com")). + AssertLastUsage( + assertTerminationMode(t, usage.TerminationModeBlock), + assertTerminationModeSource(t, usage.TerminationModeSourceApplication), + ). + AssertBlockerEventsCount(1) + + // 9. Quick-Allow Input Shape Parity (hostname vs full URL) + h. + Await(250*time.Millisecond). + ResetBlockerEvents(). + ResetUsageEvents(). + RemoveActiveWhitelists(). + Whitelist("Chrome", "amazon.com", 6*time.Second). + TitleChanged("Chrome", "Amazon", withPtr("https://www.amazon.com")). + AssertLastUsage( + assertTerminationMode(t, usage.TerminationModeAllow), + assertTerminationModeSource(t, usage.TerminationModeSourceWhitelist), + ). + AssertBlockerEventsCount(0). + TitleChanged("Chrome", "Amazon", withPtr("https://amazon.com")). + AssertLastUsage( + assertTerminationMode(t, usage.TerminationModeAllow), + assertTerminationModeSource(t, usage.TerminationModeSourceWhitelist), + ). + AssertBlockerEventsCount(0) } diff --git a/main.go b/main.go index 5c44362..b2b7b6f 100644 --- a/main.go +++ b/main.go @@ -117,6 +117,9 @@ func main() { usageService, err := usage.NewService( ctx, db, usage.WithAppBlocker(func(appName, title, reason string, tags []string, browserURL *string) { + + slog.Info("blocking app", "appName", appName, "title", title, "reason", reason, "tags", tags, "browserURL", browserURL) + client := extension.HasClient(appName) // if an extension has been connected to handle app, they should take care of blocking the app @@ -170,7 +173,7 @@ func main() { category = &event.AppCategory } - err := usageService.TitleChanged( + appUsage, err := usageService.TitleChanged( ctx, event.ExecutablePath, event.Title, @@ -183,6 +186,30 @@ func main() { if err != nil { slog.Error("failed to handle title change", "error", err) } + + if appUsage.TerminationMode == usage.TerminationModeBlock { + tags := usage.ApplicationTagsSlice(appUsage.Tags).Tags() + reasoning := "" + if appUsage.ClassificationReasoning != nil { + reasoning = *appUsage.ClassificationReasoning + } + + if url != nil { + slog.Info("browser url provided, blocking url", "url", *url) + if err := native.BlockURL(*url, event.Title, reasoning, tags, event.AppName); err != nil { + slog.Error("failed to block URL", "url", *url, "error", err) + + return + } + } + + if err := native.BlockApp(event.AppName, event.Title, reasoning, tags); err != nil { + slog.Error("failed to block app", "appName", event.AppName, "error", err) + + return + } + } + }) var updaterService *updater.Service @@ -235,8 +262,10 @@ func main() { wailsApp.OnShutdown(cancel) - usageService.OnUsageUpdated(func(appUsage usage.ApplicationUsage) { - wailsApp.Event.Emit("usage:update", appUsage) + usageService.OnUsageUpdated(func(appUsage *usage.ApplicationUsage) { + if appUsage != nil { + wailsApp.Event.Emit("usage:update", appUsage) + } }) native.OnIdleChange(func(idleSeconds float64) { From 2145aaf43dcdf878a0b8dcdaef57de471ac8e491 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Fri, 20 Mar 2026 13:44:25 +0000 Subject: [PATCH 11/11] refactor(usage): remove AppBlocker callback and clean up tests - Removed AppBlocker callback mechanism from usage.Service.\n- Simplified usage.NewService in main.go and tests.\n- Cleaned up usageHarness and tests by removing blocker event assertions.\n- Updated usage:update event registration to use pointer type. --- .../wailsapp/wails/v3/internal/eventcreate.js | 3 +- .../wailsapp/wails/v3/internal/eventdata.d.ts | 2 +- .../components/pause-confirmation-dialog.tsx | 224 ++++++++++++++++-- internal/usage/harness_test.go | 64 +---- internal/usage/service_option.go | 7 - internal/usage/service_usage_test.go | 46 +--- main.go | 33 +-- 7 files changed, 225 insertions(+), 154 deletions(-) diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js index 444cb31..493eb33 100644 --- a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js @@ -14,7 +14,7 @@ function configure() { Object.freeze(Object.assign($Create.Events, { "daily-summary:ready": $$createType0, "protection:status": $$createType1, - "usage:update": $$createType2, + "usage:update": $$createType3, })); } @@ -22,5 +22,6 @@ function configure() { const $$createType0 = usage$0.LLMDailySummary.createFrom; const $$createType1 = usage$0.ProtectionPause.createFrom; const $$createType2 = usage$0.ApplicationUsage.createFrom; +const $$createType3 = $Create.Nullable($$createType2); configure(); diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts index 83e18a2..1c39dfd 100644 --- a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -15,7 +15,7 @@ declare module "@wailsio/runtime" { "authctx:updated": any; "daily-summary:ready": usage$0.LLMDailySummary; "protection:status": usage$0.ProtectionPause; - "usage:update": usage$0.ApplicationUsage; + "usage:update": usage$0.ApplicationUsage | null; } } } diff --git a/frontend/src/components/pause-confirmation-dialog.tsx b/frontend/src/components/pause-confirmation-dialog.tsx index 39828c0..e2ea9ab 100644 --- a/frontend/src/components/pause-confirmation-dialog.tsx +++ b/frontend/src/components/pause-confirmation-dialog.tsx @@ -2,10 +2,12 @@ import { useState, useMemo, useEffect } from "react"; import { useNavigate } from "@tanstack/react-router"; import { IconAlertTriangle, + IconCalendar, IconClock, IconShieldOff, IconBulb, } from "@tabler/icons-react"; +import { format } from "date-fns"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -16,7 +18,21 @@ import { DialogTitle, DialogDescription, } from "@/components/ui/dialog"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useUsageStore } from "@/stores/usage-store"; +import { cn } from "@/lib/utils"; interface PauseConfirmationDialogProps { open: boolean; @@ -29,6 +45,50 @@ const PAUSE_OPTIONS = [ { label: "1 hour", value: 60 }, ]; +const TIME_OPTIONS = Array.from({ length: 96 }, (_, index) => { + const hours = Math.floor(index / 4); + const minutes = (index % 4) * 15; + const value = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; + const label = new Date(2024, 0, 1, hours, minutes).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }); + + return { value, label }; +}); + +function getNextQuarterHourValue(date = new Date()): string { + const next = new Date(date); + next.setSeconds(0, 0); + next.setMinutes(Math.ceil(next.getMinutes() / 15) * 15); + + if (next.getMinutes() === 60) { + next.setHours(next.getHours() + 1, 0, 0, 0); + } + + return `${String(next.getHours()).padStart(2, "0")}:${String(next.getMinutes()).padStart(2, "0")}`; +} + +function formatPauseDuration(totalMinutes: number): string { + const days = Math.floor(totalMinutes / (60 * 24)); + const hours = Math.floor((totalMinutes % (60 * 24)) / 60); + const minutes = totalMinutes % 60; + + const parts: string[] = []; + + if (days > 0) { + parts.push(`${days} day${days === 1 ? "" : "s"}`); + } + if (hours > 0) { + parts.push(`${hours} hour${hours === 1 ? "" : "s"}`); + } + if (days === 0 && minutes > 0) { + parts.push(`${minutes} minute${minutes === 1 ? "" : "s"}`); + } + + return parts.join(" "); +} + export function PauseConfirmationDialog({ open, onOpenChange, @@ -39,20 +99,51 @@ export function PauseConfirmationDialog({ const pauseHistory = useUsageStore((state) => state.pauseHistory); const getPauseHistory = useUsageStore((state) => state.getPauseHistory); const navigate = useNavigate(); + const [pauseMode, setPauseMode] = useState<"duration" | "until">("duration"); const [selectedDuration, setSelectedDuration] = useState(null); + const [pauseUntilDate, setPauseUntilDate] = useState(undefined); + const [pauseUntilTime, setPauseUntilTime] = useState(""); const [isPausing, setIsPausing] = useState(false); const [allowingKey, setAllowingKey] = useState(null); + const resetPauseForm = () => { + setPauseMode("duration"); + setSelectedDuration(null); + setPauseUntilDate(undefined); + setPauseUntilTime(""); + }; + // Fetch pause history for the last 30 days when the dialog opens // and reset selected duration when the dialog is closed. useEffect(() => { if (open) { getPauseHistory(30); } else { - setSelectedDuration(null); + resetPauseForm(); } }, [open, getPauseHistory]); + const pauseUntilDateTime = useMemo(() => { + if (!pauseUntilDate || !pauseUntilTime) return null; + const [hours, minutes] = pauseUntilTime.split(":").map(Number); + + if (Number.isNaN(hours) || Number.isNaN(minutes)) return null; + + const date = new Date(pauseUntilDate); + date.setHours(hours, minutes, 0, 0); + return date; + }, [pauseUntilDate, pauseUntilTime]); + + const pauseUntilDurationMinutes = useMemo(() => { + if (!pauseUntilDateTime) return null; + return Math.ceil((pauseUntilDateTime.getTime() - Date.now()) / (1000 * 60)); + }, [pauseUntilDateTime]); + + const canConfirmPause = + pauseMode === "duration" + ? !!selectedDuration + : !!pauseUntilDurationMinutes && pauseUntilDurationMinutes > 0; + // Get last 2 blocked items to show as quick allow options const blockedItems = getBlockedItemsList().slice(0, 2); @@ -80,13 +171,21 @@ export function PauseConfirmationDialog({ }, [pauseHistory]); const handleConfirmPause = async () => { - if (!selectedDuration) return; + let durationMinutes: number | null = null; + + if (pauseMode === "duration") { + durationMinutes = selectedDuration; + } else if (pauseUntilDateTime) { + durationMinutes = Math.ceil((pauseUntilDateTime.getTime() - Date.now()) / (1000 * 60)); + } + + if (!durationMinutes || durationMinutes <= 0) return; setIsPausing(true); try { - await pauseProtection(selectedDuration); + await pauseProtection(durationMinutes); onOpenChange(false); - setSelectedDuration(null); + resetPauseForm(); } finally { setIsPausing(false); } @@ -94,7 +193,7 @@ export function PauseConfirmationDialog({ const handleCancel = () => { onOpenChange(false); - setSelectedDuration(null); + resetPauseForm(); }; const handleQuickAllow = async (appName: string, hostname: string, durationMinutes: number) => { @@ -221,19 +320,112 @@ export function PauseConfirmationDialog({
{PAUSE_OPTIONS.map((opt) => ( - + + {opt.label} + ))}
+ + + + {pauseMode === "until" && ( +
+
+ + + + + + date < new Date(new Date().setHours(0, 0, 0, 0))} + initialFocus + /> + + + + +
+ + {pauseUntilDurationMinutes !== null && pauseUntilDurationMinutes > 0 && ( +

+ Protection will resume in {formatPauseDuration(pauseUntilDurationMinutes)} + {pauseUntilDateTime ? ` · ${format(pauseUntilDateTime, "MMM d, yyyy h:mm a")}` : ""}. +

+ )} + + {pauseUntilDurationMinutes !== null && pauseUntilDurationMinutes <= 0 && ( +

+ Please select a future date and time. +

+ )} +
+ )}
@@ -249,8 +441,8 @@ export function PauseConfirmationDialog({