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