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