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 toaster *component.Toaster
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 toaster *component.Toaster,
35 enableOnStartup bool,
36 autoDownload bool,
37) *UpdateCoordinator {
38 return &UpdateCoordinator{
39 checkUC: checkUC,
40 applyUC: applyUC,
41 toaster: toaster,
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 c.toaster.Show(ctx, msg, component.ToastInfo)
108 return false
109 })
110 glib.IdleAdd(&cb, 0)
111}
112
113func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL string) {
114 log := logging.FromContext(ctx)
115
116 c.mu.Lock()
117 c.status = entity.UpdateStatusDownloading
118 c.mu.Unlock()
119
120 result, err := c.applyUC.Execute(ctx, usecase.ApplyUpdateInput{
121 DownloadURL: downloadURL,
122 })
123 if err != nil {
124 log.Error().Err(err).Msg("update download failed")
125 c.mu.Lock()
126 c.status = entity.UpdateStatusFailed
127 c.mu.Unlock()
128 c.showToast(ctx, "Update download failed", component.ToastError)
129 return
130 }
131
132 c.mu.Lock()
133 if result.Status == entity.UpdateStatusReady {
134 c.status = entity.UpdateStatusReady
135 c.mu.Unlock()
136 log.Info().Msg("update ready, will apply on exit")
137 c.showToast(ctx, "Update ready - applies on exit", component.ToastSuccess)
138 } else {
139 c.status = entity.UpdateStatusFailed
140 c.mu.Unlock()
141 log.Warn().Str("message", result.Message).Msg("update staging failed")
142 c.showToast(ctx, result.Message, component.ToastWarning)
143 }
144}
145
146func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) {
147 cb := glib.SourceFunc(func(_ uintptr) bool {
148 c.toaster.Show(ctx, msg, level)
149 return false
150 })
151 glib.IdleAdd(&cb, 0)
152}
153
154// FinalizeOnExit applies a staged update on process exit.
155func (c *UpdateCoordinator) FinalizeOnExit(ctx context.Context) error {
156 if c.applyUC == nil {
157 return nil
158 }
159 return c.applyUC.FinalizeOnExit(ctx)
160}
161
162// Status returns the current update status.
163func (c *UpdateCoordinator) Status() entity.UpdateStatus {
164 c.mu.RLock()
165 defer c.mu.RUnlock()
166 return c.status
167}
168
169// LastCheckResult returns the most recent update check result.
170func (c *UpdateCoordinator) LastCheckResult() *usecase.CheckUpdateOutput {
171 c.mu.RLock()
172 defer c.mu.RUnlock()
173 return c.lastInfo
174}
175
176// HasPendingUpdate returns true if a downloaded update is waiting to be applied.
177func (c *UpdateCoordinator) HasPendingUpdate(ctx context.Context) bool {
178 if c.applyUC == nil {
179 return false
180 }
181 return c.applyUC.HasPendingUpdate(ctx)
182}