diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb70eca..51a9f83 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -146,7 +146,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -511,7 +510,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -555,7 +553,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3452,7 +3449,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.144.0.tgz", "integrity": "sha512-GmRyIGmHtGj3VLTHXepIwXAxTcHyL5W7Vw7O1CnVEtFxQQWKMVOnWgI7tPY6FhlNwMKVb3n0mPFWz9KMYyd2GA==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", @@ -3658,7 +3654,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3852,7 +3847,6 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3863,7 +3857,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3874,7 +3867,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3884,7 +3876,8 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@typescript/vfs": { "version": "1.6.2", @@ -4237,7 +4230,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4665,6 +4657,7 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -5075,7 +5068,6 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -5450,6 +5442,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -5827,7 +5820,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5858,7 +5850,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5871,7 +5862,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6194,7 +6184,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.2.tgz", "integrity": "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -6491,7 +6480,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6631,7 +6619,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/frontend/src/components/onboarding/privacy-note.tsx b/frontend/src/components/onboarding/privacy-note.tsx index 97b5e06..2b05e69 100644 --- a/frontend/src/components/onboarding/privacy-note.tsx +++ b/frontend/src/components/onboarding/privacy-note.tsx @@ -1,36 +1,35 @@ import { ShieldCheck } from "lucide-react"; +import { Browser } from "@wailsio/runtime"; interface PrivacyNoteProps { - className?: string; - style?: React.CSSProperties; + className?: string; + style?: React.CSSProperties; } export function PrivacyNote({ className = "", style }: PrivacyNoteProps) { - return ( -
+ +

+ Privacy First — All + data stays on your Mac. When needed, it's processed through our + encrypted server to AI models with zero retention — never stored, + shared, or used for training. Focusd is source available on{" "} +

- ); + GitHub + + . +

+ + ); } diff --git a/frontend/src/components/onboarding/step2.tsx b/frontend/src/components/onboarding/step2.tsx index ab8612e..cb993af 100644 --- a/frontend/src/components/onboarding/step2.tsx +++ b/frontend/src/components/onboarding/step2.tsx @@ -12,6 +12,7 @@ import { RequestAccessibility, RequestAutomation, OpenSettings, + GetInstalledBrowsers, } from "../../../bindings/github.com/focusd-so/focusd/internal/native/nativeservice"; import { PrivacyNote } from "./privacy-note"; import { OnboardingHeader } from "./onboarding-header"; @@ -27,6 +28,17 @@ interface PermissionCard { icon: React.ReactNode; } +interface BrowserEntry { + bundleID: string; + name: string; + status: PermissionStatus; +} + +const MANDATORY_BROWSERS = new Set([ + "com.apple.Safari", + "com.google.Chrome", +]); + interface Step2Props { onAllGranted: (allGranted: boolean) => void; entered: boolean; @@ -36,60 +48,94 @@ export function Step2({ onAllGranted, entered }: Step2Props) { const [isVisible, setIsVisible] = useState(false); const [accessibility, setAccessibility] = useState("pending"); - const [systemEvents, setSystemEvents] = - useState("pending"); - const [browsers, setBrowsers] = useState("pending"); + const [systemEvents, setSystemEvents] = useState("pending"); + const [browsers, setBrowsers] = useState([]); - // Pre-check all permissions on mount (silent, no prompts) useEffect(() => { const t = setTimeout(() => setIsVisible(true), 50); + CheckAccessibility().then((granted) => { if (granted) setAccessibility("granted"); }); + CheckAutomation("com.apple.systemevents").then((granted) => { + console.log(granted) if (granted) setSystemEvents("granted"); }); - Promise.all([ - CheckAutomation("com.google.Chrome"), - CheckAutomation("com.apple.Safari"), - ]).then(([chrome, safari]) => { - if (chrome || safari) setBrowsers("granted"); + + GetInstalledBrowsers().then(async (installed) => { + const entries: BrowserEntry[] = await Promise.all( + installed.map(async (b) => { + const granted = await CheckAutomation(b.bundleID); + return { + bundleID: b.bundleID, + name: b.name, + status: granted ? ("granted" as const) : ("pending" as const), + }; + }), + ); + setBrowsers(entries); }); + return () => clearTimeout(t); }, []); - // Notify parent when all granted + const mandatoryBrowsersGranted = browsers + .filter((b) => MANDATORY_BROWSERS.has(b.bundleID)) + .every((b) => b.status === "granted"); + useEffect(() => { const allGranted = accessibility === "granted" && systemEvents === "granted" && - browsers === "granted"; + mandatoryBrowsersGranted; onAllGranted(allGranted); - }, [accessibility, systemEvents, browsers, onAllGranted]); - - const handleGrant = useCallback( - async (id: string) => { - switch (id) { - case "accessibility": { - const granted = await RequestAccessibility(); - setAccessibility(granted ? "granted" : "denied"); - break; - } - case "system-events": { - const granted = await RequestAutomation("com.apple.systemevents"); - setSystemEvents(granted ? "granted" : "denied"); - break; - } - case "browsers": { - const chrome = await RequestAutomation("com.google.Chrome"); - const safari = await RequestAutomation("com.apple.Safari"); - setBrowsers(chrome || safari ? "granted" : "denied"); - break; - } + }, [accessibility, systemEvents, mandatoryBrowsersGranted, onAllGranted]); + + const handleGrant = useCallback(async (id: string) => { + switch (id) { + case "accessibility": { + const granted = await RequestAccessibility(); + setAccessibility(granted ? "granted" : "denied"); + break; } - }, - [] - ); + case "system-events": { + const granted = await RequestAutomation("com.apple.systemevents"); + setSystemEvents(granted ? "granted" : "denied"); + break; + } + } + }, []); + + const handleBrowserGrant = useCallback(async (bundleID: string) => { + const granted = await RequestAutomation(bundleID); + setBrowsers((prev) => + prev.map((b) => + b.bundleID === bundleID + ? { ...b, status: granted ? "granted" : "denied" } + : b, + ), + ); + }, []); + + const handleAllowAll = useCallback(async () => { + for (const browser of browsers) { + if (browser.status === "granted") continue; + const granted = await RequestAutomation(browser.bundleID); + setBrowsers((prev) => + prev.map((b) => + b.bundleID === browser.bundleID + ? { ...b, status: granted ? "granted" : "denied" } + : b, + ), + ); + } + }, [browsers]); + + const allBrowsersGranted = + browsers.length > 0 && browsers.every((b) => b.status === "granted"); + const anyBrowserDenied = browsers.some((b) => b.status === "denied"); + const anyBrowserPending = browsers.some((b) => b.status === "pending"); const cards: PermissionCard[] = [ { @@ -108,16 +154,37 @@ export function Step2({ onAllGranted, entered }: Step2Props) { status: systemEvents, icon: , }, - { - id: "browsers", - title: "Web Browsing", - description: - "Detects distracting websites in your browser and redirects you to productivity.", - status: browsers, - icon: , - }, ]; + const renderAction = (status: PermissionStatus, onGrant: () => void) => { + if (status === "granted") { + return ( +
+ Granted +
+ ); + } + if (status === "denied") { + return ( + + ); + } + return ( + + ); + }; + return (
- Granted -
- ) : card.status === "denied" ? ( - - ) : ( - - ) - } + action={renderAction(card.status, () => handleGrant(card.id))} /> ))} + + {browsers.length > 0 && ( +
+
+
+
+ + + Web Browsing + +
+ {allBrowsersGranted ? ( +
+ + All Granted +
+ ) : anyBrowserPending ? ( + + ) : anyBrowserDenied ? ( + + ) : null} +
+

+ Allow Focusd to monitor and block distracting websites in your + browsers. +

+
+ +
+ {browsers.map((browser, i) => { + const isMandatory = MANDATORY_BROWSERS.has(browser.bundleID); + return ( +
0 ? "border-t border-white/5" : ""}`} + > +
+ {browser.status === "granted" ? ( + + ) : ( + + )} + + {browser.name} + + {isMandatory && browser.status !== "granted" && ( + + Required + + )} +
+ {browser.status === "granted" ? ( + + Granted + + ) : browser.status === "denied" ? ( + + ) : isMandatory ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ )} - {/* Privacy note */} { - const parsedHour = new Date(hourKey).getHours(); + const parsedHour = parseInt(hourKey, 10); const safeScore = normalizeProductivityScore(score); const current = hourlyTotals.get(parsedHour); diff --git a/internal/native/native_service_darwin.go b/internal/native/native_service_darwin.go index 3c0908e..eec3193 100644 --- a/internal/native/native_service_darwin.go +++ b/internal/native/native_service_darwin.go @@ -7,8 +7,10 @@ package native #cgo CFLAGS: -x objective-c -fobjc-arc #cgo LDFLAGS: -framework Cocoa -framework ApplicationServices -framework ServiceManagement +#include #include +#import #import static Boolean checkAccessibility(Boolean prompt) { @@ -55,11 +57,34 @@ static int loginItemEnabled(void) { } return 0; } + +// getInstalledAppName returns the display name of the app with the given bundle ID, +// or NULL if the app is not installed. +static const char* getInstalledAppName(const char* bundleID) { + @autoreleasepool { + NSString *bid = [NSString stringWithUTF8String:bundleID]; + NSURL *appURL = [[NSWorkspace sharedWorkspace] URLForApplicationWithBundleIdentifier:bid]; + if (appURL == nil) { + return NULL; + } + NSBundle *bundle = [NSBundle bundleWithURL:appURL]; + NSString *name = [bundle objectForInfoDictionaryKey:@"CFBundleDisplayName"]; + if (name == nil) { + name = [bundle objectForInfoDictionaryKey:@"CFBundleName"]; + } + if (name == nil) { + name = [[appURL lastPathComponent] stringByDeletingPathExtension]; + } + return strdup([name UTF8String]); + } +} */ import "C" import ( "fmt" + "sort" "sync" + "unsafe" ) // NativeService is a Wails-bound service for managing macOS permissions @@ -134,3 +159,51 @@ func (s *NativeService) DisableLoginItem() error { func (s *NativeService) LoginItemEnabled() bool { return C.loginItemEnabled() != 0 } + +var browserPriority = map[string]int{ + "com.apple.Safari": 0, + "com.google.Chrome": 1, + "com.brave.Browser": 2, + "com.microsoft.edgemac": 3, + "com.operasoftware.Opera": 4, + "com.vivaldi.Vivaldi": 5, + "company.thebrowser.Browser": 6, +} + +// GetInstalledBrowsers returns all known browsers that are installed on the system, +// sorted with popular browsers first. +func (s *NativeService) GetInstalledBrowsers() []InstalledBrowser { + allBundleIDs := make([]string, 0, len(chromeBaseBundleIDs)+len(safariBasedBundleIDs)) + allBundleIDs = append(allBundleIDs, safariBasedBundleIDs...) + allBundleIDs = append(allBundleIDs, chromeBaseBundleIDs...) + + var installed []InstalledBrowser + for _, bid := range allBundleIDs { + cBid := C.CString(bid) + cName := C.getInstalledAppName(cBid) + C.free(unsafe.Pointer(cBid)) + if cName == nil { + continue + } + name := C.GoString(cName) + C.free(unsafe.Pointer(cName)) + installed = append(installed, InstalledBrowser{BundleID: bid, Name: name}) + } + + sort.SliceStable(installed, func(i, j int) bool { + pi, okI := browserPriority[installed[i].BundleID] + pj, okJ := browserPriority[installed[j].BundleID] + if okI && okJ { + return pi < pj + } + if okI { + return true + } + if okJ { + return false + } + return installed[i].Name < installed[j].Name + }) + + return installed +} diff --git a/internal/native/native_service_linux.go b/internal/native/native_service_linux.go index fc8f31c..fbf418d 100644 --- a/internal/native/native_service_linux.go +++ b/internal/native/native_service_linux.go @@ -58,3 +58,7 @@ func (s *NativeService) DisableLoginItem() error { func (s *NativeService) LoginItemEnabled() bool { return false } + +func (s *NativeService) GetInstalledBrowsers() []InstalledBrowser { + return []InstalledBrowser{} +} diff --git a/internal/native/native_service_windows.go b/internal/native/native_service_windows.go index 25e7a15..19fdbac 100644 --- a/internal/native/native_service_windows.go +++ b/internal/native/native_service_windows.go @@ -58,3 +58,7 @@ func (s *NativeService) DisableLoginItem() error { func (s *NativeService) LoginItemEnabled() bool { return false } + +func (s *NativeService) GetInstalledBrowsers() []InstalledBrowser { + return []InstalledBrowser{} +} diff --git a/internal/native/types.go b/internal/native/types.go index e2e1bac..d7c6ac8 100644 --- a/internal/native/types.go +++ b/internal/native/types.go @@ -49,6 +49,11 @@ func (e *NativeEvent) BrowserHostname() string { return hostname } +type InstalledBrowser struct { + BundleID string `json:"bundleID"` + Name string `json:"name"` +} + func (e *NativeEvent) Domain() string { if e.URL == "" { return "" diff --git a/internal/usage/insights_report.go b/internal/usage/insights_report.go index 4107703..8231d7f 100644 --- a/internal/usage/insights_report.go +++ b/internal/usage/insights_report.go @@ -2,82 +2,77 @@ package usage import ( "fmt" - "log/slog" "math" "time" ) func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) { - - slog.Info("[INGISHTS]: requestung for", "date", date) - if date.IsZero() { date = time.Now() } - listOpts := GetUsageListOptions{ - Date: &date, - } - - usages, err := s.GetUsageList(listOpts) + usages, err := s.GetUsageList(GetUsageListOptions{Date: &date}) if err != nil { return DayInsights{}, fmt.Errorf("failed to get usage list: %w", err) } - slog.Info("[INGISHTS]: number of usages", "count", len(usages)) - - var ( - productivityScore = ProductivityScore{} - productivityPerHourBreakdown = make(ProductivityPerHourBreakdown) - ) + score := ProductivityScore{} + hourly := make(ProductivityPerHourBreakdown) for i, usage := range usages { - // calculate overall summary - end := fromPtr(usage.EndedAt) - - if end == 0 { - if len(usages) > i+1 { - end = usages[i+1].StartedAt - } - - if end == 0 { - continue - } - } - - durationSeconds := end - usage.StartedAt - if durationSeconds <= 0 { + end := resolveEndTime(usage, usages, i) + if end <= usage.StartedAt { continue } - slog.Info("[INGISHTS]: calculated duration in seconds", "duration", durationSeconds) + dur := int(end - usage.StartedAt) + score.addSeconds(usage.Classification, dur) - switch usage.Classification { - case ClassificationProductive: - productivityScore.ProductiveSeconds += int(durationSeconds) - case ClassificationDistracting: - productivityScore.DistractiveSeconds += int(durationSeconds) - default: - productivityScore.OtherSeconds += int(durationSeconds) + for hour, secs := range splitSecondsPerHour(usage.StartedAt, end) { + entry := hourly[hour] + entry.addSeconds(usage.Classification, secs) + hourly[hour] = entry } + } + score.ProductivityScore = calculateProductivityScore(score.ProductiveSeconds, score.DistractiveSeconds) + for hour, s := range hourly { + s.ProductivityScore = calculateProductivityScore(s.ProductiveSeconds, s.DistractiveSeconds) + hourly[hour] = s } - slog.Info("[INGISHTS]: total seconds", "productivity", productivityScore.ProductiveSeconds, "distracting", productivityScore.DistractiveSeconds) + return DayInsights{ProductivityScore: score, ProductivityPerHourBreakdown: hourly}, nil +} - // Calculate overall score - productivityScore.ProductivityScore = calculateProductivityScore(productivityScore.ProductiveSeconds, productivityScore.DistractiveSeconds) +func resolveEndTime(usage ApplicationUsage, usages []ApplicationUsage, i int) int64 { + if usage.EndedAt != nil { + return *usage.EndedAt + } + if i+1 < len(usages) { + return usages[i+1].StartedAt + } + return 0 +} + +func splitSecondsPerHour(startUnix, endUnix int64) map[int]int { + start := time.Unix(startUnix, 0).UTC() + end := time.Unix(endUnix, 0).UTC() + result := make(map[int]int) + + for cursor := start; cursor.Before(end); { + hour := cursor.Hour() + nextHour := time.Date(cursor.Year(), cursor.Month(), cursor.Day(), hour+1, 0, 0, 0, time.UTC) + + segmentEnd := end + if nextHour.Before(end) { + segmentEnd = nextHour + } - // Calculate hourly scores - for hour, score := range productivityPerHourBreakdown { - score.ProductivityScore = calculateProductivityScore(score.ProductiveSeconds, score.DistractiveSeconds) - productivityPerHourBreakdown[hour] = score + result[hour] += int(segmentEnd.Sub(cursor).Seconds()) + cursor = segmentEnd } - return DayInsights{ - ProductivityScore: productivityScore, - ProductivityPerHourBreakdown: productivityPerHourBreakdown, - }, nil + return result } func calculateProductivityScore(productiveSeconds, distractiveSeconds int) int { diff --git a/internal/usage/types_insights.go b/internal/usage/types_insights.go index ee3c39a..962384e 100644 --- a/internal/usage/types_insights.go +++ b/internal/usage/types_insights.go @@ -12,4 +12,15 @@ type ProductivityScore struct { ProductivityScore int } +func (p *ProductivityScore) addSeconds(classification Classification, seconds int) { + switch classification { + case ClassificationProductive: + p.ProductiveSeconds += seconds + case ClassificationDistracting: + p.DistractiveSeconds += seconds + default: + p.OtherSeconds += seconds + } +} + type ProductivityPerHourBreakdown map[int]ProductivityScore