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 Tags []int64 `json:"tags"`
276 }{
277 RequestID: nextRequestID(),
278 URL: input.URL,
279 Title: input.Title,
280 FaviconURL: input.FaviconURL,
281 Tags: tagIDPayloads(input.Tags),
282 }
283}
284
285func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
286 payload := map[string]any{
287 "requestId": nextRequestID(),
288 "id": int64(input.ID),
289 "title": input.Title,
290 "favicon_url": input.FaviconURL,
291 }
292 if input.ShortcutKeySet {
293 payload["shortcut_key"] = input.ShortcutKey
294 }
295 return payload
296}
297
298func tagIDPayloads(ids []entity.TagID) []int64 {
299 out := make([]int64, 0, len(ids))
300 for _, id := range ids {
301 out = append(out, int64(id))
302 }
303 return out
304}
305
306func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
307 return request[[]*entity.Tag](c, ctx, "tag_list", struct {
308 RequestID string `json:"requestId"`
309 }{RequestID: nextRequestID()})
310}
311
312func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error {
313 if favoriteID <= 0 {
314 return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID)
315 }
316 _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {
317 RequestID string `json:"requestId"`
318 FavoriteID int64 `json:"favorite_id"`
319 ShortcutKey *int `json:"shortcut_key"`
320 }{RequestID: nextRequestID(), FavoriteID: favoriteID, ShortcutKey: shortcutKey})
321 return err
322}
323
324func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
325 name = strings.TrimSpace(name)
326 if name == "" {
327 return nil, fmt.Errorf("tag name is required")
328 }
329 var colorPtr *string
330 if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
331 colorPtr = &trimmedColor
332 }
333 return request[*entity.Tag](c, ctx, "tag_create", struct {
334 RequestID string `json:"requestId"`
335 Name string `json:"name"`
336 Color *string `json:"color"`
337 }{RequestID: nextRequestID(), Name: name, Color: colorPtr})
338}
339
340func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error {
341 if id <= 0 {
342 return fmt.Errorf("update tag id must be positive, got %d", id)
343 }
344 trimmedName := strings.TrimSpace(name)
345 if trimmedName == "" {
346 return fmt.Errorf("tag name is required")
347 }
348 namePtr := &trimmedName
349 var colorPtr *string
350 if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
351 colorPtr = &trimmedColor
352 }
353 _, err := request[struct{}](c, ctx, "tag_update", struct {
354 RequestID string `json:"requestId"`
355 ID int64 `json:"id"`
356 Name *string `json:"name"`
357 Color *string `json:"color"`
358 }{RequestID: nextRequestID(), ID: id, Name: namePtr, Color: colorPtr})
359 return err
360}
361
362func (c *Client) DeleteTag(ctx context.Context, id int64) error {
363 if id <= 0 {
364 return fmt.Errorf("delete tag id must be positive, got %d", id)
365 }
366 _, err := request[struct{}](c, ctx, "tag_delete", struct {
367 RequestID string `json:"requestId"`
368 ID int64 `json:"id"`
369 }{RequestID: nextRequestID(), ID: id})
370 return err
371}
372
373func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
374 if favoriteID <= 0 {
375 return fmt.Errorf("assign tag favorite id must be positive, got %d", favoriteID)
376 }
377 if tagID <= 0 {
378 return fmt.Errorf("assign tag id must be positive, got %d", tagID)
379 }
380 _, err := request[struct{}](c, ctx, "tag_assign", struct {
381 RequestID string `json:"requestId"`
382 FavoriteID int64 `json:"favorite_id"`
383 TagID int64 `json:"tag_id"`
384 }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
385 return err
386}
387
388func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {
389 if favoriteID <= 0 {
390 return fmt.Errorf("remove tag favorite id must be positive, got %d", favoriteID)
391 }
392 if tagID <= 0 {
393 return fmt.Errorf("remove tag id must be positive, got %d", tagID)
394 }
395 _, err := request[struct{}](c, ctx, "tag_remove", struct {
396 RequestID string `json:"requestId"`
397 FavoriteID int64 `json:"favorite_id"`
398 TagID int64 `json:"tag_id"`
399 }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
400 return err
401}
402
403func (c *Client) transport() Transport {
404 if c == nil {
405 return nil
406 }
407 if c.native != nil && c.native.Available() {
408 return c.native
409 }
410 if c.fetch != nil && c.fetch.Available() {
411 return c.fetch
412 }
413 return nil
414}
415
416func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
417 var zero T
418
419 ctx, cancel := c.withBridgeRequestTimeout(ctx)
420 defer cancel()
421
422 raw, err := c.Send(ctx, msgType, payload)
423 if err != nil {
424 return zero, err
425 }
426
427 return decodeBridgeResponse[T](raw)
428}
429
430// withBridgeRequestTimeout applies the bridge request timeout; ctx must be non-nil.
431func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
432 timeout := defaultBridgeRequestTimeout
433 if c != nil {
434 timeout = c.requestTimeout
435 }
436 if _, ok := ctx.Deadline(); ok || timeout <= 0 {
437 return context.WithCancel(ctx)
438 }
439 return context.WithTimeout(ctx, timeout)
440}
441
442type bridgeResponse struct {
443 RequestID string `json:"requestId"`
444 Success bool `json:"success"`
445 Data json.RawMessage `json:"data,omitempty"`
446 Error string `json:"error,omitempty"`
447}
448
449func decodeBridgeResponse[T any](body []byte) (T, error) {
450 var result T
451
452 var resp bridgeResponse
453 if err := json.Unmarshal(body, &resp); err != nil {
454 return result, fmt.Errorf("unmarshal bridge response: %w", err)
455 }
456 if !resp.Success {
457 if resp.Error == "" {
458 return result, errors.New("bridge request failed")
459 }
460 return result, errors.New(resp.Error)
461 }
462 if len(resp.Data) == 0 {
463 return result, nil
464 }
465
466 if err := json.Unmarshal(resp.Data, &result); err != nil {
467 return result, fmt.Errorf("unmarshal bridge data: %w", err)
468 }
469
470 return result, nil
471}
472
473func nextRequestID() string {
474 return fmt.Sprintf("systemviews-%d", requestSeq.Add(1))
475}
476
477func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {
478 if msgType == "" {
479 return nil, errors.New("message type cannot be empty")
480 }
481
482 body, err := json.Marshal(payload)
483 if err != nil {
484 return nil, fmt.Errorf("marshal payload: %w", err)
485 }
486
487 envelope := port.WebUIMessage{
488 Type: msgType,
489 Payload: body,
490 }
491
492 data, err := json.Marshal(envelope)
493 if err != nil {
494 return nil, fmt.Errorf("marshal envelope: %w", err)
495 }
496
497 return data, nil
498}