1package coordinator
2
3import (
4 "context"
5 "fmt"
6 "sync"
7
8 "github.com/bnema/puregotk/v4/glib"
9
10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/dumber/internal/ui/component"
14)
15
16// UpdateCoordinator handles update checking and notification.
17type UpdateCoordinator struct {
18 checkUC *usecase.CheckUpdateUseCase
19 applyUC *usecase.ApplyUpdateUseCase
20 toastFn func(ctx context.Context, msg string, level component.ToastLevel)
21 enableOnStartup bool
22 autoDownload bool
23 mu sync.RWMutex // Protects status and lastInfo
24 status entity.UpdateStatus
25 lastInfo *usecase.CheckUpdateOutput
26}
27
28// NewUpdateCoordinator creates a new update coordinator.
29// enableOnStartup controls whether an update check runs at startup.
30// autoDownload controls whether updates are downloaded automatically in the background.
31func NewUpdateCoordinator(
32 checkUC *usecase.CheckUpdateUseCase,
33 applyUC *usecase.ApplyUpdateUseCase,
34 showToast func(ctx context.Context, msg string, level component.ToastLevel),
35 enableOnStartup bool,
36 autoDownload bool,
37) *UpdateCoordinator {
38 return &UpdateCoordinator{
39 checkUC: checkUC,
40 applyUC: applyUC,
41 toastFn: showToast,
42 enableOnStartup: enableOnStartup,
43 autoDownload: autoDownload,
44 status: entity.UpdateStatusUnknown,
45 }
46}
47
48// CheckOnStartup triggers an update check if enabled via the enableOnStartup constructor flag.
49func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) {
50 log := logging.FromContext(ctx)
51
52 if !c.enableOnStartup {
53 log.Debug().Msg("update check on startup disabled")
54 return
55 }
56
57 go c.checkAsync(ctx)
58}
59
60func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
61 log := logging.FromContext(ctx)
62
63 result, err := c.checkUC.Execute(ctx, usecase.CheckUpdateInput{})
64 if err != nil {
65 log.Warn().Err(err).Msg("background update check failed")
66 c.mu.Lock()
67 c.status = entity.UpdateStatusFailed
68 c.mu.Unlock()
69 return
70 }
71
72 c.mu.Lock()
73 c.lastInfo = result
74 if !result.UpdateAvailable {
75 c.status = entity.UpdateStatusUpToDate
76 c.mu.Unlock()
77 log.Debug().
78 Str("version", result.CurrentVersion).
79 Msg("already on latest version")
80 return
81 }
82 c.status = entity.UpdateStatusAvailable
83 c.mu.Unlock()
84 log.Info().
85 Str("current", result.CurrentVersion).
86 Str("latest", result.LatestVersion).
87 Bool("can_auto_update", result.CanAutoUpdate).
88 Msg("update available")
89
90 c.showUpdateNotification(ctx, result)
91
92 if c.autoDownload && result.CanAutoUpdate {
93 go c.downloadAsync(ctx, result.DownloadURL)
94 }
95}
96
97func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) {
98 var msg string
99 switch {
100 case result.CanAutoUpdate && c.autoDownload:
101 msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion)
102 default:
103 msg = fmt.Sprintf("Update %s available", result.LatestVersion)
104 }
105
106 cb := glib.SourceFunc(func(_ uintptr) bool {
107 if c.toastFn != nil {
108 c.toastFn(ctx, msg, component.ToastInfo)
109 }
110 return false
111 })
112 glib.IdleAdd(&cb, 0)
113}
114
115func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL string) {
116 log := logging.FromContext(ctx)
117
118 c.mu.Lock()
119 c.status = entity.UpdateStatusDownloading
120 c.mu.Unlock()
121
122 result, err := c.applyUC.Execute(ctx, usecase.ApplyUpdateInput{
123 DownloadURL: downloadURL,
124 })
125 if err != nil {
126 log.Error().Err(err).Msg("update download failed")
127 c.mu.Lock()
128 c.status = entity.UpdateStatusFailed
129 c.mu.Unlock()
130 c.showToast(ctx, "Update download failed", component.ToastError)
131 return
132 }
133
134 c.mu.Lock()
135 if result.Status == entity.UpdateStatusReady {
136 c.status = entity.UpdateStatusReady
137 c.mu.Unlock()
138 log.Info().Msg("update ready, will apply on exit")
139 c.showToast(ctx, "Update ready - applies on exit", component.ToastSuccess)
140 } else {
141 c.status = entity.UpdateStatusFailed
142 c.mu.Unlock()
143 log.Warn().Str("message", result.Message).Msg("update staging failed")
144 c.showToast(ctx, result.Message, component.ToastWarning)
145 }
146}
147
148func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) {
149 cb := glib.SourceFunc(func(_ uintptr) bool {
150 if c.toastFn != nil {
151 c.toastFn(ctx, msg, level)
152 }
153 return false
154 })
155 glib.IdleAdd(&cb, 0)
156}
157
158// FinalizeOnExit applies a staged update on process exit.
159func (c *UpdateCoordinator) FinalizeOnExit(ctx context.Context) error {
160 if c.applyUC == nil {
161 return nil
162 }
163 return c.applyUC.FinalizeOnExit(ctx)
164}
165
166// Status returns the current update status.
167func (c *UpdateCoordinator) Status() entity.UpdateStatus {
168 c.mu.RLock()
169 defer c.mu.RUnlock()
170 return c.status
171}
172
173// LastCheckResult returns the most recent update check result.
174func (c *UpdateCoordinator) LastCheckResult() *usecase.CheckUpdateOutput {
175 c.mu.RLock()
176 defer c.mu.RUnlock()
177 return c.lastInfo
178}
179
180// HasPendingUpdate returns true if a downloaded update is waiting to be applied.
181func (c *UpdateCoordinator) HasPendingUpdate(ctx context.Context) bool {
182 if c.applyUC == nil {
183 return false
184 }
185 return c.applyUC.HasPendingUpdate(ctx)
186}