1package cli
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "net/url"
8 "os"
9 "os/exec"
10 "strings"
11
12 "github.com/bnema/dumber/internal/cache"
13 "github.com/bnema/dumber/internal/config"
14
15 "github.com/spf13/cobra"
16)
17
18// NewBrowseCmd creates the browse command
19func NewBrowseCmd() *cobra.Command {
20 var renderingMode string
21
22 cmd := &cobra.Command{
23 Use: "browse <url|query>",
24 Short: "Browse a URL or search query",
25 Long: `Browse a URL directly or use search shortcuts like:
26 g:golang -> Google search for "golang"
27 gh:cobra -> GitHub search for "cobra"
28 yt:tutorials -> YouTube search for "tutorials"
29 w:go language -> Wikipedia search for "go language"
30
31Direct URLs are also supported:
32 https://example.com
33 example.com (automatically adds https://)`,
34 Args: cobra.ExactArgs(1),
35 RunE: func(_ *cobra.Command, args []string) error {
36 // Propagate rendering mode to GUI process via env var
37 if renderingMode != "" {
38 rm := strings.ToLower(renderingMode)
39 switch rm {
40 case "auto", "gpu", "cpu":
41 _ = os.Setenv("DUMBER_RENDERING_MODE", rm)
42 default:
43 return fmt.Errorf("invalid --rendering-mode: %s (expected auto|gpu|cpu)", renderingMode)
44 }
45 }
46 cli, err := NewCLI()
47 if err != nil {
48 return fmt.Errorf("failed to initialize CLI: %w", err)
49 }
50 defer func() {
51 if closeErr := cli.Close(); closeErr != nil {
52 fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
53 }
54 }()
55
56 return browse(cli, args[0])
57 },
58 }
59
60 // Flags
61 cmd.Flags().StringVar(&renderingMode, "rendering-mode", "", "Rendering mode: auto|gpu|cpu")
62
63 return cmd
64}
65
66// browse handles the core browsing logic
67func browse(cli *CLI, input string) error {
68 ctx := context.Background()
69
70 // Parse the input using parser service
71 result, err := cli.ParserService.ParseInput(ctx, input)
72 if err != nil {
73 return fmt.Errorf("failed to parse input: %w", err)
74 }
75
76 finalURL := result.URL
77
78 // Record in history
79 if err := recordVisit(ctx, cli, finalURL, ""); err != nil {
80 // Don't fail the browse operation if history recording fails
81 fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err)
82 }
83
84 // Update favicon asynchronously (non-blocking)
85 go updateFavicon(ctx, cli, finalURL)
86
87 // Open URL using configuration
88 return openURLWithConfig(finalURL, cli.Config)
89}
90
91// recordVisit adds or updates a URL visit in the history
92func recordVisit(ctx context.Context, cli *CLI, url, title string) error {
93 var sqlTitle sql.NullString
94 if title != "" {
95 sqlTitle = sql.NullString{String: title, Valid: true}
96 }
97
98 return cli.Queries.AddOrUpdateHistory(ctx, url, sqlTitle)
99}
100
101// openURL opens a URL using the configured browser
102func openURL(url string) error { //nolint:unused // Public API function
103 return openURLWithConfig(url, nil)
104}
105
106// openURLWithConfig opens a URL using our built-in browser
107func openURLWithConfig(url string, cfg *config.Config) error {
108 // Get the path to our own executable
109 executable, err := os.Executable()
110 if err != nil {
111 return fmt.Errorf("failed to get executable path: %w", err)
112 }
113
114 // Launch our own browser in browse mode with the URL directly
115 cmd := exec.Command(executable, "browse", url)
116
117 // The subprocess will read the same config file and apply the proper precedence:
118 // 1. Config file defaults
119 // 2. Environment variables (override config)
120 // 3. Command line flags (override both)
121 //
122 // We don't convert config to env vars here because that would break precedence.
123 // The cfg parameter ensures we use the same config source in both processes.
124
125 // Start the browser in detached mode
126 if err := cmd.Start(); err != nil {
127 return fmt.Errorf("failed to open URL in built-in browser: %w", err)
128 }
129
130 // Don't wait for the browser process to exit
131 go func() {
132 _ = cmd.Wait()
133 }()
134
135 fmt.Printf("Opening: %s (using built-in browser)\n", url)
136 return nil
137}
138
139// updateFavicon fetches and stores favicon URL for a given page URL
140func updateFavicon(ctx context.Context, cli *CLI, pageURL string) {
141 parsedURL, err := url.Parse(pageURL)
142 if err != nil {
143 return // Silently fail for invalid URLs
144 }
145
146 // Skip favicon update for localhost, file://, or special schemes
147 if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
148 return
149 }
150 if strings.Contains(parsedURL.Host, "localhost") || strings.Contains(parsedURL.Host, "127.0.0.1") {
151 return
152 }
153
154 // Standard favicon location
155 faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
156
157 // Update in database using the new sqlc-generated method
158 faviconNullString := sql.NullString{String: faviconURL, Valid: true}
159 if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
160 // Silently fail - favicon is not critical
161 return
162 }
163
164 // Also cache the favicon for dmenu use
165 if faviconCache, err := cache.NewFaviconCache(); err == nil {
166 faviconCache.CacheAsync(faviconURL)
167 }
168}