1package daemonclient
2
3import (
4 "context"
5 "log"
6 "os"
7 "os/exec"
8 "time"
9
10 "github.com/floatpane/matcha/backend"
11 "github.com/floatpane/matcha/config"
12 "github.com/floatpane/matcha/daemonrpc"
13)
14
15// Service abstracts daemon-backed vs direct email operations.
16// TUI and CLI use this interface — they don't care which mode is active.
17type Service interface {
18 FetchEmails(accountID, folder string, limit, offset uint32) ([]backend.Email, error)
19 FetchEmailBody(accountID, folder string, uid uint32) (string, []backend.Attachment, error)
20 DeleteEmails(accountID, folder string, uids []uint32) error
21 ArchiveEmails(accountID, folder string, uids []uint32) error
22 MoveEmails(accountID string, uids []uint32, src, dst string) error
23 MarkRead(accountID, folder string, uids []uint32) error
24 FetchFolders(accountID string) ([]backend.Folder, error)
25 RefreshFolder(accountID, folder string) error
26 Subscribe(accountID, folder string) error
27 Unsubscribe(accountID, folder string) error
28 ReloadConfig() error
29 Events() <-chan *daemonrpc.Event
30 IsDaemon() bool
31 Close() error
32}
33
34// NewService connects to the daemon, auto-starting it if needed.
35// Falls back to direct mode only if daemon cannot be started.
36func NewService(cfg *config.Config) Service {
37 // Try connecting to existing daemon.
38 if svc := tryConnect(); svc != nil {
39 return svc
40 }
41
42 // Daemon not running — auto-start it.
43 log.Println("service: daemon not running, auto-starting")
44 if err := autoStartDaemon(); err != nil {
45 log.Printf("service: auto-start failed: %v, using direct mode", err)
46 return newDirectService(cfg)
47 }
48
49 // Wait briefly for daemon to become ready, then connect.
50 for i := 0; i < 20; i++ {
51 time.Sleep(100 * time.Millisecond)
52 if svc := tryConnect(); svc != nil {
53 log.Println("service: connected to auto-started daemon")
54 return svc
55 }
56 }
57
58 log.Println("service: daemon started but not responding, using direct mode")
59 return newDirectService(cfg)
60}
61
62func tryConnect() *daemonService {
63 client, err := Dial()
64 if err != nil {
65 return nil
66 }
67 if err := client.Ping(); err != nil {
68 client.Close()
69 return nil
70 }
71 return &daemonService{client: client}
72}
73
74func autoStartDaemon() error {
75 exe, err := os.Executable()
76 if err != nil {
77 return err
78 }
79
80 cmd := exec.Command(exe, "daemon", "run")
81 cmd.Stdout = nil
82 cmd.Stderr = nil
83 cmd.Stdin = nil
84 cmd.SysProcAttr = DaemonProcAttr()
85
86 return cmd.Start()
87}
88
89// daemonService routes all operations through the daemon socket.
90type daemonService struct {
91 client *Client
92}
93
94func (s *daemonService) FetchEmails(accountID, folder string, limit, offset uint32) ([]backend.Email, error) {
95 var emails []backend.Email
96 err := s.client.Call(daemonrpc.MethodFetchEmails, daemonrpc.FetchEmailsParams{
97 AccountID: accountID,
98 Folder: folder,
99 Limit: limit,
100 Offset: offset,
101 }, &emails)
102 return emails, err
103}
104
105func (s *daemonService) FetchEmailBody(accountID, folder string, uid uint32) (string, []backend.Attachment, error) {
106 var result daemonrpc.FetchEmailBodyResult
107 err := s.client.Call(daemonrpc.MethodFetchEmailBody, daemonrpc.FetchEmailBodyParams{
108 AccountID: accountID,
109 Folder: folder,
110 UID: uid,
111 }, &result)
112 if err != nil {
113 return "", nil, err
114 }
115
116 var attachments []backend.Attachment
117 for _, a := range result.Attachments {
118 attachments = append(attachments, backend.Attachment{
119 Filename: a.Filename,
120 PartID: a.PartID,
121 Encoding: a.Encoding,
122 MIMEType: a.MIMEType,
123 })
124 }
125 return result.Body, attachments, nil
126}
127
128func (s *daemonService) DeleteEmails(accountID, folder string, uids []uint32) error {
129 return s.client.Call(daemonrpc.MethodDeleteEmails, daemonrpc.DeleteEmailsParams{
130 AccountID: accountID,
131 Folder: folder,
132 UIDs: uids,
133 }, nil)
134}
135
136func (s *daemonService) ArchiveEmails(accountID, folder string, uids []uint32) error {
137 return s.client.Call(daemonrpc.MethodArchiveEmails, daemonrpc.ArchiveEmailsParams{
138 AccountID: accountID,
139 Folder: folder,
140 UIDs: uids,
141 }, nil)
142}
143
144func (s *daemonService) MoveEmails(accountID string, uids []uint32, src, dst string) error {
145 return s.client.Call(daemonrpc.MethodMoveEmails, daemonrpc.MoveEmailsParams{
146 AccountID: accountID,
147 UIDs: uids,
148 SourceFolder: src,
149 DestFolder: dst,
150 }, nil)
151}
152
153func (s *daemonService) MarkRead(accountID, folder string, uids []uint32) error {
154 return s.client.Call(daemonrpc.MethodMarkRead, daemonrpc.MarkReadParams{
155 AccountID: accountID,
156 Folder: folder,
157 UIDs: uids,
158 Read: true,
159 }, nil)
160}
161
162func (s *daemonService) FetchFolders(accountID string) ([]backend.Folder, error) {
163 var folders []backend.Folder
164 err := s.client.Call(daemonrpc.MethodFetchFolders, daemonrpc.FetchFoldersParams{
165 AccountID: accountID,
166 }, &folders)
167 return folders, err
168}
169
170func (s *daemonService) RefreshFolder(accountID, folder string) error {
171 return s.client.Call(daemonrpc.MethodRefreshFolder, daemonrpc.RefreshFolderParams{
172 AccountID: accountID,
173 Folder: folder,
174 }, nil)
175}
176
177func (s *daemonService) Subscribe(accountID, folder string) error {
178 return s.client.Call(daemonrpc.MethodSubscribe, daemonrpc.SubscribeParams{
179 AccountID: accountID,
180 Folder: folder,
181 }, nil)
182}
183
184func (s *daemonService) Unsubscribe(accountID, folder string) error {
185 return s.client.Call(daemonrpc.MethodUnsubscribe, daemonrpc.UnsubscribeParams{
186 AccountID: accountID,
187 Folder: folder,
188 }, nil)
189}
190
191func (s *daemonService) ReloadConfig() error {
192 return s.client.Call(daemonrpc.MethodReloadConfig, nil, nil)
193}
194
195func (s *daemonService) Events() <-chan *daemonrpc.Event {
196 return s.client.Events()
197}
198
199func (s *daemonService) IsDaemon() bool { return true }
200
201func (s *daemonService) Close() error {
202 return s.client.Close()
203}
204
205// directService runs operations in-process (no daemon).
206// This is the fallback when daemon is not running.
207type directService struct {
208 cfg *config.Config
209 providers map[string]backend.Provider
210 events chan *daemonrpc.Event
211}
212
213func newDirectService(cfg *config.Config) *directService {
214 s := &directService{
215 cfg: cfg,
216 providers: make(map[string]backend.Provider),
217 events: make(chan *daemonrpc.Event, 64),
218 }
219 s.initProviders()
220 return s
221}
222
223func (s *directService) initProviders() {
224 for i := range s.cfg.Accounts {
225 acct := &s.cfg.Accounts[i]
226 if _, ok := s.providers[acct.ID]; ok {
227 continue
228 }
229 p, err := backend.New(acct)
230 if err != nil {
231 log.Printf("direct service: provider for %s failed: %v", acct.Email, err)
232 continue
233 }
234 s.providers[acct.ID] = p
235 }
236}
237
238func (s *directService) getProvider(accountID string) (backend.Provider, error) {
239 p, ok := s.providers[accountID]
240 if !ok {
241 return nil, &daemonrpc.Error{Code: daemonrpc.ErrCodeInternal, Message: "no provider for account " + accountID}
242 }
243 return p, nil
244}
245
246func (s *directService) FetchEmails(accountID, folder string, limit, offset uint32) ([]backend.Email, error) {
247 p, err := s.getProvider(accountID)
248 if err != nil {
249 return nil, err
250 }
251 return p.FetchEmails(context.Background(), folder, limit, offset)
252}
253
254func (s *directService) FetchEmailBody(accountID, folder string, uid uint32) (string, []backend.Attachment, error) {
255 p, err := s.getProvider(accountID)
256 if err != nil {
257 return "", nil, err
258 }
259 return p.FetchEmailBody(context.Background(), folder, uid)
260}
261
262func (s *directService) DeleteEmails(accountID, folder string, uids []uint32) error {
263 p, err := s.getProvider(accountID)
264 if err != nil {
265 return err
266 }
267 return p.DeleteEmails(context.Background(), folder, uids)
268}
269
270func (s *directService) ArchiveEmails(accountID, folder string, uids []uint32) error {
271 p, err := s.getProvider(accountID)
272 if err != nil {
273 return err
274 }
275 return p.ArchiveEmails(context.Background(), folder, uids)
276}
277
278func (s *directService) MoveEmails(accountID string, uids []uint32, src, dst string) error {
279 p, err := s.getProvider(accountID)
280 if err != nil {
281 return err
282 }
283 return p.MoveEmails(context.Background(), uids, src, dst)
284}
285
286func (s *directService) MarkRead(accountID, folder string, uids []uint32) error {
287 p, err := s.getProvider(accountID)
288 if err != nil {
289 return err
290 }
291 for _, uid := range uids {
292 if err := p.MarkAsRead(context.Background(), folder, uid); err != nil {
293 return err
294 }
295 }
296 return nil
297}
298
299func (s *directService) FetchFolders(accountID string) ([]backend.Folder, error) {
300 p, err := s.getProvider(accountID)
301 if err != nil {
302 return nil, err
303 }
304 return p.FetchFolders(context.Background())
305}
306
307func (s *directService) RefreshFolder(_, _ string) error {
308 // In direct mode, caller handles refresh via their own fetcher calls.
309 return nil
310}
311
312func (s *directService) Subscribe(_, _ string) error {
313 // No-op in direct mode — TUI manages its own IDLE.
314 return nil
315}
316
317func (s *directService) Unsubscribe(_, _ string) error {
318 return nil
319}
320
321func (s *directService) ReloadConfig() error {
322 cfg, err := config.LoadConfig()
323 if err != nil {
324 return err
325 }
326 s.cfg = cfg
327 s.initProviders()
328 return nil
329}
330
331func (s *directService) Events() <-chan *daemonrpc.Event {
332 return s.events
333}
334
335func (s *directService) IsDaemon() bool { return false }
336
337func (s *directService) Close() error {
338 for _, p := range s.providers {
339 p.Close()
340 }
341 close(s.events)
342 return nil
343}