1package systemviewsbridge
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 "sync/atomic"
10 "time"
11
12 "github.com/bnema/dumber/internal/application/dto"
13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/domain/entity"
15)
16
17// Transport sends a WebUI message envelope and returns the raw response body.
18type Transport interface {
19 Available() bool
20 Send(ctx context.Context, body []byte) ([]byte, error)
21}
22
23// Client sends WebUI message envelopes through the first available transport.
24type Client struct {
25 native Transport
26 fetch Transport
27 requestTimeout time.Duration
28}
29
30var _ port.SystemviewConfigService = (*Client)(nil)
31var _ port.SystemviewHistoryService = (*Client)(nil)
32var _ port.SystemviewFavoritesService = (*Client)(nil)
33
34var requestSeq atomic.Uint64
35
36const defaultBridgeRequestTimeout = 15 * time.Second
37
38// ClientOption customizes a bridge client.
39type ClientOption func(*Client)
40
41// WithRequestTimeout sets the timeout used when callers provide no deadline.
42func WithRequestTimeout(timeout time.Duration) ClientOption {
43 return func(c *Client) {
44 c.requestTimeout = timeout
45 }
46}
47
48// NewClient creates a bridge client with native WebKit and fetch transports.
49func NewClient(native, fetch Transport, opts ...ClientOption) *Client {
50 client := &Client{native: native, fetch: fetch, requestTimeout: defaultBridgeRequestTimeout}
51 for _, opt := range opts {
52 if opt != nil {
53 opt(client)
54 }
55 }
56 return client
57}
58
59// Send builds a WebUI message envelope and sends it through the active transport.
60func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte, error) {
61 if c == nil {
62 return nil, errors.New("bridge client is nil")
63 }
64
65 envelope, err := buildMessageEnvelope(msgType, payload)
66 if err != nil {
67 return nil, err
68 }
69
70 transport := c.transport()
71 if transport == nil {
72 return nil, errors.New("no bridge transport available")
73 }
74
75 return transport.Send(ctx, envelope)
76}
77
78func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
79 if limit < 0 {
80 return nil, fmt.Errorf("timeline limit must be non-negative, got %d", limit)
81 }
82 if offset < 0 {
83 return nil, fmt.Errorf("timeline offset must be non-negative, got %d", offset)
84 }
85 return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct {
86 RequestID string `json:"requestId"`
87 Limit int `json:"limit"`
88 Offset int `json:"offset"`
89 }{RequestID: nextRequestID(), Limit: limit, Offset: offset})
90}
91
92func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
93 domain = strings.TrimSpace(domain)
94 if domain == "" {
95 return nil, fmt.Errorf("timeline-by-domain domain must be non-empty")
96 }
97 if limit < 0 {
98 return nil, fmt.Errorf("timeline-by-domain limit must be non-negative, got %d", limit)
99 }
100 if offset < 0 {
101 return nil, fmt.Errorf("timeline-by-domain offset must be non-negative, got %d", offset)
102 }
103 return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct {
104 RequestID string `json:"requestId"`
105 Domain string `json:"domain"`
106 Limit int `json:"limit"`
107 Offset int `json:"offset"`
108 }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset})
109}
110
111func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
112 beforeCursor := ""
113 if !before.IsZero() {
114 beforeCursor = before.Format(time.RFC3339Nano)
115 }
116 return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct {
117 RequestID string `json:"requestId"`
118 Before string `json:"before,omitempty"`
119 Domain string `json:"domain,omitempty"`
120 }{RequestID: nextRequestID(), Before: beforeCursor, Domain: strings.TrimSpace(domain)})
121}
122
123func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
124 if limit < 0 {
125 return nil, fmt.Errorf("search limit must be non-negative, got %d", limit)
126 }
127 return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {
128 RequestID string `json:"requestId"`
129 Query string `json:"query"`
130 Limit int `json:"limit"`
131 }{RequestID: nextRequestID(), Query: query, Limit: limit})
132}
133
134func (c *Client) DeleteEntry(ctx context.Context, id int64) error {
135 if id <= 0 {
136 return fmt.Errorf("delete entry id must be positive, got %d", id)
137 }
138 _, err := request[struct{}](c, ctx, "history_delete_entry", struct {
139 RequestID string `json:"requestId"`
140 ID int64 `json:"id"`
141 }{RequestID: nextRequestID(), ID: id})
142 return err
143}
144
145func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
146 rangeID = strings.TrimSpace(rangeID)
147 if rangeID == "" {
148 return errors.New("history range ID cannot be empty")
149 }
150 _, err := request[struct{}](c, ctx, "history_delete_range", struct {
151 RequestID string `json:"requestId"`
152 Range string `json:"range"`
153 }{RequestID: nextRequestID(), Range: rangeID})
154 return err
155}
156
157// Current intentionally targets the direct HTTP config endpoint rather than a
158// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config.
159func (c *Client) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
160 return request[dto.SystemviewConfigPayload](c, ctx, "/api/config", struct {
161 RequestID string `json:"requestId"`
162 }{RequestID: nextRequestID()})
163}
164
165// Default intentionally targets the direct HTTP config endpoint rather than a
166// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default.
167func (c *Client) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
168 return request[dto.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
169 RequestID string `json:"requestId"`
170 }{RequestID: nextRequestID()})
171}
172
173func (c *Client) Save(ctx context.Context, cfg dto.WebUIConfig) error {
174 _, err := request[struct{}](c, ctx, "save_config", cfg)
175 return err
176}
177
178func (c *Client) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
179 return request[port.KeybindingsConfig](c, ctx, "get_keybindings", struct {
180 RequestID string `json:"requestId"`
181 }{RequestID: nextRequestID()})
182}
183
184func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
185 return request[port.SetKeybindingResponse](c, ctx, "set_keybinding", req)
186}
187
188func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
189 _, err := request[struct{}](c, ctx, "reset_keybinding", req)
190 return err
191}
192
193func (c *Client) ResetAllKeybindings(ctx context.Context) error {
194 _, err := request[struct{}](c, ctx, "reset_all_keybindings", struct {
195 RequestID string `json:"requestId"`
196 }{RequestID: nextRequestID()})
197 return err
198}
199
200func (c *Client) Stats(ctx context.Context) (*entity.HistoryStats, error) {
201 return request[*entity.HistoryStats](c, ctx, "history_stats", struct {
202 RequestID string `json:"requestId"`
203 }{RequestID: nextRequestID()})
204}
205
206func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
207 return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct {
208 RequestID string `json:"requestId"`
209 }{RequestID: nextRequestID()})
210}
211
212func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
213 if limit < 0 {
214 return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit)
215 }
216 return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct {
217 RequestID string `json:"requestId"`
218 Limit int `json:"limit"`
219 }{RequestID: nextRequestID(), Limit: limit})
220}
221
222func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
223 domain = strings.TrimSpace(domain)
224 if domain == "" {
225 return errors.New("history domain cannot be empty")
226 }
227 _, err := request[struct{}](c, ctx, "history_delete_domain", struct {
228 RequestID string `json:"requestId"`
229 Domain string `json:"domain"`
230 }{RequestID: nextRequestID(), Domain: domain})
231 return err
232}
233
234func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) {
235 return request[[]*entity.Favorite](c, ctx, "favorite_list", struct {
236 RequestID string `json:"requestId"`
237 }{RequestID: nextRequestID()})
238}
239
240func (c *Client) CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
241 return request[*entity.Favorite](c, ctx, "favorite_create", favoriteCreatePayload(input))
242}
243
244func (c *Client) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
245 if input.ID <= 0 {
246 return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID)
247 }
248 return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input))
249}
250
251func (c *Client) DeleteFavorite(ctx context.Context, id int64) error {
252 if id <= 0 {
253 return fmt.Errorf("delete favorite id must be positive, got %d", id)
254 }
255 _, err := request[struct{}](c, ctx, "favorite_delete", struct {
256 RequestID string `json:"requestId"`
257 ID int64 `json:"id"`
258 }{RequestID: nextRequestID(), ID: id})
259 return err
260}
261
262func favoriteCreatePayload(input dto.FavoriteCreateInput) any {
263 return struct {
264 RequestID string `json:"requestId"`
265 URL string `json:"url"`
266 Title string `json:"title"`
267 FaviconURL string `json:"favicon_url"`
268 FolderID *int64 `json:"folder_id"`
269 Tags []int64 `json:"tags"`
270 }{
271 RequestID: nextRequestID(),
272 URL: input.URL,
273 Title: input.Title,
274 FaviconURL: input.FaviconURL,
275 FolderID: folderIDPayload(input.FolderID),
276 Tags: tagIDPayloads(input.Tags),
277 }
278}
279
280func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
281 return struct {
282 RequestID string `json:"requestId"`
283 ID int64 `json:"id"`
284 Title string `json:"title"`
285 FaviconURL string `json:"favicon_url"`
286 FolderID *int64 `json:"folder_id"`
287 ShortcutKey *int `json:"shortcut_key"`
288 }{
289 RequestID: nextRequestID(),
290 ID: int64(input.ID),
291 Title: input.Title,
292 FaviconURL: input.FaviconURL,
293 FolderID: folderIDPayload(input.FolderID),
294 ShortcutKey: input.ShortcutKey,
295 }
296}
297
298func folderIDPayload(id *entity.FolderID) *int64 {
299 if id == nil {
300 return nil
301 }
302 value := int64(*id)
303 return &value
304}
305
306func tagIDPayloads(ids []entity.TagID) []int64 {
307 out := make([]int64, 0, len(ids))
308 for _, id := range ids {
309 out = append(out, int64(id))
310 }
311 return out
312}
313
314func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) {
315 return request[[]*entity.Folder](c, ctx, "folder_list", struct {
316 RequestID string `json:"requestId"`
317 }{RequestID: nextRequestID()})
318}
319
320func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
321 return request[[]*entity.Tag](c, ctx, "tag_list", struct {
322 RequestID string `json:"requestId"`
323 }{RequestID: nextRequestID()})
324}
325
326func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error {
327 if favoriteID <= 0 {
328 return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID)
329 }
330 _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {
331 RequestID string `json:"requestId"`
332 FavoriteID int64 `json:"favorite_id"`
333 ShortcutKey *int `json:"shortcut_key"`
334 }{RequestID: nextRequestID(), FavoriteID: favoriteID, ShortcutKey: shortcutKey})
335 return err
336}
337
338func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error {
339 if favoriteID <= 0 {
340 return fmt.Errorf("set folder favorite id must be positive, got %d", favoriteID)
341 }
342 _, err := request[struct{}](c, ctx, "favorite_set_folder", struct {
343 RequestID string `json:"requestId"`
344 FavoriteID int64 `json:"favorite_id"`
345 FolderID *int64 `json:"folder_id"`
346 }{RequestID: nextRequestID(), FavoriteID: favoriteID, FolderID: folderID})
347 return err
348}
349
350func (c *Client) CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) {
351 name = strings.TrimSpace(name)
352 if name == "" {
353 return nil, fmt.Errorf("folder name is required")
354 }
355 var iconPtr *string
356 if trimmedIcon := strings.TrimSpace(icon); trimmedIcon != "" {
357 iconPtr = &trimmedIcon
358 }
359 return request[*entity.Folder](c, ctx, "folder_create", struct {
360 RequestID string `json:"requestId"`
361 Name string `json:"name"`
362 Icon *string `json:"icon"`
363 ParentID *int64 `json:"parent_id,omitempty"`
364 }{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID})
365}
366
367func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {
368 if id <= 0 {
369 return fmt.Errorf("update folder id must be positive, got %d", id)
370 }
371 name = strings.TrimSpace(name)
372 if name == "" {
373 return fmt.Errorf("folder name is required")
374 }
375 var iconPtr *string
376 if trimmed := strings.TrimSpace(icon); trimmed != "" {
377 iconPtr = &trimmed
378 }
379 _, err := request[struct{}](c, ctx, "folder_update", struct {
380 RequestID string `json:"requestId"`
381 ID int64 `json:"id"`
382 Name string `json:"name"`
383 Icon *string `json:"icon"`
384 }{RequestID: nextRequestID(), ID: id, Name: name, Icon: iconPtr})
385 return err
386}
387
388func (c *Client) DeleteFolder(ctx context.Context, id int64) error {
389 if id <= 0 {
390 return fmt.Errorf("delete folder id must be positive, got %d", id)
391 }
392 _, err := request[struct{}](c, ctx, "folder_delete", struct {
393 RequestID string `json:"requestId"`
394 ID int64 `json:"id"`
395 }{RequestID: nextRequestID(), ID: id})
396 return err
397}
398
399func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
400 name = strings.TrimSpace(name)
401 if name == "" {
402 return nil, fmt.Errorf("tag name is required")
403 }
404 var colorPtr *string
405 if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
406 colorPtr = &trimmedColor
407 }
408 return request[*entity.Tag](c, ctx, "tag_create", struct {
409 RequestID string `json:"requestId"`
410 Name string `json:"name"`
411 Color *string `json:"color"`
412 }{RequestID: nextRequestID(), Name: name, Color: colorPtr})
413}
414
415func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error {
416 if id <= 0 {
417 return fmt.Errorf("update tag id must be positive, got %d", id)
418 }
419 trimmedName := strings.TrimSpace(name)
420 if trimmedName == "" {
421 return fmt.Errorf("tag name is required")
422 }
423 namePtr := &trimmedName
424 var colorPtr *string
425 if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
426 colorPtr = &trimmedColor
427 }
428 _, err := request[struct{}](c, ctx, "tag_update", struct {
429 RequestID string `json:"requestId"`
430 ID int64 `json:"id"`
431 Name *string `json:"name"`
432 Color *string `json:"color"`
433 }{RequestID: nextRequestID(), ID: id, Name: namePtr, Color: colorPtr})
434 return err
435}
436
437func (c *Client) DeleteTag(ctx context.Context, id int64) error {
438 if id <= 0 {
439 return fmt.Errorf("delete tag id must be positive, got %d", id)
440 }
441 _, err := request[struct{}](c, ctx, "tag_delete", struct {
442 RequestID string `json:"requestId"`
443 ID int64 `json:"id"`
444 }{RequestID: nextRequestID(), ID: id})
445 return err
446}
447
448func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
449 if favoriteID <= 0 {
450 return fmt.Errorf("assign tag favorite id must be positive, got %d", favoriteID)
451 }
452 if tagID <= 0 {
453 return fmt.Errorf("assign tag id must be positive, got %d", tagID)
454 }
455 _, err := request[struct{}](c, ctx, "tag_assign", struct {
456 RequestID string `json:"requestId"`
457 FavoriteID int64 `json:"favorite_id"`
458 TagID int64 `json:"tag_id"`
459 }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
460 return err
461}
462
463func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {
464 if favoriteID <= 0 {
465 return fmt.Errorf("remove tag favorite id must be positive, got %d", favoriteID)
466 }
467 if tagID <= 0 {
468 return fmt.Errorf("remove tag id must be positive, got %d", tagID)
469 }
470 _, err := request[struct{}](c, ctx, "tag_remove", struct {
471 RequestID string `json:"requestId"`
472 FavoriteID int64 `json:"favorite_id"`
473 TagID int64 `json:"tag_id"`
474 }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
475 return err
476}
477
478func (c *Client) transport() Transport {
479 if c == nil {
480 return nil
481 }
482 if c.native != nil && c.native.Available() {
483 return c.native
484 }
485 if c.fetch != nil && c.fetch.Available() {
486 return c.fetch
487 }
488 return nil
489}
490
491func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
492 var zero T
493
494 ctx, cancel := c.withBridgeRequestTimeout(ctx)
495 defer cancel()
496
497 raw, err := c.Send(ctx, msgType, payload)
498 if err != nil {
499 return zero, err
500 }
501
502 return decodeBridgeResponse[T](raw)
503}
504
505// withBridgeRequestTimeout applies the bridge request timeout; ctx must be non-nil.
506func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
507 timeout := defaultBridgeRequestTimeout
508 if c != nil {
509 timeout = c.requestTimeout
510 }
511 if _, ok := ctx.Deadline(); ok || timeout <= 0 {
512 return context.WithCancel(ctx)
513 }
514 return context.WithTimeout(ctx, timeout)
515}
516
517type bridgeResponse struct {
518 RequestID string `json:"requestId"`
519 Success bool `json:"success"`
520 Data json.RawMessage `json:"data,omitempty"`
521 Error string `json:"error,omitempty"`
522}
523
524func decodeBridgeResponse[T any](body []byte) (T, error) {
525 var result T
526
527 var resp bridgeResponse
528 if err := json.Unmarshal(body, &resp); err != nil {
529 return result, fmt.Errorf("unmarshal bridge response: %w", err)
530 }
531 if !resp.Success {
532 if resp.Error == "" {
533 return result, errors.New("bridge request failed")
534 }
535 return result, errors.New(resp.Error)
536 }
537 if len(resp.Data) == 0 {
538 return result, nil
539 }
540
541 if err := json.Unmarshal(resp.Data, &result); err != nil {
542 return result, fmt.Errorf("unmarshal bridge data: %w", err)
543 }
544
545 return result, nil
546}
547
548func nextRequestID() string {
549 return fmt.Sprintf("systemviews-%d", requestSeq.Add(1))
550}
551
552func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {
553 if msgType == "" {
554 return nil, errors.New("message type cannot be empty")
555 }
556
557 body, err := json.Marshal(payload)
558 if err != nil {
559 return nil, fmt.Errorf("marshal payload: %w", err)
560 }
561
562 envelope := port.WebUIMessage{
563 Type: msgType,
564 Payload: body,
565 }
566
567 data, err := json.Marshal(envelope)
568 if err != nil {
569 return nil, fmt.Errorf("marshal envelope: %w", err)
570 }
571
572 return data, nil
573}