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
92// recordVisit adds or updates a URL visit in the history
93func recordVisit(ctx context.Context, cli *CLI, url, title string) error {
94 var sqlTitle sql.NullString
95 if title != "" {
96 sqlTitle = sql.NullString{String: title, Valid: true}
97 }
98
99 return cli.Queries.AddOrUpdateHistory(ctx, url, sqlTitle)
100}
101
102// openURL opens a URL using the configured browser
103func openURL(url string) error { //nolint:unused // Public API function
104 return openURLWithConfig(url, nil)
105}
106
107// openURLWithConfig opens a URL using our built-in browser
108func openURLWithConfig(url string, cfg *config.Config) error {
109 // Get the path to our own executable
110 executable, err := os.Executable()
111 if err != nil {
112 return fmt.Errorf("failed to get executable path: %w", err)
113 }
114
115 // Launch our own browser in browse mode with the URL directly
116 cmd := exec.Command(executable, "browse", url)
117
118 // The subprocess will read the same config file and apply the proper precedence:
119 // 1. Config file defaults
120 // 2. Environment variables (override config)
121 // 3. Command line flags (override both)
122 //
123 // We don't convert config to env vars here because that would break precedence.
124 // The cfg parameter ensures we use the same config source in both processes.
125
126 // Start the browser in detached mode
127 if err := cmd.Start(); err != nil {
128 return fmt.Errorf("failed to open URL in built-in browser: %w", err)
129 }
130
131 // Don't wait for the browser process to exit
132 go func() {
133 _ = cmd.Wait()
134 }()
135
136 fmt.Printf("Opening: %s (using built-in browser)\n", url)
137 return nil
138}
139
140// updateFavicon fetches and stores favicon URL for a given page URL
141func updateFavicon(ctx context.Context, cli *CLI, pageURL string) {
142 parsedURL, err := url.Parse(pageURL)
143 if err != nil {
144 return // Silently fail for invalid URLs
145 }
146
147 // Skip favicon update for localhost, file://, or special schemes
148 if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
149 return
150 }
151 if strings.Contains(parsedURL.Host, "localhost") || strings.Contains(parsedURL.Host, "127.0.0.1") {
152 return
153 }
154
155 // Standard favicon location
156 faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
157
158 // Update in database using the new sqlc-generated method
159 faviconNullString := sql.NullString{String: faviconURL, Valid: true}
160 if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
161 // Silently fail - favicon is not critical
162 return
163 }
164
165 // Also cache the favicon for dmenu use
166 if faviconCache, err := cache.NewFaviconCache(); err == nil {
167 faviconCache.CacheAsync(faviconURL)
168 }
169}
170