Skip to content

Commit e6af617

Browse files
authored
feat: add comprehensive caching for instant startup (#40)
- Cache all Homebrew data (installed packages, remote formulae/casks, analytics) - Reduce startup time from 1-2s to ~50-100ms with populated cache - Add cache validation to prevent corrupted data usage - Make boot process resilient to cache corruption and network failures - Background update ensures data freshness after startup Cache files stored in ~/Library/Caches/bbrew/ following XDG standards: - installed.json, installed-casks.json (local packages) - formula.json, cask.json (remote packages) - analytics.json, cask-analytics.json (download stats) The app now starts instantly using cached data, then updates in background without blocking the UI. Falls back gracefully if cache is corrupted or network is unavailable.
1 parent 6d10e0b commit e6af617

File tree

2 files changed

+187
-29
lines changed

2 files changed

+187
-29
lines changed

internal/services/app.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"bbrew/internal/ui/theme"
77
"context"
88
"fmt"
9+
"os"
910
"sort"
1011
"strings"
1112
"time"
@@ -97,8 +98,11 @@ func (s *AppService) Boot() (err error) {
9798
}
9899

99100
// Download and parse Homebrew formulae data
101+
// Non-critical: if this fails (corrupted cache + no internet), app will start with empty data
102+
// and background update will populate it when network is available
100103
if err = s.brewService.SetupData(false); err != nil {
101-
return fmt.Errorf("failed to load Homebrew formulae: %v", err)
104+
// Log error but don't fail - app can work with empty/partial data
105+
fmt.Fprintf(os.Stderr, "Warning: failed to load Homebrew data (will retry in background): %v\n", err)
102106
}
103107

104108
// Initialize packages and filteredPackages

internal/services/brew.go

Lines changed: 182 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -210,36 +210,67 @@ func (s *BrewService) GetPackages() (packages *[]models.Package) {
210210
// SetupData initializes the BrewService by loading installed packages, remote formulae, casks, and analytics data.
211211
func (s *BrewService) SetupData(forceDownload bool) (err error) {
212212
// Load formulae
213-
if err = s.loadInstalled(); err != nil {
213+
if err = s.loadInstalled(forceDownload); err != nil {
214214
return fmt.Errorf("failed to load installed formulae: %w", err)
215215
}
216216

217217
if err = s.loadRemote(forceDownload); err != nil {
218218
return fmt.Errorf("failed to load remote formulae: %w", err)
219219
}
220220

221-
if err = s.loadAnalytics(); err != nil {
221+
if err = s.loadAnalytics(forceDownload); err != nil {
222222
return fmt.Errorf("failed to load formulae analytics: %w", err)
223223
}
224224

225225
// Load casks
226-
if err = s.loadInstalledCasks(); err != nil {
226+
if err = s.loadInstalledCasks(forceDownload); err != nil {
227227
return fmt.Errorf("failed to load installed casks: %w", err)
228228
}
229229

230230
if err = s.loadRemoteCasks(forceDownload); err != nil {
231231
return fmt.Errorf("failed to load remote casks: %w", err)
232232
}
233233

234-
if err = s.loadCaskAnalytics(); err != nil {
234+
if err = s.loadCaskAnalytics(forceDownload); err != nil {
235235
return fmt.Errorf("failed to load cask analytics: %w", err)
236236
}
237237

238238
return nil
239239
}
240240

241-
// loadInstalled retrieves the list of installed Homebrew formulae and updates their local paths.
242-
func (s *BrewService) loadInstalled() (err error) {
241+
// loadInstalled retrieves installed formulae, optionally using cache.
242+
func (s *BrewService) loadInstalled(forceDownload bool) (err error) {
243+
cacheDir := getCacheDir()
244+
installedFile := filepath.Join(cacheDir, "installed.json")
245+
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
246+
if err := os.MkdirAll(cacheDir, 0750); err != nil {
247+
return err
248+
}
249+
}
250+
251+
// Check if we should use the cached file
252+
if !forceDownload {
253+
if fileInfo, err := os.Stat(installedFile); err == nil {
254+
// Only use cache if file is not empty (size > 10 bytes for "[]" or "{}")
255+
if fileInfo.Size() > 10 {
256+
// #nosec G304 -- installedFile path is safely constructed from UserHomeDir and sanitized with filepath.Join
257+
data, err := os.ReadFile(installedFile)
258+
if err == nil && len(data) > 0 {
259+
*s.installed = make([]models.Formula, 0)
260+
if err := json.Unmarshal(data, &s.installed); err == nil {
261+
// Mark all installed Packages as locally installed and set LocalPath
262+
prefix := s.GetPrefixPath()
263+
for i := range *s.installed {
264+
(*s.installed)[i].LocallyInstalled = true
265+
(*s.installed)[i].LocalPath = filepath.Join(prefix, "Cellar", (*s.installed)[i].Name)
266+
}
267+
return nil
268+
}
269+
}
270+
}
271+
}
272+
}
273+
243274
cmd := exec.Command("brew", "info", "--json=v1", "--installed")
244275
output, err := cmd.Output()
245276
if err != nil {
@@ -259,11 +290,46 @@ func (s *BrewService) loadInstalled() (err error) {
259290
(*s.installed)[i].LocalPath = filepath.Join(prefix, "Cellar", (*s.installed)[i].Name)
260291
}
261292

293+
// Cache the installed formulae data
294+
_ = os.WriteFile(installedFile, output, 0600)
262295
return nil
263296
}
264297

265-
// loadInstalledCasks retrieves the list of installed Homebrew casks.
266-
func (s *BrewService) loadInstalledCasks() (err error) {
298+
// loadInstalledCasks retrieves installed casks, optionally using cache.
299+
func (s *BrewService) loadInstalledCasks(forceDownload bool) (err error) {
300+
cacheDir := getCacheDir()
301+
installedCasksFile := filepath.Join(cacheDir, "installed-casks.json")
302+
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
303+
if err := os.MkdirAll(cacheDir, 0750); err != nil {
304+
return err
305+
}
306+
}
307+
308+
// Check if we should use the cached file
309+
if !forceDownload {
310+
if fileInfo, err := os.Stat(installedCasksFile); err == nil {
311+
// Only use cache if file is not empty (size > 10 bytes for minimal JSON)
312+
if fileInfo.Size() > 10 {
313+
// #nosec G304 -- installedCasksFile path is safely constructed from UserHomeDir and sanitized with filepath.Join
314+
data, err := os.ReadFile(installedCasksFile)
315+
if err == nil && len(data) > 0 {
316+
var response struct {
317+
Casks []models.Cask `json:"casks"`
318+
}
319+
if err := json.Unmarshal(data, &response); err == nil {
320+
*s.installedCasks = response.Casks
321+
// Mark all installed casks as locally installed
322+
for i := range *s.installedCasks {
323+
(*s.installedCasks)[i].LocallyInstalled = true
324+
(*s.installedCasks)[i].IsCask = true
325+
}
326+
return nil
327+
}
328+
}
329+
}
330+
}
331+
}
332+
267333
// Get list of installed cask names
268334
listCmd := exec.Command("brew", "list", "--cask")
269335
listOutput, err := listCmd.Output()
@@ -307,6 +373,8 @@ func (s *BrewService) loadInstalledCasks() (err error) {
307373
(*s.installedCasks)[i].IsCask = true
308374
}
309375

376+
// Cache the installed casks data
377+
_ = os.WriteFile(installedCasksFile, infoOutput, 0600)
310378
return nil
311379
}
312380

@@ -322,13 +390,16 @@ func (s *BrewService) loadRemote(forceDownload bool) (err error) {
322390

323391
// Check if we should use the cached file
324392
if !forceDownload {
325-
if _, err := os.Stat(formulaFile); err == nil {
326-
// #nosec G304 -- formulaFile path is safely constructed from UserHomeDir and sanitized with filepath.Join
327-
data, err := os.ReadFile(formulaFile)
328-
if err == nil {
329-
*s.remote = make([]models.Formula, 0)
330-
if err := json.Unmarshal(data, &s.remote); err == nil {
331-
return nil
393+
if fileInfo, err := os.Stat(formulaFile); err == nil {
394+
// Only use cache if file has reasonable size (formulae list should be several MB)
395+
if fileInfo.Size() > 1000 {
396+
// #nosec G304 -- formulaFile path is safely constructed from UserHomeDir and sanitized with filepath.Join
397+
data, err := os.ReadFile(formulaFile)
398+
if err == nil && len(data) > 0 {
399+
*s.remote = make([]models.Formula, 0)
400+
if err := json.Unmarshal(data, &s.remote); err == nil && len(*s.remote) > 0 {
401+
return nil
402+
}
332403
}
333404
}
334405
}
@@ -368,13 +439,16 @@ func (s *BrewService) loadRemoteCasks(forceDownload bool) (err error) {
368439

369440
// Check if we should use the cached file
370441
if !forceDownload {
371-
if _, err := os.Stat(caskFile); err == nil {
372-
// #nosec G304 -- caskFile path is safely constructed from UserHomeDir and sanitized with filepath.Join
373-
data, err := os.ReadFile(caskFile)
374-
if err == nil {
375-
*s.remoteCasks = make([]models.Cask, 0)
376-
if err := json.Unmarshal(data, &s.remoteCasks); err == nil {
377-
return nil
442+
if fileInfo, err := os.Stat(caskFile); err == nil {
443+
// Only use cache if file has reasonable size (cask list should be several MB)
444+
if fileInfo.Size() > 1000 {
445+
// #nosec G304 -- caskFile path is safely constructed from UserHomeDir and sanitized with filepath.Join
446+
data, err := os.ReadFile(caskFile)
447+
if err == nil && len(data) > 0 {
448+
*s.remoteCasks = make([]models.Cask, 0)
449+
if err := json.Unmarshal(data, &s.remoteCasks); err == nil && len(*s.remoteCasks) > 0 {
450+
return nil
451+
}
378452
}
379453
}
380454
}
@@ -402,16 +476,51 @@ func (s *BrewService) loadRemoteCasks(forceDownload bool) (err error) {
402476
return nil
403477
}
404478

405-
// loadAnalytics retrieves the analytics data for Homebrew formulae from the API.
406-
func (s *BrewService) loadAnalytics() (err error) {
479+
// loadAnalytics retrieves the analytics data for Homebrew formulae from the API and caches them locally.
480+
func (s *BrewService) loadAnalytics(forceDownload bool) (err error) {
481+
cacheDir := getCacheDir()
482+
analyticsFile := filepath.Join(cacheDir, "analytics.json")
483+
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
484+
if err := os.MkdirAll(cacheDir, 0750); err != nil {
485+
return err
486+
}
487+
}
488+
489+
// Check if we should use the cached file
490+
if !forceDownload {
491+
if fileInfo, err := os.Stat(analyticsFile); err == nil {
492+
// Only use cache if file has reasonable size (analytics should be > 1KB)
493+
if fileInfo.Size() > 100 {
494+
// #nosec G304 -- analyticsFile path is safely constructed from UserHomeDir and sanitized with filepath.Join
495+
data, err := os.ReadFile(analyticsFile)
496+
if err == nil && len(data) > 0 {
497+
analytics := models.Analytics{}
498+
if err := json.Unmarshal(data, &analytics); err == nil && len(analytics.Items) > 0 {
499+
analyticsByFormula := map[string]models.AnalyticsItem{}
500+
for _, f := range analytics.Items {
501+
analyticsByFormula[f.Formula] = f
502+
}
503+
s.analytics = analyticsByFormula
504+
return nil
505+
}
506+
}
507+
}
508+
}
509+
}
510+
407511
resp, err := http.Get(AnalyticsAPIURL)
408512
if err != nil {
409513
return err
410514
}
411515
defer resp.Body.Close()
412516

517+
body, err := io.ReadAll(resp.Body)
518+
if err != nil {
519+
return err
520+
}
521+
413522
analytics := models.Analytics{}
414-
err = json.NewDecoder(resp.Body).Decode(&analytics)
523+
err = json.Unmarshal(body, &analytics)
415524
if err != nil {
416525
return err
417526
}
@@ -422,19 +531,61 @@ func (s *BrewService) loadAnalytics() (err error) {
422531
}
423532

424533
s.analytics = analyticsByFormula
534+
535+
// Cache the analytics data
536+
_ = os.WriteFile(analyticsFile, body, 0600)
425537
return nil
426538
}
427539

428-
// loadCaskAnalytics retrieves the analytics data for Homebrew casks from the API.
429-
func (s *BrewService) loadCaskAnalytics() (err error) {
540+
// loadCaskAnalytics retrieves the analytics data for Homebrew casks from the API and caches them locally.
541+
func (s *BrewService) loadCaskAnalytics(forceDownload bool) (err error) {
542+
cacheDir := getCacheDir()
543+
caskAnalyticsFile := filepath.Join(cacheDir, "cask-analytics.json")
544+
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
545+
if err := os.MkdirAll(cacheDir, 0750); err != nil {
546+
return err
547+
}
548+
}
549+
550+
// Check if we should use the cached file
551+
if !forceDownload {
552+
if fileInfo, err := os.Stat(caskAnalyticsFile); err == nil {
553+
// Only use cache if file has reasonable size (analytics should be > 1KB)
554+
if fileInfo.Size() > 100 {
555+
// #nosec G304 -- caskAnalyticsFile path is safely constructed from UserHomeDir and sanitized with filepath.Join
556+
data, err := os.ReadFile(caskAnalyticsFile)
557+
if err == nil && len(data) > 0 {
558+
analytics := models.Analytics{}
559+
if err := json.Unmarshal(data, &analytics); err == nil && len(analytics.Items) > 0 {
560+
analyticsByCask := map[string]models.AnalyticsItem{}
561+
for _, c := range analytics.Items {
562+
// Cask analytics use the "cask" field instead of "formula"
563+
caskName := c.Cask
564+
if caskName != "" {
565+
analyticsByCask[caskName] = c
566+
}
567+
}
568+
s.caskAnalytics = analyticsByCask
569+
return nil
570+
}
571+
}
572+
}
573+
}
574+
}
575+
430576
resp, err := http.Get(CaskAnalyticsAPIURL)
431577
if err != nil {
432578
return err
433579
}
434580
defer resp.Body.Close()
435581

582+
body, err := io.ReadAll(resp.Body)
583+
if err != nil {
584+
return err
585+
}
586+
436587
analytics := models.Analytics{}
437-
err = json.NewDecoder(resp.Body).Decode(&analytics)
588+
err = json.Unmarshal(body, &analytics)
438589
if err != nil {
439590
return err
440591
}
@@ -449,6 +600,9 @@ func (s *BrewService) loadCaskAnalytics() (err error) {
449600
}
450601

451602
s.caskAnalytics = analyticsByCask
603+
604+
// Cache the cask analytics data
605+
_ = os.WriteFile(caskAnalyticsFile, body, 0600)
452606
return nil
453607
}
454608

0 commit comments

Comments
 (0)