refactor(arch): extract browser logic to modular internal/app structure
- commit
- c6e449fca7e2c556b55492b0cda4f26344c40e26
- author
- bamen · · 2025-09-11 16:26 UTC
- committer
- bamen · · 2025-09-11 16:14 UTC
- parent
- faf9893
- signature
- unsigned
15 files · +1600 -0
A internal/app/api/routes.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package api | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "log" | |
| 7 | + | neturl "net/url" | |
| 8 | + | "strconv" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/app/constants" | |
| 11 | + | "github.com/bnema/dumber/internal/config" | |
| 12 | + | "github.com/bnema/dumber/services" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Handler provides API endpoint handling functionality | |
| 16 | + | type Handler struct { | |
| 17 | + | browserService *services.BrowserService | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // NewHandler creates a new API handler | |
| 21 | + | func NewHandler(browserService *services.BrowserService) *Handler { | |
| 22 | + | return &Handler{ | |
| 23 | + | browserService: browserService, | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // HandleRenderingStatus handles GET /rendering/status endpoint | |
| 28 | + | func (h *Handler) HandleRenderingStatus(cfg *config.Config) (string, []byte, bool) { | |
| 29 | + | log.Printf("[api] GET /rendering/status") | |
| 30 | + | // Report configured rendering mode; runtime GPU state depends on WebKit internals | |
| 31 | + | resp := struct { | |
| 32 | + | Mode string `json:"mode"` | |
| 33 | + | }{Mode: string(cfg.RenderingMode)} | |
| 34 | + | b, _ := json.Marshal(resp) | |
| 35 | + | return constants.ContentTypeJSON, b, true | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // HandleConfig handles GET /config endpoint | |
| 39 | + | func (h *Handler) HandleConfig(cfg *config.Config) (string, []byte, bool) { | |
| 40 | + | log.Printf("[api] GET /config") | |
| 41 | + | // Build config info | |
| 42 | + | cfgPath, _ := config.GetConfigFile() | |
| 43 | + | info := struct { | |
| 44 | + | ConfigPath string `json:"config_path"` | |
| 45 | + | DatabasePath string `json:"database_path"` | |
| 46 | + | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | |
| 47 | + | Appearance config.AppearanceConfig `json:"appearance"` | |
| 48 | + | }{ | |
| 49 | + | ConfigPath: cfgPath, | |
| 50 | + | DatabasePath: cfg.Database.Path, | |
| 51 | + | SearchShortcuts: cfg.SearchShortcuts, | |
| 52 | + | Appearance: cfg.Appearance, | |
| 53 | + | } | |
| 54 | + | b, _ := json.Marshal(info) | |
| 55 | + | return constants.ContentTypeJSON, b, true | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // HandleHistoryRecent handles GET /history/recent endpoint | |
| 59 | + | func (h *Handler) HandleHistoryRecent(u *neturl.URL) (string, []byte, bool) { | |
| 60 | + | log.Printf("[api] GET /history/recent%s", u.RawQuery) | |
| 61 | + | // Parse limit | |
| 62 | + | q := u.Query() | |
| 63 | + | limit := 50 | |
| 64 | + | if l := q.Get("limit"); l != "" { | |
| 65 | + | if n, err := strconv.Atoi(l); err == nil && n > 0 { | |
| 66 | + | limit = n | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ctx := context.Background() | |
| 70 | + | entries, err := h.browserService.GetRecentHistory(ctx, limit) | |
| 71 | + | if err != nil { | |
| 72 | + | return constants.ContentTypeJSON, []byte("[]"), true | |
| 73 | + | } | |
| 74 | + | b, _ := json.Marshal(entries) | |
| 75 | + | return constants.ContentTypeJSON, b, true | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // HandleHistorySearch handles GET /history/search endpoint | |
| 79 | + | func (h *Handler) HandleHistorySearch(u *neturl.URL) (string, []byte, bool) { | |
| 80 | + | log.Printf("[api] GET /history/search%s", u.RawQuery) | |
| 81 | + | q := u.Query() | |
| 82 | + | query := q.Get("q") | |
| 83 | + | limit := 50 | |
| 84 | + | if l := q.Get("limit"); l != "" { | |
| 85 | + | if n, err := strconv.Atoi(l); err == nil && n > 0 { | |
| 86 | + | limit = n | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ctx := context.Background() | |
| 90 | + | entries, err := h.browserService.SearchHistory(ctx, query, limit) | |
| 91 | + | if err != nil { | |
| 92 | + | return constants.ContentTypeJSON, []byte("[]"), true | |
| 93 | + | } | |
| 94 | + | b, _ := json.Marshal(entries) | |
| 95 | + | return constants.ContentTypeJSON, b, true | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // HandleHistoryStats handles GET /history/stats endpoint | |
| 99 | + | func (h *Handler) HandleHistoryStats() (string, []byte, bool) { | |
| 100 | + | log.Printf("[api] GET /history/stats") | |
| 101 | + | ctx := context.Background() | |
| 102 | + | stats, err := h.browserService.GetHistoryStats(ctx) | |
| 103 | + | if err != nil { | |
| 104 | + | return constants.ContentTypeJSON, []byte("{}"), true | |
| 105 | + | } | |
| 106 | + | b, _ := json.Marshal(stats) | |
| 107 | + | return constants.ContentTypeJSON, b, true | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // HandleDefault returns empty JSON for unknown endpoints | |
| 111 | + | func (h *Handler) HandleDefault() (string, []byte, bool) { | |
| 112 | + | return constants.ContentTypeJSON, []byte("{}"), true | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package api | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "log" | |||
| 7 | + | neturl "net/url" | |||
| 8 | + | "strconv" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/app/constants" | |||
| 11 | + | "github.com/bnema/dumber/internal/config" | |||
| 12 | + | "github.com/bnema/dumber/services" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Handler provides API endpoint handling functionality | |||
| 16 | + | type Handler struct { | |||
| 17 | + | browserService *services.BrowserService | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // NewHandler creates a new API handler | |||
| 21 | + | func NewHandler(browserService *services.BrowserService) *Handler { | |||
| 22 | + | return &Handler{ | |||
| 23 | + | browserService: browserService, | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // HandleRenderingStatus handles GET /rendering/status endpoint | |||
| 28 | + | func (h *Handler) HandleRenderingStatus(cfg *config.Config) (string, []byte, bool) { | |||
| 29 | + | log.Printf("[api] GET /rendering/status") | |||
| 30 | + | // Report configured rendering mode; runtime GPU state depends on WebKit internals | |||
| 31 | + | resp := struct { | |||
| 32 | + | Mode string `json:"mode"` | |||
| 33 | + | }{Mode: string(cfg.RenderingMode)} | |||
| 34 | + | b, _ := json.Marshal(resp) | |||
| 35 | + | return constants.ContentTypeJSON, b, true | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // HandleConfig handles GET /config endpoint | |||
| 39 | + | func (h *Handler) HandleConfig(cfg *config.Config) (string, []byte, bool) { | |||
| 40 | + | log.Printf("[api] GET /config") | |||
| 41 | + | // Build config info | |||
| 42 | + | cfgPath, _ := config.GetConfigFile() | |||
| 43 | + | info := struct { | |||
| 44 | + | ConfigPath string `json:"config_path"` | |||
| 45 | + | DatabasePath string `json:"database_path"` | |||
| 46 | + | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | |||
| 47 | + | Appearance config.AppearanceConfig `json:"appearance"` | |||
| 48 | + | }{ | |||
| 49 | + | ConfigPath: cfgPath, | |||
| 50 | + | DatabasePath: cfg.Database.Path, | |||
| 51 | + | SearchShortcuts: cfg.SearchShortcuts, | |||
| 52 | + | Appearance: cfg.Appearance, | |||
| 53 | + | } | |||
| 54 | + | b, _ := json.Marshal(info) | |||
| 55 | + | return constants.ContentTypeJSON, b, true | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // HandleHistoryRecent handles GET /history/recent endpoint | |||
| 59 | + | func (h *Handler) HandleHistoryRecent(u *neturl.URL) (string, []byte, bool) { | |||
| 60 | + | log.Printf("[api] GET /history/recent%s", u.RawQuery) | |||
| 61 | + | // Parse limit | |||
| 62 | + | q := u.Query() | |||
| 63 | + | limit := 50 | |||
| 64 | + | if l := q.Get("limit"); l != "" { | |||
| 65 | + | if n, err := strconv.Atoi(l); err == nil && n > 0 { | |||
| 66 | + | limit = n | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ctx := context.Background() | |||
| 70 | + | entries, err := h.browserService.GetRecentHistory(ctx, limit) | |||
| 71 | + | if err != nil { | |||
| 72 | + | return constants.ContentTypeJSON, []byte("[]"), true | |||
| 73 | + | } | |||
| 74 | + | b, _ := json.Marshal(entries) | |||
| 75 | + | return constants.ContentTypeJSON, b, true | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // HandleHistorySearch handles GET /history/search endpoint | |||
| 79 | + | func (h *Handler) HandleHistorySearch(u *neturl.URL) (string, []byte, bool) { | |||
| 80 | + | log.Printf("[api] GET /history/search%s", u.RawQuery) | |||
| 81 | + | q := u.Query() | |||
| 82 | + | query := q.Get("q") | |||
| 83 | + | limit := 50 | |||
| 84 | + | if l := q.Get("limit"); l != "" { | |||
| 85 | + | if n, err := strconv.Atoi(l); err == nil && n > 0 { | |||
| 86 | + | limit = n | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ctx := context.Background() | |||
| 90 | + | entries, err := h.browserService.SearchHistory(ctx, query, limit) | |||
| 91 | + | if err != nil { | |||
| 92 | + | return constants.ContentTypeJSON, []byte("[]"), true | |||
| 93 | + | } | |||
| 94 | + | b, _ := json.Marshal(entries) | |||
| 95 | + | return constants.ContentTypeJSON, b, true | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // HandleHistoryStats handles GET /history/stats endpoint | |||
| 99 | + | func (h *Handler) HandleHistoryStats() (string, []byte, bool) { | |||
| 100 | + | log.Printf("[api] GET /history/stats") | |||
| 101 | + | ctx := context.Background() | |||
| 102 | + | stats, err := h.browserService.GetHistoryStats(ctx) | |||
| 103 | + | if err != nil { | |||
| 104 | + | return constants.ContentTypeJSON, []byte("{}"), true | |||
| 105 | + | } | |||
| 106 | + | b, _ := json.Marshal(stats) | |||
| 107 | + | return constants.ContentTypeJSON, b, true | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // HandleDefault returns empty JSON for unknown endpoints | |||
| 111 | + | func (h *Handler) HandleDefault() (string, []byte, bool) { | |||
| 112 | + | return constants.ContentTypeJSON, []byte("{}"), true | |||
| 113 | + | } | |||
A internal/app/api/scheme.go +151 -0
| @@ -0,0 +1,151 @@ | |||
| 1 | + | package api | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "embed" | |
| 5 | + | "log" | |
| 6 | + | "mime" | |
| 7 | + | neturl "net/url" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/app/constants" | |
| 12 | + | "github.com/bnema/dumber/internal/config" | |
| 13 | + | "github.com/bnema/dumber/services" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // SchemeHandler handles custom dumb:// scheme resolution | |
| 17 | + | type SchemeHandler struct { | |
| 18 | + | assets embed.FS | |
| 19 | + | apiHandler *Handler | |
| 20 | + | parserService *services.ParserService | |
| 21 | + | browserService *services.BrowserService | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // NewSchemeHandler creates a new scheme handler | |
| 25 | + | func NewSchemeHandler( | |
| 26 | + | assets embed.FS, | |
| 27 | + | parserService *services.ParserService, | |
| 28 | + | browserService *services.BrowserService, | |
| 29 | + | ) *SchemeHandler { | |
| 30 | + | return &SchemeHandler{ | |
| 31 | + | assets: assets, | |
| 32 | + | apiHandler: NewHandler(browserService), | |
| 33 | + | parserService: parserService, | |
| 34 | + | browserService: browserService, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Handle processes dumb:// scheme requests | |
| 39 | + | func (s *SchemeHandler) Handle(uri string, cfg *config.Config) (string, []byte, bool) { | |
| 40 | + | log.Printf("[scheme] request: %s", uri) | |
| 41 | + | // Known forms: | |
| 42 | + | // - dumb://homepage or dumb:homepage → index.html | |
| 43 | + | // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path> | |
| 44 | + | // - dumb://<anything> without path → index.html | |
| 45 | + | u, err := neturl.Parse(uri) | |
| 46 | + | if err != nil || u.Scheme != "dumb" { | |
| 47 | + | return "", nil, false | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // API routes | |
| 51 | + | if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") { | |
| 52 | + | return s.handleAPIRoute(u, cfg) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Static assets | |
| 56 | + | return s.handleAsset(u) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // handleAPIRoute processes API requests | |
| 60 | + | func (s *SchemeHandler) handleAPIRoute(u *neturl.URL, cfg *config.Config) (string, []byte, bool) { | |
| 61 | + | // Normalize path for opaque or hierarchical forms | |
| 62 | + | path := u.Path | |
| 63 | + | if path == "" && u.Opaque != "" { | |
| 64 | + | // e.g., dumb:api/config or dumb:api/history/recent | |
| 65 | + | parts := strings.SplitN(u.Opaque, ":", constants.ColonSplitParts) | |
| 66 | + | if len(parts) == constants.ColonSplitParts { | |
| 67 | + | path = "/" + parts[1] | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if strings.HasPrefix(path, "/api/") { | |
| 71 | + | path = strings.TrimPrefix(path, "/api") | |
| 72 | + | } | |
| 73 | + | if path == "/api" { | |
| 74 | + | path = "/" | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | switch { | |
| 78 | + | case strings.HasPrefix(path, "/rendering/status"): | |
| 79 | + | return s.apiHandler.HandleRenderingStatus(cfg) | |
| 80 | + | case strings.HasPrefix(path, "/config"): | |
| 81 | + | return s.apiHandler.HandleConfig(cfg) | |
| 82 | + | case strings.HasPrefix(path, "/history/recent"): | |
| 83 | + | return s.apiHandler.HandleHistoryRecent(u) | |
| 84 | + | case strings.HasPrefix(path, "/history/search"): | |
| 85 | + | return s.apiHandler.HandleHistorySearch(u) | |
| 86 | + | case strings.HasPrefix(path, "/history/stats"): | |
| 87 | + | return s.apiHandler.HandleHistoryStats() | |
| 88 | + | default: | |
| 89 | + | return s.apiHandler.HandleDefault() | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // handleAsset serves static assets from embedded filesystem | |
| 94 | + | func (s *SchemeHandler) handleAsset(u *neturl.URL) (string, []byte, bool) { | |
| 95 | + | // Resolve target path inside embed FS | |
| 96 | + | var rel string | |
| 97 | + | if u.Opaque == constants.HomepagePath || (u.Host == constants.HomepagePath && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) { | |
| 98 | + | rel = constants.IndexHTML | |
| 99 | + | } else { | |
| 100 | + | host := u.Host | |
| 101 | + | p := strings.TrimPrefix(u.Path, "/") | |
| 102 | + | switch { | |
| 103 | + | case host == "app" && p == "": | |
| 104 | + | rel = constants.IndexHTML | |
| 105 | + | case host == "app": | |
| 106 | + | rel = p | |
| 107 | + | case host == constants.HomepagePath && p != "": | |
| 108 | + | // dumb://homepage/<asset> | |
| 109 | + | rel = p | |
| 110 | + | case p != "": | |
| 111 | + | rel = p | |
| 112 | + | default: | |
| 113 | + | rel = constants.IndexHTML | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // Special-case homepage favicon: map .ico request to embedded SVG file | |
| 118 | + | if (u.Host == constants.HomepagePath || u.Opaque == constants.HomepagePath) && strings.EqualFold(rel, "favicon.ico") { | |
| 119 | + | log.Printf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.svg", rel, u.Host, u.Path) | |
| 120 | + | data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", "favicon.svg"))) | |
| 121 | + | if rerr == nil { | |
| 122 | + | return "image/svg+xml", data, true | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path) | |
| 127 | + | data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel))) | |
| 128 | + | if rerr != nil { | |
| 129 | + | log.Printf("[scheme] not found: %s", rel) | |
| 130 | + | return "", nil, false | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Determine mime type | |
| 134 | + | mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel))) | |
| 135 | + | if mt == "" { | |
| 136 | + | // Fallbacks | |
| 137 | + | switch strings.ToLower(filepath.Ext(rel)) { | |
| 138 | + | case ".js": | |
| 139 | + | mt = "application/javascript" | |
| 140 | + | case ".css": | |
| 141 | + | mt = "text/css" | |
| 142 | + | case ".svg": | |
| 143 | + | mt = "image/svg+xml" | |
| 144 | + | case ".ico": | |
| 145 | + | mt = "image/x-icon" | |
| 146 | + | default: | |
| 147 | + | mt = "text/html; charset=utf-8" | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | return mt, data, true | |
| 151 | + | } | |
| @@ -0,0 +1,151 @@ | |||||
| 1 | + | package api | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "embed" | |||
| 5 | + | "log" | |||
| 6 | + | "mime" | |||
| 7 | + | neturl "net/url" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/app/constants" | |||
| 12 | + | "github.com/bnema/dumber/internal/config" | |||
| 13 | + | "github.com/bnema/dumber/services" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // SchemeHandler handles custom dumb:// scheme resolution | |||
| 17 | + | type SchemeHandler struct { | |||
| 18 | + | assets embed.FS | |||
| 19 | + | apiHandler *Handler | |||
| 20 | + | parserService *services.ParserService | |||
| 21 | + | browserService *services.BrowserService | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // NewSchemeHandler creates a new scheme handler | |||
| 25 | + | func NewSchemeHandler( | |||
| 26 | + | assets embed.FS, | |||
| 27 | + | parserService *services.ParserService, | |||
| 28 | + | browserService *services.BrowserService, | |||
| 29 | + | ) *SchemeHandler { | |||
| 30 | + | return &SchemeHandler{ | |||
| 31 | + | assets: assets, | |||
| 32 | + | apiHandler: NewHandler(browserService), | |||
| 33 | + | parserService: parserService, | |||
| 34 | + | browserService: browserService, | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Handle processes dumb:// scheme requests | |||
| 39 | + | func (s *SchemeHandler) Handle(uri string, cfg *config.Config) (string, []byte, bool) { | |||
| 40 | + | log.Printf("[scheme] request: %s", uri) | |||
| 41 | + | // Known forms: | |||
| 42 | + | // - dumb://homepage or dumb:homepage → index.html | |||
| 43 | + | // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path> | |||
| 44 | + | // - dumb://<anything> without path → index.html | |||
| 45 | + | u, err := neturl.Parse(uri) | |||
| 46 | + | if err != nil || u.Scheme != "dumb" { | |||
| 47 | + | return "", nil, false | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // API routes | |||
| 51 | + | if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") { | |||
| 52 | + | return s.handleAPIRoute(u, cfg) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Static assets | |||
| 56 | + | return s.handleAsset(u) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // handleAPIRoute processes API requests | |||
| 60 | + | func (s *SchemeHandler) handleAPIRoute(u *neturl.URL, cfg *config.Config) (string, []byte, bool) { | |||
| 61 | + | // Normalize path for opaque or hierarchical forms | |||
| 62 | + | path := u.Path | |||
| 63 | + | if path == "" && u.Opaque != "" { | |||
| 64 | + | // e.g., dumb:api/config or dumb:api/history/recent | |||
| 65 | + | parts := strings.SplitN(u.Opaque, ":", constants.ColonSplitParts) | |||
| 66 | + | if len(parts) == constants.ColonSplitParts { | |||
| 67 | + | path = "/" + parts[1] | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if strings.HasPrefix(path, "/api/") { | |||
| 71 | + | path = strings.TrimPrefix(path, "/api") | |||
| 72 | + | } | |||
| 73 | + | if path == "/api" { | |||
| 74 | + | path = "/" | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | switch { | |||
| 78 | + | case strings.HasPrefix(path, "/rendering/status"): | |||
| 79 | + | return s.apiHandler.HandleRenderingStatus(cfg) | |||
| 80 | + | case strings.HasPrefix(path, "/config"): | |||
| 81 | + | return s.apiHandler.HandleConfig(cfg) | |||
| 82 | + | case strings.HasPrefix(path, "/history/recent"): | |||
| 83 | + | return s.apiHandler.HandleHistoryRecent(u) | |||
| 84 | + | case strings.HasPrefix(path, "/history/search"): | |||
| 85 | + | return s.apiHandler.HandleHistorySearch(u) | |||
| 86 | + | case strings.HasPrefix(path, "/history/stats"): | |||
| 87 | + | return s.apiHandler.HandleHistoryStats() | |||
| 88 | + | default: | |||
| 89 | + | return s.apiHandler.HandleDefault() | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // handleAsset serves static assets from embedded filesystem | |||
| 94 | + | func (s *SchemeHandler) handleAsset(u *neturl.URL) (string, []byte, bool) { | |||
| 95 | + | // Resolve target path inside embed FS | |||
| 96 | + | var rel string | |||
| 97 | + | if u.Opaque == constants.HomepagePath || (u.Host == constants.HomepagePath && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) { | |||
| 98 | + | rel = constants.IndexHTML | |||
| 99 | + | } else { | |||
| 100 | + | host := u.Host | |||
| 101 | + | p := strings.TrimPrefix(u.Path, "/") | |||
| 102 | + | switch { | |||
| 103 | + | case host == "app" && p == "": | |||
| 104 | + | rel = constants.IndexHTML | |||
| 105 | + | case host == "app": | |||
| 106 | + | rel = p | |||
| 107 | + | case host == constants.HomepagePath && p != "": | |||
| 108 | + | // dumb://homepage/<asset> | |||
| 109 | + | rel = p | |||
| 110 | + | case p != "": | |||
| 111 | + | rel = p | |||
| 112 | + | default: | |||
| 113 | + | rel = constants.IndexHTML | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // Special-case homepage favicon: map .ico request to embedded SVG file | |||
| 118 | + | if (u.Host == constants.HomepagePath || u.Opaque == constants.HomepagePath) && strings.EqualFold(rel, "favicon.ico") { | |||
| 119 | + | log.Printf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.svg", rel, u.Host, u.Path) | |||
| 120 | + | data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", "favicon.svg"))) | |||
| 121 | + | if rerr == nil { | |||
| 122 | + | return "image/svg+xml", data, true | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path) | |||
| 127 | + | data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel))) | |||
| 128 | + | if rerr != nil { | |||
| 129 | + | log.Printf("[scheme] not found: %s", rel) | |||
| 130 | + | return "", nil, false | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Determine mime type | |||
| 134 | + | mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel))) | |||
| 135 | + | if mt == "" { | |||
| 136 | + | // Fallbacks | |||
| 137 | + | switch strings.ToLower(filepath.Ext(rel)) { | |||
| 138 | + | case ".js": | |||
| 139 | + | mt = "application/javascript" | |||
| 140 | + | case ".css": | |||
| 141 | + | mt = "text/css" | |||
| 142 | + | case ".svg": | |||
| 143 | + | mt = "image/svg+xml" | |||
| 144 | + | case ".ico": | |||
| 145 | + | mt = "image/x-icon" | |||
| 146 | + | default: | |||
| 147 | + | mt = "text/html; charset=utf-8" | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | return mt, data, true | |||
| 151 | + | } | |||
A internal/app/browser/browser.go +220 -0
| @@ -0,0 +1,220 @@ | |||
| 1 | + | package browser | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "database/sql" | |
| 5 | + | "embed" | |
| 6 | + | "log" | |
| 7 | + | "os" | |
| 8 | + | "os/signal" | |
| 9 | + | "runtime" | |
| 10 | + | "syscall" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/app/api" | |
| 13 | + | "github.com/bnema/dumber/internal/app/control" | |
| 14 | + | "github.com/bnema/dumber/internal/app/environment" | |
| 15 | + | "github.com/bnema/dumber/internal/app/messaging" | |
| 16 | + | "github.com/bnema/dumber/internal/config" | |
| 17 | + | "github.com/bnema/dumber/internal/db" | |
| 18 | + | "github.com/bnema/dumber/internal/logging" | |
| 19 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 20 | + | "github.com/bnema/dumber/services" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // BrowserApp represents the main browser application | |
| 24 | + | type BrowserApp struct { | |
| 25 | + | version string | |
| 26 | + | commit string | |
| 27 | + | buildDate string | |
| 28 | + | assets embed.FS | |
| 29 | + | ||
| 30 | + | // Core components | |
| 31 | + | config *config.Config | |
| 32 | + | database *sql.DB | |
| 33 | + | queries *db.Queries | |
| 34 | + | ||
| 35 | + | // Services | |
| 36 | + | parserService *services.ParserService | |
| 37 | + | browserService *services.BrowserService | |
| 38 | + | ||
| 39 | + | // WebView and controllers | |
| 40 | + | webView *webkit.WebView | |
| 41 | + | zoomController *control.ZoomController | |
| 42 | + | navigationController *control.NavigationController | |
| 43 | + | clipboardController *control.ClipboardController | |
| 44 | + | ||
| 45 | + | // Handlers | |
| 46 | + | schemeHandler *api.SchemeHandler | |
| 47 | + | messageHandler *messaging.Handler | |
| 48 | + | shortcutHandler *ShortcutHandler | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Run starts the browser application | |
| 52 | + | func Run(assets embed.FS, version, commit, buildDate string) { | |
| 53 | + | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) | |
| 54 | + | ||
| 55 | + | app := &BrowserApp{ | |
| 56 | + | version: version, | |
| 57 | + | commit: commit, | |
| 58 | + | buildDate: buildDate, | |
| 59 | + | assets: assets, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | if err := app.Initialize(); err != nil { | |
| 63 | + | log.Printf("Failed to initialize browser: %v", err) | |
| 64 | + | if webkit.IsNativeAvailable() { | |
| 65 | + | runtime.UnlockOSThread() | |
| 66 | + | } | |
| 67 | + | os.Exit(1) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | app.Run() | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Initialize sets up all browser components | |
| 74 | + | func (app *BrowserApp) Initialize() error { | |
| 75 | + | // Setup media pipeline hardening | |
| 76 | + | environment.SetupMediaPipeline() | |
| 77 | + | ||
| 78 | + | // Initialize configuration | |
| 79 | + | if err := config.Init(); err != nil { | |
| 80 | + | return err | |
| 81 | + | } | |
| 82 | + | app.config = config.Get() | |
| 83 | + | ||
| 84 | + | // Apply environment configurations | |
| 85 | + | environment.ApplyCodecConfiguration(app.config.CodecPreferences) | |
| 86 | + | ||
| 87 | + | // Initialize logging | |
| 88 | + | if err := logging.Init( | |
| 89 | + | app.config.Logging.LogDir, | |
| 90 | + | app.config.Logging.Level, | |
| 91 | + | app.config.Logging.Format, | |
| 92 | + | app.config.Logging.EnableFileLog, | |
| 93 | + | app.config.Logging.MaxSize, | |
| 94 | + | app.config.Logging.MaxBackups, | |
| 95 | + | app.config.Logging.MaxAge, | |
| 96 | + | app.config.Logging.Compress, | |
| 97 | + | ); err != nil { | |
| 98 | + | log.Printf("Warning: failed to initialize logging: %v", err) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Setup crash handler | |
| 102 | + | logging.SetupCrashHandler() | |
| 103 | + | ||
| 104 | + | // Setup output capture if configured | |
| 105 | + | if err := app.setupOutputCapture(); err != nil { | |
| 106 | + | log.Printf("Warning: failed to setup output capture: %v", err) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Initialize WebKit log capture if configured | |
| 110 | + | if app.config.Logging.CaptureCOutput { | |
| 111 | + | if err := webkit.InitWebKitLogCapture(); err != nil { | |
| 112 | + | log.Printf("Warning: failed to initialize WebKit log capture: %v", err) | |
| 113 | + | } else { | |
| 114 | + | defer webkit.StopWebKitLogCapture() | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | log.Printf("Config initialized") | |
| 119 | + | ||
| 120 | + | // Detect keyboard layout | |
| 121 | + | environment.DetectAndSetKeyboardLocale() | |
| 122 | + | ||
| 123 | + | // Initialize database | |
| 124 | + | database, err := db.InitDB(app.config.Database.Path) | |
| 125 | + | if err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | app.database = database | |
| 129 | + | app.queries = db.New(database) | |
| 130 | + | log.Printf("Database opened at %s", app.config.Database.Path) | |
| 131 | + | ||
| 132 | + | // Initialize services | |
| 133 | + | app.parserService = services.NewParserService(app.config, app.queries) | |
| 134 | + | app.browserService = services.NewBrowserService(app.config, app.queries) | |
| 135 | + | ||
| 136 | + | // Initialize handlers | |
| 137 | + | app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService) | |
| 138 | + | app.messageHandler = messaging.NewHandler(app.parserService, app.browserService) | |
| 139 | + | ||
| 140 | + | return nil | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // Run executes the main browser loop | |
| 144 | + | func (app *BrowserApp) Run() { | |
| 145 | + | defer app.cleanup() | |
| 146 | + | defer logging.SetupPanicRecovery() | |
| 147 | + | ||
| 148 | + | // Register custom scheme resolver | |
| 149 | + | webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) { | |
| 150 | + | return app.schemeHandler.Handle(uri, app.config) | |
| 151 | + | }) | |
| 152 | + | ||
| 153 | + | // Create and setup WebView | |
| 154 | + | if err := app.createWebView(); err != nil { | |
| 155 | + | log.Printf("Warning: failed to create WebView: %v", err) | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Handle browse command if present | |
| 160 | + | app.navigationController.HandleBrowseCommand() | |
| 161 | + | ||
| 162 | + | // Setup signal handling | |
| 163 | + | app.setupSignalHandling() | |
| 164 | + | ||
| 165 | + | // Run main loop | |
| 166 | + | app.runMainLoop() | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // cleanup handles cleanup on shutdown | |
| 170 | + | func (app *BrowserApp) cleanup() { | |
| 171 | + | if app.database != nil { | |
| 172 | + | if closeErr := app.database.Close(); closeErr != nil { | |
| 173 | + | log.Printf("Warning: failed to close database: %v", closeErr) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // setupOutputCapture initializes stdout/stderr capture if configured | |
| 179 | + | func (app *BrowserApp) setupOutputCapture() error { | |
| 180 | + | if app.config.Logging.CaptureStdout || app.config.Logging.CaptureStderr { | |
| 181 | + | log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations") | |
| 182 | + | if logger := logging.GetLogger(); logger != nil { | |
| 183 | + | outputCapture := logging.NewOutputCapture(logger) | |
| 184 | + | if err := outputCapture.Start(); err != nil { | |
| 185 | + | return err | |
| 186 | + | } | |
| 187 | + | // Note: defer outputCapture.Stop() should be handled by the caller | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | return nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // setupSignalHandling configures graceful shutdown on signals | |
| 194 | + | func (app *BrowserApp) setupSignalHandling() { | |
| 195 | + | sigChan := make(chan os.Signal, 1) | |
| 196 | + | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) | |
| 197 | + | ||
| 198 | + | // Handle signals in a goroutine to quit the main loop | |
| 199 | + | go func() { | |
| 200 | + | sig := <-sigChan | |
| 201 | + | log.Printf("Received signal %v - shutting down gracefully", sig) | |
| 202 | + | webkit.QuitMainLoop() | |
| 203 | + | }() | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // runMainLoop starts the appropriate main loop based on WebKit availability | |
| 207 | + | func (app *BrowserApp) runMainLoop() { | |
| 208 | + | if webkit.IsNativeAvailable() { | |
| 209 | + | log.Printf("Entering GTK main loop…") | |
| 210 | + | webkit.RunMainLoop() | |
| 211 | + | log.Printf("GTK main loop exited") | |
| 212 | + | } else { | |
| 213 | + | log.Printf("Not entering GUI loop (non-CGO build)") | |
| 214 | + | // In non-CGO mode, just wait for signals | |
| 215 | + | sigChan := make(chan os.Signal, 1) | |
| 216 | + | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) | |
| 217 | + | sig := <-sigChan | |
| 218 | + | log.Printf("Received signal %v - exiting", sig) | |
| 219 | + | } | |
| 220 | + | } | |
| @@ -0,0 +1,220 @@ | |||||
| 1 | + | package browser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "database/sql" | |||
| 5 | + | "embed" | |||
| 6 | + | "log" | |||
| 7 | + | "os" | |||
| 8 | + | "os/signal" | |||
| 9 | + | "runtime" | |||
| 10 | + | "syscall" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/app/api" | |||
| 13 | + | "github.com/bnema/dumber/internal/app/control" | |||
| 14 | + | "github.com/bnema/dumber/internal/app/environment" | |||
| 15 | + | "github.com/bnema/dumber/internal/app/messaging" | |||
| 16 | + | "github.com/bnema/dumber/internal/config" | |||
| 17 | + | "github.com/bnema/dumber/internal/db" | |||
| 18 | + | "github.com/bnema/dumber/internal/logging" | |||
| 19 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 20 | + | "github.com/bnema/dumber/services" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // BrowserApp represents the main browser application | |||
| 24 | + | type BrowserApp struct { | |||
| 25 | + | version string | |||
| 26 | + | commit string | |||
| 27 | + | buildDate string | |||
| 28 | + | assets embed.FS | |||
| 29 | + | ||||
| 30 | + | // Core components | |||
| 31 | + | config *config.Config | |||
| 32 | + | database *sql.DB | |||
| 33 | + | queries *db.Queries | |||
| 34 | + | ||||
| 35 | + | // Services | |||
| 36 | + | parserService *services.ParserService | |||
| 37 | + | browserService *services.BrowserService | |||
| 38 | + | ||||
| 39 | + | // WebView and controllers | |||
| 40 | + | webView *webkit.WebView | |||
| 41 | + | zoomController *control.ZoomController | |||
| 42 | + | navigationController *control.NavigationController | |||
| 43 | + | clipboardController *control.ClipboardController | |||
| 44 | + | ||||
| 45 | + | // Handlers | |||
| 46 | + | schemeHandler *api.SchemeHandler | |||
| 47 | + | messageHandler *messaging.Handler | |||
| 48 | + | shortcutHandler *ShortcutHandler | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Run starts the browser application | |||
| 52 | + | func Run(assets embed.FS, version, commit, buildDate string) { | |||
| 53 | + | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) | |||
| 54 | + | ||||
| 55 | + | app := &BrowserApp{ | |||
| 56 | + | version: version, | |||
| 57 | + | commit: commit, | |||
| 58 | + | buildDate: buildDate, | |||
| 59 | + | assets: assets, | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | if err := app.Initialize(); err != nil { | |||
| 63 | + | log.Printf("Failed to initialize browser: %v", err) | |||
| 64 | + | if webkit.IsNativeAvailable() { | |||
| 65 | + | runtime.UnlockOSThread() | |||
| 66 | + | } | |||
| 67 | + | os.Exit(1) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | app.Run() | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Initialize sets up all browser components | |||
| 74 | + | func (app *BrowserApp) Initialize() error { | |||
| 75 | + | // Setup media pipeline hardening | |||
| 76 | + | environment.SetupMediaPipeline() | |||
| 77 | + | ||||
| 78 | + | // Initialize configuration | |||
| 79 | + | if err := config.Init(); err != nil { | |||
| 80 | + | return err | |||
| 81 | + | } | |||
| 82 | + | app.config = config.Get() | |||
| 83 | + | ||||
| 84 | + | // Apply environment configurations | |||
| 85 | + | environment.ApplyCodecConfiguration(app.config.CodecPreferences) | |||
| 86 | + | ||||
| 87 | + | // Initialize logging | |||
| 88 | + | if err := logging.Init( | |||
| 89 | + | app.config.Logging.LogDir, | |||
| 90 | + | app.config.Logging.Level, | |||
| 91 | + | app.config.Logging.Format, | |||
| 92 | + | app.config.Logging.EnableFileLog, | |||
| 93 | + | app.config.Logging.MaxSize, | |||
| 94 | + | app.config.Logging.MaxBackups, | |||
| 95 | + | app.config.Logging.MaxAge, | |||
| 96 | + | app.config.Logging.Compress, | |||
| 97 | + | ); err != nil { | |||
| 98 | + | log.Printf("Warning: failed to initialize logging: %v", err) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // Setup crash handler | |||
| 102 | + | logging.SetupCrashHandler() | |||
| 103 | + | ||||
| 104 | + | // Setup output capture if configured | |||
| 105 | + | if err := app.setupOutputCapture(); err != nil { | |||
| 106 | + | log.Printf("Warning: failed to setup output capture: %v", err) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Initialize WebKit log capture if configured | |||
| 110 | + | if app.config.Logging.CaptureCOutput { | |||
| 111 | + | if err := webkit.InitWebKitLogCapture(); err != nil { | |||
| 112 | + | log.Printf("Warning: failed to initialize WebKit log capture: %v", err) | |||
| 113 | + | } else { | |||
| 114 | + | defer webkit.StopWebKitLogCapture() | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | log.Printf("Config initialized") | |||
| 119 | + | ||||
| 120 | + | // Detect keyboard layout | |||
| 121 | + | environment.DetectAndSetKeyboardLocale() | |||
| 122 | + | ||||
| 123 | + | // Initialize database | |||
| 124 | + | database, err := db.InitDB(app.config.Database.Path) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return err | |||
| 127 | + | } | |||
| 128 | + | app.database = database | |||
| 129 | + | app.queries = db.New(database) | |||
| 130 | + | log.Printf("Database opened at %s", app.config.Database.Path) | |||
| 131 | + | ||||
| 132 | + | // Initialize services | |||
| 133 | + | app.parserService = services.NewParserService(app.config, app.queries) | |||
| 134 | + | app.browserService = services.NewBrowserService(app.config, app.queries) | |||
| 135 | + | ||||
| 136 | + | // Initialize handlers | |||
| 137 | + | app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService) | |||
| 138 | + | app.messageHandler = messaging.NewHandler(app.parserService, app.browserService) | |||
| 139 | + | ||||
| 140 | + | return nil | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // Run executes the main browser loop | |||
| 144 | + | func (app *BrowserApp) Run() { | |||
| 145 | + | defer app.cleanup() | |||
| 146 | + | defer logging.SetupPanicRecovery() | |||
| 147 | + | ||||
| 148 | + | // Register custom scheme resolver | |||
| 149 | + | webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) { | |||
| 150 | + | return app.schemeHandler.Handle(uri, app.config) | |||
| 151 | + | }) | |||
| 152 | + | ||||
| 153 | + | // Create and setup WebView | |||
| 154 | + | if err := app.createWebView(); err != nil { | |||
| 155 | + | log.Printf("Warning: failed to create WebView: %v", err) | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Handle browse command if present | |||
| 160 | + | app.navigationController.HandleBrowseCommand() | |||
| 161 | + | ||||
| 162 | + | // Setup signal handling | |||
| 163 | + | app.setupSignalHandling() | |||
| 164 | + | ||||
| 165 | + | // Run main loop | |||
| 166 | + | app.runMainLoop() | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // cleanup handles cleanup on shutdown | |||
| 170 | + | func (app *BrowserApp) cleanup() { | |||
| 171 | + | if app.database != nil { | |||
| 172 | + | if closeErr := app.database.Close(); closeErr != nil { | |||
| 173 | + | log.Printf("Warning: failed to close database: %v", closeErr) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // setupOutputCapture initializes stdout/stderr capture if configured | |||
| 179 | + | func (app *BrowserApp) setupOutputCapture() error { | |||
| 180 | + | if app.config.Logging.CaptureStdout || app.config.Logging.CaptureStderr { | |||
| 181 | + | log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations") | |||
| 182 | + | if logger := logging.GetLogger(); logger != nil { | |||
| 183 | + | outputCapture := logging.NewOutputCapture(logger) | |||
| 184 | + | if err := outputCapture.Start(); err != nil { | |||
| 185 | + | return err | |||
| 186 | + | } | |||
| 187 | + | // Note: defer outputCapture.Stop() should be handled by the caller | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | return nil | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // setupSignalHandling configures graceful shutdown on signals | |||
| 194 | + | func (app *BrowserApp) setupSignalHandling() { | |||
| 195 | + | sigChan := make(chan os.Signal, 1) | |||
| 196 | + | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) | |||
| 197 | + | ||||
| 198 | + | // Handle signals in a goroutine to quit the main loop | |||
| 199 | + | go func() { | |||
| 200 | + | sig := <-sigChan | |||
| 201 | + | log.Printf("Received signal %v - shutting down gracefully", sig) | |||
| 202 | + | webkit.QuitMainLoop() | |||
| 203 | + | }() | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // runMainLoop starts the appropriate main loop based on WebKit availability | |||
| 207 | + | func (app *BrowserApp) runMainLoop() { | |||
| 208 | + | if webkit.IsNativeAvailable() { | |||
| 209 | + | log.Printf("Entering GTK main loop…") | |||
| 210 | + | webkit.RunMainLoop() | |||
| 211 | + | log.Printf("GTK main loop exited") | |||
| 212 | + | } else { | |||
| 213 | + | log.Printf("Not entering GUI loop (non-CGO build)") | |||
| 214 | + | // In non-CGO mode, just wait for signals | |||
| 215 | + | sigChan := make(chan os.Signal, 1) | |||
| 216 | + | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) | |||
| 217 | + | sig := <-sigChan | |||
| 218 | + | log.Printf("Received signal %v - exiting", sig) | |||
| 219 | + | } | |||
| 220 | + | } | |||
A internal/app/browser/shortcuts.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package browser | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "log" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/app/control" | |
| 7 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ShortcutHandler manages keyboard shortcuts for the browser | |
| 11 | + | type ShortcutHandler struct { | |
| 12 | + | webView *webkit.WebView | |
| 13 | + | clipboardController *control.ClipboardController | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // NewShortcutHandler creates a new shortcut handler | |
| 17 | + | func NewShortcutHandler(webView *webkit.WebView, clipboardController *control.ClipboardController) *ShortcutHandler { | |
| 18 | + | return &ShortcutHandler{ | |
| 19 | + | webView: webView, | |
| 20 | + | clipboardController: clipboardController, | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // RegisterShortcuts registers all keyboard shortcuts | |
| 25 | + | func (s *ShortcutHandler) RegisterShortcuts() { | |
| 26 | + | // DevTools | |
| 27 | + | _ = s.webView.RegisterKeyboardShortcut("F12", func() { | |
| 28 | + | log.Printf("Shortcut: F12 (devtools)") | |
| 29 | + | _ = s.webView.ShowDevTools() | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | // Omnibox (Ctrl+L): rely on injected script listener | |
| 33 | + | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+l", func() { | |
| 34 | + | log.Printf("Shortcut: Omnibox toggle") | |
| 35 | + | _ = s.webView.InjectScript("window.__dumber_toggle && window.__dumber_toggle()") | |
| 36 | + | }) | |
| 37 | + | ||
| 38 | + | // Copy URL (Ctrl+Shift+C) | |
| 39 | + | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() { | |
| 40 | + | s.clipboardController.CopyCurrentURL() | |
| 41 | + | }) | |
| 42 | + | ||
| 43 | + | // Page refresh shortcuts | |
| 44 | + | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", func() { | |
| 45 | + | log.Printf("Shortcut: Reload page") | |
| 46 | + | _ = s.webView.Reload() | |
| 47 | + | }) | |
| 48 | + | ||
| 49 | + | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", func() { | |
| 50 | + | log.Printf("Shortcut: Hard reload page") | |
| 51 | + | _ = s.webView.ReloadBypassCache() | |
| 52 | + | }) | |
| 53 | + | ||
| 54 | + | _ = s.webView.RegisterKeyboardShortcut("F5", func() { | |
| 55 | + | log.Printf("Shortcut: F5 reload") | |
| 56 | + | _ = s.webView.Reload() | |
| 57 | + | }) | |
| 58 | + | ||
| 59 | + | // Zoom handled natively in webkit package (built-in shortcuts) | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package browser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "log" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/app/control" | |||
| 7 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // ShortcutHandler manages keyboard shortcuts for the browser | |||
| 11 | + | type ShortcutHandler struct { | |||
| 12 | + | webView *webkit.WebView | |||
| 13 | + | clipboardController *control.ClipboardController | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // NewShortcutHandler creates a new shortcut handler | |||
| 17 | + | func NewShortcutHandler(webView *webkit.WebView, clipboardController *control.ClipboardController) *ShortcutHandler { | |||
| 18 | + | return &ShortcutHandler{ | |||
| 19 | + | webView: webView, | |||
| 20 | + | clipboardController: clipboardController, | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // RegisterShortcuts registers all keyboard shortcuts | |||
| 25 | + | func (s *ShortcutHandler) RegisterShortcuts() { | |||
| 26 | + | // DevTools | |||
| 27 | + | _ = s.webView.RegisterKeyboardShortcut("F12", func() { | |||
| 28 | + | log.Printf("Shortcut: F12 (devtools)") | |||
| 29 | + | _ = s.webView.ShowDevTools() | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | // Omnibox (Ctrl+L): rely on injected script listener | |||
| 33 | + | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+l", func() { | |||
| 34 | + | log.Printf("Shortcut: Omnibox toggle") | |||
| 35 | + | _ = s.webView.InjectScript("window.__dumber_toggle && window.__dumber_toggle()") | |||
| 36 | + | }) | |||
| 37 | + | ||||
| 38 | + | // Copy URL (Ctrl+Shift+C) | |||
| 39 | + | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() { | |||
| 40 | + | s.clipboardController.CopyCurrentURL() | |||
| 41 | + | }) | |||
| 42 | + | ||||
| 43 | + | // Page refresh shortcuts | |||
| 44 | + | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", func() { | |||
| 45 | + | log.Printf("Shortcut: Reload page") | |||
| 46 | + | _ = s.webView.Reload() | |||
| 47 | + | }) | |||
| 48 | + | ||||
| 49 | + | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", func() { | |||
| 50 | + | log.Printf("Shortcut: Hard reload page") | |||
| 51 | + | _ = s.webView.ReloadBypassCache() | |||
| 52 | + | }) | |||
| 53 | + | ||||
| 54 | + | _ = s.webView.RegisterKeyboardShortcut("F5", func() { | |||
| 55 | + | log.Printf("Shortcut: F5 reload") | |||
| 56 | + | _ = s.webView.Reload() | |||
| 57 | + | }) | |||
| 58 | + | ||||
| 59 | + | // Zoom handled natively in webkit package (built-in shortcuts) | |||
| 60 | + | } | |||
A internal/app/browser/webview.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | package browser | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "log" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/app/constants" | |
| 11 | + | "github.com/bnema/dumber/internal/app/control" | |
| 12 | + | "github.com/bnema/dumber/internal/app/environment" | |
| 13 | + | "github.com/bnema/dumber/internal/config" | |
| 14 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // buildMemoryConfig converts config.WebkitMemoryConfig to webkit.MemoryConfig | |
| 18 | + | func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig { | |
| 19 | + | mc := webkit.MemoryConfig{ | |
| 20 | + | MemoryLimitMB: cfg.MemoryLimitMB, | |
| 21 | + | ConservativeThreshold: cfg.ConservativeThreshold, | |
| 22 | + | StrictThreshold: cfg.StrictThreshold, | |
| 23 | + | KillThreshold: cfg.KillThreshold, | |
| 24 | + | PollIntervalSeconds: cfg.PollIntervalSeconds, | |
| 25 | + | EnableGCInterval: cfg.EnableGCInterval, | |
| 26 | + | ProcessRecycleThreshold: cfg.ProcessRecycleThreshold, | |
| 27 | + | EnablePageCache: cfg.EnablePageCache, | |
| 28 | + | EnableMemoryMonitoring: cfg.EnableMemoryMonitoring, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Convert string cache model to webkit constant | |
| 32 | + | switch strings.ToLower(cfg.CacheModel) { | |
| 33 | + | case "document_viewer", "documentviewer", "doc": | |
| 34 | + | mc.CacheModel = webkit.CacheModelDocumentViewer | |
| 35 | + | case "primary_web_browser", "primary", "primarywebbrowser": | |
| 36 | + | mc.CacheModel = webkit.CacheModelPrimaryWebBrowser | |
| 37 | + | default: | |
| 38 | + | mc.CacheModel = webkit.CacheModelWebBrowser | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | return mc | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // createWebView creates and configures the WebView | |
| 45 | + | func (app *BrowserApp) createWebView() error { | |
| 46 | + | log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable()) | |
| 47 | + | dataDir, _ := config.GetDataDir() | |
| 48 | + | stateDir, _ := config.GetStateDir() | |
| 49 | + | webkitData := filepath.Join(dataDir, "webkit") | |
| 50 | + | webkitCache := filepath.Join(stateDir, "webkit-cache") | |
| 51 | + | _ = os.MkdirAll(webkitData, constants.DirPerm) | |
| 52 | + | _ = os.MkdirAll(webkitCache, constants.DirPerm) | |
| 53 | + | ||
| 54 | + | view, err := webkit.NewWebView(&webkit.Config{ | |
| 55 | + | InitialURL: "dumb://homepage", | |
| 56 | + | ZoomDefault: 1.0, | |
| 57 | + | EnableDeveloperExtras: true, | |
| 58 | + | DataDir: webkitData, | |
| 59 | + | CacheDir: webkitCache, | |
| 60 | + | DefaultSansFont: app.config.Appearance.SansFont, | |
| 61 | + | DefaultSerifFont: app.config.Appearance.SerifFont, | |
| 62 | + | DefaultMonospaceFont: app.config.Appearance.MonospaceFont, | |
| 63 | + | DefaultFontSize: app.config.Appearance.DefaultFontSize, | |
| 64 | + | Rendering: webkit.RenderingConfig{Mode: string(app.config.RenderingMode)}, | |
| 65 | + | VideoAcceleration: webkit.VideoAccelerationConfig{ | |
| 66 | + | EnableVAAPI: app.config.VideoAcceleration.EnableVAAPI, | |
| 67 | + | AutoDetectGPU: app.config.VideoAcceleration.AutoDetectGPU, | |
| 68 | + | VAAPIDriverName: app.config.VideoAcceleration.VAAPIDriverName, | |
| 69 | + | EnableAllDrivers: app.config.VideoAcceleration.EnableAllDrivers, | |
| 70 | + | LegacyVAAPI: app.config.VideoAcceleration.LegacyVAAPI, | |
| 71 | + | }, | |
| 72 | + | Memory: buildMemoryConfig(app.config.WebkitMemory), | |
| 73 | + | CodecPreferences: webkit.CodecPreferencesConfig{ | |
| 74 | + | PreferredCodecs: strings.Split(app.config.CodecPreferences.PreferredCodecs, ","), | |
| 75 | + | BlockedCodecs: environment.BuildBlockedCodecsList(app.config.CodecPreferences), | |
| 76 | + | ForceAV1: app.config.CodecPreferences.ForceAV1, | |
| 77 | + | CustomUserAgent: app.config.CodecPreferences.CustomUserAgent, | |
| 78 | + | DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl, | |
| 79 | + | }, | |
| 80 | + | }) | |
| 81 | + | ||
| 82 | + | if err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | app.webView = view | |
| 87 | + | app.setupWebViewIntegration() | |
| 88 | + | app.setupWebViewHandlers() | |
| 89 | + | app.setupControllers() | |
| 90 | + | app.setupKeyboardShortcuts() | |
| 91 | + | ||
| 92 | + | // Apply initial zoom and show window | |
| 93 | + | app.zoomController.ApplyInitialZoom() | |
| 94 | + | ||
| 95 | + | log.Printf("Showing WebView window…") | |
| 96 | + | if err := view.Show(); err != nil { | |
| 97 | + | log.Printf("Warning: failed to show WebView: %v", err) | |
| 98 | + | } else if !webkit.IsNativeAvailable() { | |
| 99 | + | log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // setupWebViewIntegration connects the WebView to browser services | |
| 106 | + | func (app *BrowserApp) setupWebViewIntegration() { | |
| 107 | + | app.browserService.AttachWebView(app.webView) | |
| 108 | + | // Use native window as title updater | |
| 109 | + | if win := app.webView.Window(); win != nil { | |
| 110 | + | app.browserService.SetWindowTitleUpdater(win) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // setupWebViewHandlers configures WebView event handlers | |
| 115 | + | func (app *BrowserApp) setupWebViewHandlers() { | |
| 116 | + | // Persist page titles to DB when they change | |
| 117 | + | app.webView.RegisterTitleChangedHandler(func(title string) { | |
| 118 | + | ctx := context.Background() | |
| 119 | + | url := app.webView.GetCurrentURL() | |
| 120 | + | if url != "" && title != "" { | |
| 121 | + | if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil { | |
| 122 | + | log.Printf("Warning: failed to update page title: %v", err) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | }) | |
| 126 | + | ||
| 127 | + | // Set WebView reference for message handler and register script messages | |
| 128 | + | app.messageHandler.SetWebView(app.webView) | |
| 129 | + | app.webView.RegisterScriptMessageHandler(app.messageHandler.Handle) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // setupControllers initializes and configures controller objects | |
| 133 | + | func (app *BrowserApp) setupControllers() { | |
| 134 | + | // Initialize controllers | |
| 135 | + | app.zoomController = control.NewZoomController(app.browserService, app.webView) | |
| 136 | + | app.navigationController = control.NewNavigationController( | |
| 137 | + | app.parserService, | |
| 138 | + | app.browserService, | |
| 139 | + | app.webView, | |
| 140 | + | app.zoomController, | |
| 141 | + | ) | |
| 142 | + | app.clipboardController = control.NewClipboardController(app.webView) | |
| 143 | + | ||
| 144 | + | // Register controller handlers | |
| 145 | + | app.zoomController.RegisterHandlers() | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // setupKeyboardShortcuts registers keyboard shortcuts | |
| 149 | + | func (app *BrowserApp) setupKeyboardShortcuts() { | |
| 150 | + | app.shortcutHandler = NewShortcutHandler(app.webView, app.clipboardController) | |
| 151 | + | app.shortcutHandler.RegisterShortcuts() | |
| 152 | + | } | |
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | package browser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "log" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/app/constants" | |||
| 11 | + | "github.com/bnema/dumber/internal/app/control" | |||
| 12 | + | "github.com/bnema/dumber/internal/app/environment" | |||
| 13 | + | "github.com/bnema/dumber/internal/config" | |||
| 14 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // buildMemoryConfig converts config.WebkitMemoryConfig to webkit.MemoryConfig | |||
| 18 | + | func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig { | |||
| 19 | + | mc := webkit.MemoryConfig{ | |||
| 20 | + | MemoryLimitMB: cfg.MemoryLimitMB, | |||
| 21 | + | ConservativeThreshold: cfg.ConservativeThreshold, | |||
| 22 | + | StrictThreshold: cfg.StrictThreshold, | |||
| 23 | + | KillThreshold: cfg.KillThreshold, | |||
| 24 | + | PollIntervalSeconds: cfg.PollIntervalSeconds, | |||
| 25 | + | EnableGCInterval: cfg.EnableGCInterval, | |||
| 26 | + | ProcessRecycleThreshold: cfg.ProcessRecycleThreshold, | |||
| 27 | + | EnablePageCache: cfg.EnablePageCache, | |||
| 28 | + | EnableMemoryMonitoring: cfg.EnableMemoryMonitoring, | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // Convert string cache model to webkit constant | |||
| 32 | + | switch strings.ToLower(cfg.CacheModel) { | |||
| 33 | + | case "document_viewer", "documentviewer", "doc": | |||
| 34 | + | mc.CacheModel = webkit.CacheModelDocumentViewer | |||
| 35 | + | case "primary_web_browser", "primary", "primarywebbrowser": | |||
| 36 | + | mc.CacheModel = webkit.CacheModelPrimaryWebBrowser | |||
| 37 | + | default: | |||
| 38 | + | mc.CacheModel = webkit.CacheModelWebBrowser | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | return mc | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // createWebView creates and configures the WebView | |||
| 45 | + | func (app *BrowserApp) createWebView() error { | |||
| 46 | + | log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable()) | |||
| 47 | + | dataDir, _ := config.GetDataDir() | |||
| 48 | + | stateDir, _ := config.GetStateDir() | |||
| 49 | + | webkitData := filepath.Join(dataDir, "webkit") | |||
| 50 | + | webkitCache := filepath.Join(stateDir, "webkit-cache") | |||
| 51 | + | _ = os.MkdirAll(webkitData, constants.DirPerm) | |||
| 52 | + | _ = os.MkdirAll(webkitCache, constants.DirPerm) | |||
| 53 | + | ||||
| 54 | + | view, err := webkit.NewWebView(&webkit.Config{ | |||
| 55 | + | InitialURL: "dumb://homepage", | |||
| 56 | + | ZoomDefault: 1.0, | |||
| 57 | + | EnableDeveloperExtras: true, | |||
| 58 | + | DataDir: webkitData, | |||
| 59 | + | CacheDir: webkitCache, | |||
| 60 | + | DefaultSansFont: app.config.Appearance.SansFont, | |||
| 61 | + | DefaultSerifFont: app.config.Appearance.SerifFont, | |||
| 62 | + | DefaultMonospaceFont: app.config.Appearance.MonospaceFont, | |||
| 63 | + | DefaultFontSize: app.config.Appearance.DefaultFontSize, | |||
| 64 | + | Rendering: webkit.RenderingConfig{Mode: string(app.config.RenderingMode)}, | |||
| 65 | + | VideoAcceleration: webkit.VideoAccelerationConfig{ | |||
| 66 | + | EnableVAAPI: app.config.VideoAcceleration.EnableVAAPI, | |||
| 67 | + | AutoDetectGPU: app.config.VideoAcceleration.AutoDetectGPU, | |||
| 68 | + | VAAPIDriverName: app.config.VideoAcceleration.VAAPIDriverName, | |||
| 69 | + | EnableAllDrivers: app.config.VideoAcceleration.EnableAllDrivers, | |||
| 70 | + | LegacyVAAPI: app.config.VideoAcceleration.LegacyVAAPI, | |||
| 71 | + | }, | |||
| 72 | + | Memory: buildMemoryConfig(app.config.WebkitMemory), | |||
| 73 | + | CodecPreferences: webkit.CodecPreferencesConfig{ | |||
| 74 | + | PreferredCodecs: strings.Split(app.config.CodecPreferences.PreferredCodecs, ","), | |||
| 75 | + | BlockedCodecs: environment.BuildBlockedCodecsList(app.config.CodecPreferences), | |||
| 76 | + | ForceAV1: app.config.CodecPreferences.ForceAV1, | |||
| 77 | + | CustomUserAgent: app.config.CodecPreferences.CustomUserAgent, | |||
| 78 | + | DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl, | |||
| 79 | + | }, | |||
| 80 | + | }) | |||
| 81 | + | ||||
| 82 | + | if err != nil { | |||
| 83 | + | return err | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | app.webView = view | |||
| 87 | + | app.setupWebViewIntegration() | |||
| 88 | + | app.setupWebViewHandlers() | |||
| 89 | + | app.setupControllers() | |||
| 90 | + | app.setupKeyboardShortcuts() | |||
| 91 | + | ||||
| 92 | + | // Apply initial zoom and show window | |||
| 93 | + | app.zoomController.ApplyInitialZoom() | |||
| 94 | + | ||||
| 95 | + | log.Printf("Showing WebView window…") | |||
| 96 | + | if err := view.Show(); err != nil { | |||
| 97 | + | log.Printf("Warning: failed to show WebView: %v", err) | |||
| 98 | + | } else if !webkit.IsNativeAvailable() { | |||
| 99 | + | log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.") | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // setupWebViewIntegration connects the WebView to browser services | |||
| 106 | + | func (app *BrowserApp) setupWebViewIntegration() { | |||
| 107 | + | app.browserService.AttachWebView(app.webView) | |||
| 108 | + | // Use native window as title updater | |||
| 109 | + | if win := app.webView.Window(); win != nil { | |||
| 110 | + | app.browserService.SetWindowTitleUpdater(win) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // setupWebViewHandlers configures WebView event handlers | |||
| 115 | + | func (app *BrowserApp) setupWebViewHandlers() { | |||
| 116 | + | // Persist page titles to DB when they change | |||
| 117 | + | app.webView.RegisterTitleChangedHandler(func(title string) { | |||
| 118 | + | ctx := context.Background() | |||
| 119 | + | url := app.webView.GetCurrentURL() | |||
| 120 | + | if url != "" && title != "" { | |||
| 121 | + | if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil { | |||
| 122 | + | log.Printf("Warning: failed to update page title: %v", err) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | }) | |||
| 126 | + | ||||
| 127 | + | // Set WebView reference for message handler and register script messages | |||
| 128 | + | app.messageHandler.SetWebView(app.webView) | |||
| 129 | + | app.webView.RegisterScriptMessageHandler(app.messageHandler.Handle) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // setupControllers initializes and configures controller objects | |||
| 133 | + | func (app *BrowserApp) setupControllers() { | |||
| 134 | + | // Initialize controllers | |||
| 135 | + | app.zoomController = control.NewZoomController(app.browserService, app.webView) | |||
| 136 | + | app.navigationController = control.NewNavigationController( | |||
| 137 | + | app.parserService, | |||
| 138 | + | app.browserService, | |||
| 139 | + | app.webView, | |||
| 140 | + | app.zoomController, | |||
| 141 | + | ) | |||
| 142 | + | app.clipboardController = control.NewClipboardController(app.webView) | |||
| 143 | + | ||||
| 144 | + | // Register controller handlers | |||
| 145 | + | app.zoomController.RegisterHandlers() | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // setupKeyboardShortcuts registers keyboard shortcuts | |||
| 149 | + | func (app *BrowserApp) setupKeyboardShortcuts() { | |||
| 150 | + | app.shortcutHandler = NewShortcutHandler(app.webView, app.clipboardController) | |||
| 151 | + | app.shortcutHandler.RegisterShortcuts() | |||
| 152 | + | } | |||
A internal/app/cli/runner.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/cli" | |
| 8 | + | "github.com/bnema/dumber/internal/config" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Execute runs the CLI functionality using the existing CLI package | |
| 13 | + | func Execute(version, commit, buildDate string) { | |
| 14 | + | // Initialize configuration system | |
| 15 | + | if err := config.Init(); err != nil { | |
| 16 | + | fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err) | |
| 17 | + | os.Exit(1) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Initialize logging system for CLI mode (file logging only, no output capture) | |
| 21 | + | cfg := config.Get() | |
| 22 | + | if err := logging.Init( | |
| 23 | + | cfg.Logging.LogDir, | |
| 24 | + | cfg.Logging.Level, | |
| 25 | + | cfg.Logging.Format, | |
| 26 | + | cfg.Logging.EnableFileLog, | |
| 27 | + | cfg.Logging.MaxSize, | |
| 28 | + | cfg.Logging.MaxBackups, | |
| 29 | + | cfg.Logging.MaxAge, | |
| 30 | + | cfg.Logging.Compress, | |
| 31 | + | ); err != nil { | |
| 32 | + | fmt.Fprintf(os.Stderr, "Warning: failed to initialize logging: %v\n", err) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Start configuration watching for live reload | |
| 36 | + | if err := config.Watch(); err != nil { | |
| 37 | + | // Don't exit on watch error, just warn | |
| 38 | + | fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | rootCmd := cli.NewRootCmd(version, commit, buildDate) | |
| 42 | + | ||
| 43 | + | // Handle dmenu flag at the root level for direct integration | |
| 44 | + | if len(os.Args) > 1 && os.Args[1] == "--dmenu" { | |
| 45 | + | handleDmenuMode() | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Execute the root command | |
| 50 | + | if err := rootCmd.Execute(); err != nil { | |
| 51 | + | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | |
| 52 | + | os.Exit(1) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // handleDmenuMode processes dmenu-specific functionality | |
| 57 | + | func handleDmenuMode() { | |
| 58 | + | // Create a temporary CLI instance to handle dmenu mode | |
| 59 | + | cliInstance, err := cli.NewCLI() | |
| 60 | + | if err != nil { | |
| 61 | + | fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err) | |
| 62 | + | os.Exit(1) | |
| 63 | + | } | |
| 64 | + | defer func() { | |
| 65 | + | if closeErr := cliInstance.Close(); closeErr != nil { | |
| 66 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |
| 67 | + | } | |
| 68 | + | }() | |
| 69 | + | ||
| 70 | + | // Generate dmenu options | |
| 71 | + | dmenuCmd := cli.NewDmenuCmd() | |
| 72 | + | if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil { | |
| 73 | + | fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err) | |
| 74 | + | if closeErr := cliInstance.Close(); closeErr != nil { | |
| 75 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |
| 76 | + | } | |
| 77 | + | os.Exit(1) | |
| 78 | + | } | |
| 79 | + | } | |
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/cli" | |||
| 8 | + | "github.com/bnema/dumber/internal/config" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Execute runs the CLI functionality using the existing CLI package | |||
| 13 | + | func Execute(version, commit, buildDate string) { | |||
| 14 | + | // Initialize configuration system | |||
| 15 | + | if err := config.Init(); err != nil { | |||
| 16 | + | fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err) | |||
| 17 | + | os.Exit(1) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Initialize logging system for CLI mode (file logging only, no output capture) | |||
| 21 | + | cfg := config.Get() | |||
| 22 | + | if err := logging.Init( | |||
| 23 | + | cfg.Logging.LogDir, | |||
| 24 | + | cfg.Logging.Level, | |||
| 25 | + | cfg.Logging.Format, | |||
| 26 | + | cfg.Logging.EnableFileLog, | |||
| 27 | + | cfg.Logging.MaxSize, | |||
| 28 | + | cfg.Logging.MaxBackups, | |||
| 29 | + | cfg.Logging.MaxAge, | |||
| 30 | + | cfg.Logging.Compress, | |||
| 31 | + | ); err != nil { | |||
| 32 | + | fmt.Fprintf(os.Stderr, "Warning: failed to initialize logging: %v\n", err) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Start configuration watching for live reload | |||
| 36 | + | if err := config.Watch(); err != nil { | |||
| 37 | + | // Don't exit on watch error, just warn | |||
| 38 | + | fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | rootCmd := cli.NewRootCmd(version, commit, buildDate) | |||
| 42 | + | ||||
| 43 | + | // Handle dmenu flag at the root level for direct integration | |||
| 44 | + | if len(os.Args) > 1 && os.Args[1] == "--dmenu" { | |||
| 45 | + | handleDmenuMode() | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Execute the root command | |||
| 50 | + | if err := rootCmd.Execute(); err != nil { | |||
| 51 | + | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | |||
| 52 | + | os.Exit(1) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // handleDmenuMode processes dmenu-specific functionality | |||
| 57 | + | func handleDmenuMode() { | |||
| 58 | + | // Create a temporary CLI instance to handle dmenu mode | |||
| 59 | + | cliInstance, err := cli.NewCLI() | |||
| 60 | + | if err != nil { | |||
| 61 | + | fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err) | |||
| 62 | + | os.Exit(1) | |||
| 63 | + | } | |||
| 64 | + | defer func() { | |||
| 65 | + | if closeErr := cliInstance.Close(); closeErr != nil { | |||
| 66 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 67 | + | } | |||
| 68 | + | }() | |||
| 69 | + | ||||
| 70 | + | // Generate dmenu options | |||
| 71 | + | dmenuCmd := cli.NewDmenuCmd() | |||
| 72 | + | if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil { | |||
| 73 | + | fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err) | |||
| 74 | + | if closeErr := cliInstance.Close(); closeErr != nil { | |||
| 75 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 76 | + | } | |||
| 77 | + | os.Exit(1) | |||
| 78 | + | } | |||
| 79 | + | } | |||
A internal/app/constants/constants.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | // Package constants contains shared constants for the application. | |
| 2 | + | package constants | |
| 3 | + | ||
| 4 | + | // MIME type and path constants | |
| 5 | + | const ( | |
| 6 | + | ContentTypeJSON = "application/json; charset=utf-8" | |
| 7 | + | HomepagePath = "homepage" | |
| 8 | + | IndexHTML = "index.html" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Parsing and utility constants | |
| 12 | + | const ( | |
| 13 | + | ColonSplitParts = 2 // Number of parts when splitting on colon | |
| 14 | + | DirPerm = 0o755 // Directory permissions | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // JavaScript hash constants (auto-generated, should not be changed) | |
| 18 | + | const ( | |
| 19 | + | HashGetRecentHistory = 3708519028 // BrowserService.GetRecentHistory(limit) | |
| 20 | + | HashGetSearchShortcuts = 4078533762 // BrowserService.GetSearchShortcuts() | |
| 21 | + | ) | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | // Package constants contains shared constants for the application. | |||
| 2 | + | package constants | |||
| 3 | + | ||||
| 4 | + | // MIME type and path constants | |||
| 5 | + | const ( | |||
| 6 | + | ContentTypeJSON = "application/json; charset=utf-8" | |||
| 7 | + | HomepagePath = "homepage" | |||
| 8 | + | IndexHTML = "index.html" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Parsing and utility constants | |||
| 12 | + | const ( | |||
| 13 | + | ColonSplitParts = 2 // Number of parts when splitting on colon | |||
| 14 | + | DirPerm = 0o755 // Directory permissions | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // JavaScript hash constants (auto-generated, should not be changed) | |||
| 18 | + | const ( | |||
| 19 | + | HashGetRecentHistory = 3708519028 // BrowserService.GetRecentHistory(limit) | |||
| 20 | + | HashGetSearchShortcuts = 4078533762 // BrowserService.GetSearchShortcuts() | |||
| 21 | + | ) | |||
A internal/app/control/clipboard.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package control | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "log" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/pkg/clipboard" | |
| 7 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ClipboardController handles clipboard operations | |
| 11 | + | type ClipboardController struct { | |
| 12 | + | webView *webkit.WebView | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // NewClipboardController creates a new clipboard controller | |
| 16 | + | func NewClipboardController(webView *webkit.WebView) *ClipboardController { | |
| 17 | + | return &ClipboardController{ | |
| 18 | + | webView: webView, | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // CopyCurrentURL copies the current WebView URL to the clipboard | |
| 23 | + | func (c *ClipboardController) CopyCurrentURL() { | |
| 24 | + | log.Printf("Shortcut: Copy URL") | |
| 25 | + | currentURL := c.webView.GetCurrentURL() | |
| 26 | + | if currentURL == "" { | |
| 27 | + | log.Printf("No URL to copy") | |
| 28 | + | _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`) | |
| 29 | + | return | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | if err := clipboard.CopyToClipboard(currentURL); err != nil { | |
| 33 | + | log.Printf("Failed to copy URL to clipboard: %v", err) | |
| 34 | + | _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`) | |
| 35 | + | } else { | |
| 36 | + | log.Printf("URL copied to clipboard: %s", currentURL) | |
| 37 | + | _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`) | |
| 38 | + | } | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package control | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "log" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/pkg/clipboard" | |||
| 7 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // ClipboardController handles clipboard operations | |||
| 11 | + | type ClipboardController struct { | |||
| 12 | + | webView *webkit.WebView | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // NewClipboardController creates a new clipboard controller | |||
| 16 | + | func NewClipboardController(webView *webkit.WebView) *ClipboardController { | |||
| 17 | + | return &ClipboardController{ | |||
| 18 | + | webView: webView, | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // CopyCurrentURL copies the current WebView URL to the clipboard | |||
| 23 | + | func (c *ClipboardController) CopyCurrentURL() { | |||
| 24 | + | log.Printf("Shortcut: Copy URL") | |||
| 25 | + | currentURL := c.webView.GetCurrentURL() | |||
| 26 | + | if currentURL == "" { | |||
| 27 | + | log.Printf("No URL to copy") | |||
| 28 | + | _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`) | |||
| 29 | + | return | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | if err := clipboard.CopyToClipboard(currentURL); err != nil { | |||
| 33 | + | log.Printf("Failed to copy URL to clipboard: %v", err) | |||
| 34 | + | _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`) | |||
| 35 | + | } else { | |||
| 36 | + | log.Printf("URL copied to clipboard: %s", currentURL) | |||
| 37 | + | _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`) | |||
| 38 | + | } | |||
| 39 | + | } | |||
A internal/app/control/navigation.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package control | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "log" | |
| 6 | + | "os" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 9 | + | "github.com/bnema/dumber/services" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // NavigationController manages navigation functionality | |
| 13 | + | type NavigationController struct { | |
| 14 | + | parserService *services.ParserService | |
| 15 | + | browserService *services.BrowserService | |
| 16 | + | webView *webkit.WebView | |
| 17 | + | zoomController *ZoomController | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // NewNavigationController creates a new navigation controller | |
| 21 | + | func NewNavigationController( | |
| 22 | + | parserService *services.ParserService, | |
| 23 | + | browserService *services.BrowserService, | |
| 24 | + | webView *webkit.WebView, | |
| 25 | + | zoomController *ZoomController, | |
| 26 | + | ) *NavigationController { | |
| 27 | + | return &NavigationController{ | |
| 28 | + | parserService: parserService, | |
| 29 | + | browserService: browserService, | |
| 30 | + | webView: webView, | |
| 31 | + | zoomController: zoomController, | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // NavigateToURL parses input and navigates to the resulting URL | |
| 36 | + | func (n *NavigationController) NavigateToURL(input string) error { | |
| 37 | + | ctx := context.Background() | |
| 38 | + | result, err := n.parserService.ParseInput(ctx, input) | |
| 39 | + | if err != nil { | |
| 40 | + | return err | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // Record navigation in browser service | |
| 44 | + | if _, navErr := n.browserService.Navigate(ctx, result.URL); navErr != nil { | |
| 45 | + | log.Printf("Warning: failed to navigate to %s: %v", result.URL, navErr) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Load URL in WebView | |
| 49 | + | if err := n.webView.LoadURL(result.URL); err != nil { | |
| 50 | + | return err | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Apply zoom for the new URL | |
| 54 | + | n.zoomController.ApplyZoomForURL(result.URL) | |
| 55 | + | ||
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // HandleBrowseCommand processes the browse command line argument | |
| 60 | + | func (n *NavigationController) HandleBrowseCommand() { | |
| 61 | + | if len(os.Args) >= 3 && os.Args[1] == "browse" { | |
| 62 | + | log.Printf("Browse command detected: %s", os.Args[2]) | |
| 63 | + | ctx := context.Background() | |
| 64 | + | result, err := n.parserService.ParseInput(ctx, os.Args[2]) | |
| 65 | + | if err == nil { | |
| 66 | + | log.Printf("Parsed input → URL: %s", result.URL) | |
| 67 | + | if _, navErr := n.browserService.Navigate(ctx, result.URL); navErr != nil { | |
| 68 | + | log.Printf("Warning: failed to navigate to %s: %v", result.URL, navErr) | |
| 69 | + | } | |
| 70 | + | if n.webView != nil { | |
| 71 | + | log.Printf("Loading URL in WebView: %s", result.URL) | |
| 72 | + | if err := n.webView.LoadURL(result.URL); err != nil { | |
| 73 | + | log.Printf("Warning: failed to load URL: %v", err) | |
| 74 | + | } | |
| 75 | + | n.zoomController.ApplyZoomForURL(result.URL) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | package control | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "log" | |||
| 6 | + | "os" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 9 | + | "github.com/bnema/dumber/services" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // NavigationController manages navigation functionality | |||
| 13 | + | type NavigationController struct { | |||
| 14 | + | parserService *services.ParserService | |||
| 15 | + | browserService *services.BrowserService | |||
| 16 | + | webView *webkit.WebView | |||
| 17 | + | zoomController *ZoomController | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // NewNavigationController creates a new navigation controller | |||
| 21 | + | func NewNavigationController( | |||
| 22 | + | parserService *services.ParserService, | |||
| 23 | + | browserService *services.BrowserService, | |||
| 24 | + | webView *webkit.WebView, | |||
| 25 | + | zoomController *ZoomController, | |||
| 26 | + | ) *NavigationController { | |||
| 27 | + | return &NavigationController{ | |||
| 28 | + | parserService: parserService, | |||
| 29 | + | browserService: browserService, | |||
| 30 | + | webView: webView, | |||
| 31 | + | zoomController: zoomController, | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // NavigateToURL parses input and navigates to the resulting URL | |||
| 36 | + | func (n *NavigationController) NavigateToURL(input string) error { | |||
| 37 | + | ctx := context.Background() | |||
| 38 | + | result, err := n.parserService.ParseInput(ctx, input) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return err | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // Record navigation in browser service | |||
| 44 | + | if _, navErr := n.browserService.Navigate(ctx, result.URL); navErr != nil { | |||
| 45 | + | log.Printf("Warning: failed to navigate to %s: %v", result.URL, navErr) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Load URL in WebView | |||
| 49 | + | if err := n.webView.LoadURL(result.URL); err != nil { | |||
| 50 | + | return err | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Apply zoom for the new URL | |||
| 54 | + | n.zoomController.ApplyZoomForURL(result.URL) | |||
| 55 | + | ||||
| 56 | + | return nil | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // HandleBrowseCommand processes the browse command line argument | |||
| 60 | + | func (n *NavigationController) HandleBrowseCommand() { | |||
| 61 | + | if len(os.Args) >= 3 && os.Args[1] == "browse" { | |||
| 62 | + | log.Printf("Browse command detected: %s", os.Args[2]) | |||
| 63 | + | ctx := context.Background() | |||
| 64 | + | result, err := n.parserService.ParseInput(ctx, os.Args[2]) | |||
| 65 | + | if err == nil { | |||
| 66 | + | log.Printf("Parsed input → URL: %s", result.URL) | |||
| 67 | + | if _, navErr := n.browserService.Navigate(ctx, result.URL); navErr != nil { | |||
| 68 | + | log.Printf("Warning: failed to navigate to %s: %v", result.URL, navErr) | |||
| 69 | + | } | |||
| 70 | + | if n.webView != nil { | |||
| 71 | + | log.Printf("Loading URL in WebView: %s", result.URL) | |||
| 72 | + | if err := n.webView.LoadURL(result.URL); err != nil { | |||
| 73 | + | log.Printf("Warning: failed to load URL: %v", err) | |||
| 74 | + | } | |||
| 75 | + | n.zoomController.ApplyZoomForURL(result.URL) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
A internal/app/control/zoom.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 1 | + | package control | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "log" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 9 | + | "github.com/bnema/dumber/services" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // ZoomController manages zoom functionality for the WebView | |
| 13 | + | type ZoomController struct { | |
| 14 | + | currentURL string | |
| 15 | + | lastZoomDomain string | |
| 16 | + | programmaticChange bool | |
| 17 | + | browserService *services.BrowserService | |
| 18 | + | webView *webkit.WebView | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // WebViewInterface defines the interface for WebView zoom operations | |
| 22 | + | type WebViewInterface interface { | |
| 23 | + | GetCurrentURL() string | |
| 24 | + | SetZoom(level float64) error | |
| 25 | + | InjectScript(script string) error | |
| 26 | + | RegisterZoomChangedHandler(handler func(float64)) | |
| 27 | + | RegisterURIChangedHandler(handler func(string)) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // NewZoomController creates a new zoom controller | |
| 31 | + | func NewZoomController(browserService *services.BrowserService, webView *webkit.WebView) *ZoomController { | |
| 32 | + | return &ZoomController{ | |
| 33 | + | currentURL: "dumb://homepage", | |
| 34 | + | browserService: browserService, | |
| 35 | + | webView: webView, | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // RegisterHandlers sets up the zoom-related event handlers | |
| 40 | + | func (z *ZoomController) RegisterHandlers() { | |
| 41 | + | z.webView.RegisterZoomChangedHandler(z.handleZoomChange) | |
| 42 | + | z.webView.RegisterURIChangedHandler(z.handleURIChange) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // handleURIChange responds to URL changes and applies saved zoom levels | |
| 46 | + | func (z *ZoomController) handleURIChange(url string) { | |
| 47 | + | z.currentURL = url | |
| 48 | + | if url == "" { | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | ctx := context.Background() | |
| 52 | + | currentDomain := services.ZoomKeyForLog(url) | |
| 53 | + | ||
| 54 | + | if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil { | |
| 55 | + | z.programmaticChange = true | |
| 56 | + | if err := z.webView.SetZoom(zoomLevel); err == nil { | |
| 57 | + | log.Printf("[zoom] loaded %.2f for %s", zoomLevel, currentDomain) | |
| 58 | + | ||
| 59 | + | // Only show toast when entering a new domain (not on first load) | |
| 60 | + | if currentDomain != z.lastZoomDomain && z.lastZoomDomain != "" { | |
| 61 | + | z.showZoomToast(zoomLevel) | |
| 62 | + | } | |
| 63 | + | z.lastZoomDomain = currentDomain | |
| 64 | + | } | |
| 65 | + | z.programmaticChange = false | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // handleZoomChange responds to zoom level changes and persists them | |
| 70 | + | func (z *ZoomController) handleZoomChange(level float64) { | |
| 71 | + | url := z.webView.GetCurrentURL() | |
| 72 | + | if url == "" { | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | ctx := context.Background() | |
| 76 | + | if err := z.browserService.SetZoomLevel(ctx, url, level); err != nil { | |
| 77 | + | log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err) | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | key := services.ZoomKeyForLog(url) | |
| 81 | + | log.Printf("[zoom] saved %.2f for %s", level, key) | |
| 82 | + | ||
| 83 | + | // Only show toast for user-initiated zoom changes, not programmatic ones | |
| 84 | + | if !z.programmaticChange { | |
| 85 | + | z.showZoomToast(level) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // showZoomToast displays a zoom level notification | |
| 90 | + | func (z *ZoomController) showZoomToast(level float64) { | |
| 91 | + | js := fmt.Sprintf(`try { | |
| 92 | + | if (typeof window.__dumber_showZoomToast === 'function') { | |
| 93 | + | window.__dumber_showZoomToast(%f); | |
| 94 | + | } else { | |
| 95 | + | console.warn('[dumber] Zoom toast function not available yet'); | |
| 96 | + | } | |
| 97 | + | } catch(e) { | |
| 98 | + | console.error('[dumber] Zoom toast error:', e); | |
| 99 | + | }`, level) | |
| 100 | + | if err := z.webView.InjectScript(js); err != nil { | |
| 101 | + | log.Printf("[zoom] failed to show toast: %v", err) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // ApplyInitialZoom sets the initial zoom level for the current URL | |
| 106 | + | func (z *ZoomController) ApplyInitialZoom() { | |
| 107 | + | ctx := context.Background() | |
| 108 | + | url := z.webView.GetCurrentURL() | |
| 109 | + | if url == "" { | |
| 110 | + | url = "dumb://homepage" | |
| 111 | + | } | |
| 112 | + | if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil { | |
| 113 | + | z.programmaticChange = true | |
| 114 | + | if err := z.webView.SetZoom(zoomLevel); err != nil { | |
| 115 | + | log.Printf("Warning: failed to set initial zoom: %v", err) | |
| 116 | + | } else { | |
| 117 | + | key := services.ZoomKeyForLog(url) | |
| 118 | + | log.Printf("[zoom] loaded %.2f for %s", zoomLevel, key) | |
| 119 | + | } | |
| 120 | + | z.programmaticChange = false | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // ApplyZoomForURL applies zoom for a specific URL (used for navigation) | |
| 125 | + | func (z *ZoomController) ApplyZoomForURL(url string) { | |
| 126 | + | ctx := context.Background() | |
| 127 | + | if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil { | |
| 128 | + | z.programmaticChange = true | |
| 129 | + | if err := z.webView.SetZoom(zoomLevel); err != nil { | |
| 130 | + | log.Printf("Warning: failed to set zoom: %v", err) | |
| 131 | + | } else { | |
| 132 | + | key := services.ZoomKeyForLog(url) | |
| 133 | + | log.Printf("[zoom] loaded %.2f for %s", zoomLevel, key) | |
| 134 | + | } | |
| 135 | + | z.programmaticChange = false | |
| 136 | + | } | |
| 137 | + | } | |
| @@ -0,0 +1,137 @@ | |||||
| 1 | + | package control | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "log" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 9 | + | "github.com/bnema/dumber/services" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // ZoomController manages zoom functionality for the WebView | |||
| 13 | + | type ZoomController struct { | |||
| 14 | + | currentURL string | |||
| 15 | + | lastZoomDomain string | |||
| 16 | + | programmaticChange bool | |||
| 17 | + | browserService *services.BrowserService | |||
| 18 | + | webView *webkit.WebView | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // WebViewInterface defines the interface for WebView zoom operations | |||
| 22 | + | type WebViewInterface interface { | |||
| 23 | + | GetCurrentURL() string | |||
| 24 | + | SetZoom(level float64) error | |||
| 25 | + | InjectScript(script string) error | |||
| 26 | + | RegisterZoomChangedHandler(handler func(float64)) | |||
| 27 | + | RegisterURIChangedHandler(handler func(string)) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // NewZoomController creates a new zoom controller | |||
| 31 | + | func NewZoomController(browserService *services.BrowserService, webView *webkit.WebView) *ZoomController { | |||
| 32 | + | return &ZoomController{ | |||
| 33 | + | currentURL: "dumb://homepage", | |||
| 34 | + | browserService: browserService, | |||
| 35 | + | webView: webView, | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // RegisterHandlers sets up the zoom-related event handlers | |||
| 40 | + | func (z *ZoomController) RegisterHandlers() { | |||
| 41 | + | z.webView.RegisterZoomChangedHandler(z.handleZoomChange) | |||
| 42 | + | z.webView.RegisterURIChangedHandler(z.handleURIChange) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // handleURIChange responds to URL changes and applies saved zoom levels | |||
| 46 | + | func (z *ZoomController) handleURIChange(url string) { | |||
| 47 | + | z.currentURL = url | |||
| 48 | + | if url == "" { | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | ctx := context.Background() | |||
| 52 | + | currentDomain := services.ZoomKeyForLog(url) | |||
| 53 | + | ||||
| 54 | + | if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil { | |||
| 55 | + | z.programmaticChange = true | |||
| 56 | + | if err := z.webView.SetZoom(zoomLevel); err == nil { | |||
| 57 | + | log.Printf("[zoom] loaded %.2f for %s", zoomLevel, currentDomain) | |||
| 58 | + | ||||
| 59 | + | // Only show toast when entering a new domain (not on first load) | |||
| 60 | + | if currentDomain != z.lastZoomDomain && z.lastZoomDomain != "" { | |||
| 61 | + | z.showZoomToast(zoomLevel) | |||
| 62 | + | } | |||
| 63 | + | z.lastZoomDomain = currentDomain | |||
| 64 | + | } | |||
| 65 | + | z.programmaticChange = false | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // handleZoomChange responds to zoom level changes and persists them | |||
| 70 | + | func (z *ZoomController) handleZoomChange(level float64) { | |||
| 71 | + | url := z.webView.GetCurrentURL() | |||
| 72 | + | if url == "" { | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | ctx := context.Background() | |||
| 76 | + | if err := z.browserService.SetZoomLevel(ctx, url, level); err != nil { | |||
| 77 | + | log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err) | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | key := services.ZoomKeyForLog(url) | |||
| 81 | + | log.Printf("[zoom] saved %.2f for %s", level, key) | |||
| 82 | + | ||||
| 83 | + | // Only show toast for user-initiated zoom changes, not programmatic ones | |||
| 84 | + | if !z.programmaticChange { | |||
| 85 | + | z.showZoomToast(level) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // showZoomToast displays a zoom level notification | |||
| 90 | + | func (z *ZoomController) showZoomToast(level float64) { | |||
| 91 | + | js := fmt.Sprintf(`try { | |||
| 92 | + | if (typeof window.__dumber_showZoomToast === 'function') { | |||
| 93 | + | window.__dumber_showZoomToast(%f); | |||
| 94 | + | } else { | |||
| 95 | + | console.warn('[dumber] Zoom toast function not available yet'); | |||
| 96 | + | } | |||
| 97 | + | } catch(e) { | |||
| 98 | + | console.error('[dumber] Zoom toast error:', e); | |||
| 99 | + | }`, level) | |||
| 100 | + | if err := z.webView.InjectScript(js); err != nil { | |||
| 101 | + | log.Printf("[zoom] failed to show toast: %v", err) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // ApplyInitialZoom sets the initial zoom level for the current URL | |||
| 106 | + | func (z *ZoomController) ApplyInitialZoom() { | |||
| 107 | + | ctx := context.Background() | |||
| 108 | + | url := z.webView.GetCurrentURL() | |||
| 109 | + | if url == "" { | |||
| 110 | + | url = "dumb://homepage" | |||
| 111 | + | } | |||
| 112 | + | if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil { | |||
| 113 | + | z.programmaticChange = true | |||
| 114 | + | if err := z.webView.SetZoom(zoomLevel); err != nil { | |||
| 115 | + | log.Printf("Warning: failed to set initial zoom: %v", err) | |||
| 116 | + | } else { | |||
| 117 | + | key := services.ZoomKeyForLog(url) | |||
| 118 | + | log.Printf("[zoom] loaded %.2f for %s", zoomLevel, key) | |||
| 119 | + | } | |||
| 120 | + | z.programmaticChange = false | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // ApplyZoomForURL applies zoom for a specific URL (used for navigation) | |||
| 125 | + | func (z *ZoomController) ApplyZoomForURL(url string) { | |||
| 126 | + | ctx := context.Background() | |||
| 127 | + | if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil { | |||
| 128 | + | z.programmaticChange = true | |||
| 129 | + | if err := z.webView.SetZoom(zoomLevel); err != nil { | |||
| 130 | + | log.Printf("Warning: failed to set zoom: %v", err) | |||
| 131 | + | } else { | |||
| 132 | + | key := services.ZoomKeyForLog(url) | |||
| 133 | + | log.Printf("[zoom] loaded %.2f for %s", zoomLevel, key) | |||
| 134 | + | } | |||
| 135 | + | z.programmaticChange = false | |||
| 136 | + | } | |||
| 137 | + | } | |||
A internal/app/environment/codec.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | package environment | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "log" | |
| 5 | + | "os" | |
| 6 | + | "strconv" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/config" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // ApplyCodecConfiguration applies codec preferences to environment variables and GStreamer settings | |
| 13 | + | func ApplyCodecConfiguration(codecPrefs config.CodecConfig) { | |
| 14 | + | log.Printf("[codec] Applying codec preferences: preferred=%s, force_av1=%t, block_vp9=%t", | |
| 15 | + | codecPrefs.PreferredCodecs, codecPrefs.ForceAV1, codecPrefs.BlockVP9) | |
| 16 | + | ||
| 17 | + | // Build comprehensive GStreamer plugin rankings | |
| 18 | + | var rankSettings []string | |
| 19 | + | ||
| 20 | + | // Get existing GST_PLUGIN_FEATURE_RANK or start fresh | |
| 21 | + | existingRank := os.Getenv("GST_PLUGIN_FEATURE_RANK") | |
| 22 | + | if existingRank != "" { | |
| 23 | + | rankSettings = strings.Split(existingRank, ",") | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // AV1 decoder promotions (high priority) | |
| 27 | + | if codecPrefs.ForceAV1 || strings.Contains(codecPrefs.PreferredCodecs, "av1") { | |
| 28 | + | av1Ranks := []string{ | |
| 29 | + | "vaav1dec:768", // VA-API AV1 decoder (highest priority) | |
| 30 | + | "nvav1dec:512", // NVDEC AV1 decoder | |
| 31 | + | "av1dec:384", // Software AV1 decoder (fallback) | |
| 32 | + | "avdec_av1:256", // libav AV1 decoder | |
| 33 | + | } | |
| 34 | + | rankSettings = append(rankSettings, av1Ranks...) | |
| 35 | + | log.Printf("[codec] Promoted AV1 decoders for maximum priority") | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // H.264 decoder management | |
| 39 | + | if strings.Contains(codecPrefs.PreferredCodecs, "h264") { | |
| 40 | + | h264Ranks := []string{ | |
| 41 | + | "vah264dec:512", // VA-API H.264 decoder | |
| 42 | + | "nvh264dec:384", // NVDEC H.264 decoder | |
| 43 | + | "avdec_h264:256", // Software H.264 decoder | |
| 44 | + | } | |
| 45 | + | rankSettings = append(rankSettings, h264Ranks...) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // VP9 decoder demotion/blocking | |
| 49 | + | if codecPrefs.DisableVP9Hardware || codecPrefs.BlockVP9 { | |
| 50 | + | vp9Demotions := []string{ | |
| 51 | + | "vaapivp9dec:0", // Block VA-API VP9 | |
| 52 | + | "nvvp9dec:0", // Block NVDEC VP9 | |
| 53 | + | } | |
| 54 | + | if codecPrefs.BlockVP9 { | |
| 55 | + | vp9Demotions = append(vp9Demotions, "vp9dec:0", "avdec_vp9:0") // Block all VP9 | |
| 56 | + | } else { | |
| 57 | + | // Allow software VP9 with low priority | |
| 58 | + | vp9Demotions = append(vp9Demotions, "vp9dec:64", "avdec_vp9:64") | |
| 59 | + | } | |
| 60 | + | rankSettings = append(rankSettings, vp9Demotions...) | |
| 61 | + | log.Printf("[codec] Demoted/blocked VP9 decoders") | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // VP8 decoder blocking | |
| 65 | + | if codecPrefs.BlockVP8 { | |
| 66 | + | vp8Demotions := []string{ | |
| 67 | + | "vaapivp8dec:0", | |
| 68 | + | "vp8dec:0", | |
| 69 | + | "avdec_vp8:0", | |
| 70 | + | } | |
| 71 | + | rankSettings = append(rankSettings, vp8Demotions...) | |
| 72 | + | log.Printf("[codec] Blocked VP8 decoders") | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Apply the complete ranking system | |
| 76 | + | if len(rankSettings) > 0 { | |
| 77 | + | finalRank := strings.Join(rankSettings, ",") | |
| 78 | + | if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", finalRank); err != nil { | |
| 79 | + | log.Printf("[codec] Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err) | |
| 80 | + | } else { | |
| 81 | + | log.Printf("[codec] Set comprehensive GST_PLUGIN_FEATURE_RANK: %s", finalRank) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Set video buffer sizes if specified | |
| 86 | + | if codecPrefs.VideoBufferSizeMB > 0 { | |
| 87 | + | bufferSize := strconv.Itoa(codecPrefs.VideoBufferSizeMB * 1024 * 1024) | |
| 88 | + | ||
| 89 | + | if err := os.Setenv("GST_BUFFER_SIZE", bufferSize); err != nil { | |
| 90 | + | log.Printf("[codec] Warning: failed to set GST_BUFFER_SIZE: %v", err) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | if err := os.Setenv("GST_QUEUE2_MAX_SIZE_BYTES", bufferSize); err != nil { | |
| 94 | + | log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_BYTES: %v", err) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | log.Printf("[codec] Set video buffer size to %dMB", codecPrefs.VideoBufferSizeMB) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Set queue buffer time if specified | |
| 101 | + | if codecPrefs.QueueBufferTimeSec > 0 { | |
| 102 | + | // Convert seconds to nanoseconds for GStreamer | |
| 103 | + | bufferTime := strconv.Itoa(codecPrefs.QueueBufferTimeSec * 1000000000) | |
| 104 | + | ||
| 105 | + | if err := os.Setenv("GST_QUEUE2_MAX_SIZE_TIME", bufferTime); err != nil { | |
| 106 | + | log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_TIME: %v", err) | |
| 107 | + | } else { | |
| 108 | + | log.Printf("[codec] Set queue buffer time to %ds", codecPrefs.QueueBufferTimeSec) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Set additional codec-specific environment variables | |
| 113 | + | if codecPrefs.ForceAV1 { | |
| 114 | + | // Enable AV1 hardware decoding if available | |
| 115 | + | if err := os.Setenv("GST_AV1_DECODER_ENABLE_HW", "1"); err != nil { | |
| 116 | + | log.Printf("[codec] Warning: failed to set GST_AV1_DECODER_ENABLE_HW: %v", err) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Disable video post-processing to avoid VA-API issues | |
| 121 | + | if err := os.Setenv("GST_VAAPI_DISABLE_VPP", "1"); err != nil { | |
| 122 | + | log.Printf("[codec] Warning: failed to set GST_VAAPI_DISABLE_VPP: %v", err) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | log.Printf("[codec] Codec configuration applied successfully") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // BuildBlockedCodecsList converts config codec blocking preferences to a string slice | |
| 129 | + | func BuildBlockedCodecsList(codecPrefs config.CodecConfig) []string { | |
| 130 | + | var blocked []string | |
| 131 | + | ||
| 132 | + | if codecPrefs.BlockVP9 { | |
| 133 | + | blocked = append(blocked, "vp9") | |
| 134 | + | } | |
| 135 | + | if codecPrefs.BlockVP8 { | |
| 136 | + | blocked = append(blocked, "vp8") | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | return blocked | |
| 140 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | package environment | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "log" | |||
| 5 | + | "os" | |||
| 6 | + | "strconv" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/config" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // ApplyCodecConfiguration applies codec preferences to environment variables and GStreamer settings | |||
| 13 | + | func ApplyCodecConfiguration(codecPrefs config.CodecConfig) { | |||
| 14 | + | log.Printf("[codec] Applying codec preferences: preferred=%s, force_av1=%t, block_vp9=%t", | |||
| 15 | + | codecPrefs.PreferredCodecs, codecPrefs.ForceAV1, codecPrefs.BlockVP9) | |||
| 16 | + | ||||
| 17 | + | // Build comprehensive GStreamer plugin rankings | |||
| 18 | + | var rankSettings []string | |||
| 19 | + | ||||
| 20 | + | // Get existing GST_PLUGIN_FEATURE_RANK or start fresh | |||
| 21 | + | existingRank := os.Getenv("GST_PLUGIN_FEATURE_RANK") | |||
| 22 | + | if existingRank != "" { | |||
| 23 | + | rankSettings = strings.Split(existingRank, ",") | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // AV1 decoder promotions (high priority) | |||
| 27 | + | if codecPrefs.ForceAV1 || strings.Contains(codecPrefs.PreferredCodecs, "av1") { | |||
| 28 | + | av1Ranks := []string{ | |||
| 29 | + | "vaav1dec:768", // VA-API AV1 decoder (highest priority) | |||
| 30 | + | "nvav1dec:512", // NVDEC AV1 decoder | |||
| 31 | + | "av1dec:384", // Software AV1 decoder (fallback) | |||
| 32 | + | "avdec_av1:256", // libav AV1 decoder | |||
| 33 | + | } | |||
| 34 | + | rankSettings = append(rankSettings, av1Ranks...) | |||
| 35 | + | log.Printf("[codec] Promoted AV1 decoders for maximum priority") | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // H.264 decoder management | |||
| 39 | + | if strings.Contains(codecPrefs.PreferredCodecs, "h264") { | |||
| 40 | + | h264Ranks := []string{ | |||
| 41 | + | "vah264dec:512", // VA-API H.264 decoder | |||
| 42 | + | "nvh264dec:384", // NVDEC H.264 decoder | |||
| 43 | + | "avdec_h264:256", // Software H.264 decoder | |||
| 44 | + | } | |||
| 45 | + | rankSettings = append(rankSettings, h264Ranks...) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // VP9 decoder demotion/blocking | |||
| 49 | + | if codecPrefs.DisableVP9Hardware || codecPrefs.BlockVP9 { | |||
| 50 | + | vp9Demotions := []string{ | |||
| 51 | + | "vaapivp9dec:0", // Block VA-API VP9 | |||
| 52 | + | "nvvp9dec:0", // Block NVDEC VP9 | |||
| 53 | + | } | |||
| 54 | + | if codecPrefs.BlockVP9 { | |||
| 55 | + | vp9Demotions = append(vp9Demotions, "vp9dec:0", "avdec_vp9:0") // Block all VP9 | |||
| 56 | + | } else { | |||
| 57 | + | // Allow software VP9 with low priority | |||
| 58 | + | vp9Demotions = append(vp9Demotions, "vp9dec:64", "avdec_vp9:64") | |||
| 59 | + | } | |||
| 60 | + | rankSettings = append(rankSettings, vp9Demotions...) | |||
| 61 | + | log.Printf("[codec] Demoted/blocked VP9 decoders") | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // VP8 decoder blocking | |||
| 65 | + | if codecPrefs.BlockVP8 { | |||
| 66 | + | vp8Demotions := []string{ | |||
| 67 | + | "vaapivp8dec:0", | |||
| 68 | + | "vp8dec:0", | |||
| 69 | + | "avdec_vp8:0", | |||
| 70 | + | } | |||
| 71 | + | rankSettings = append(rankSettings, vp8Demotions...) | |||
| 72 | + | log.Printf("[codec] Blocked VP8 decoders") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Apply the complete ranking system | |||
| 76 | + | if len(rankSettings) > 0 { | |||
| 77 | + | finalRank := strings.Join(rankSettings, ",") | |||
| 78 | + | if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", finalRank); err != nil { | |||
| 79 | + | log.Printf("[codec] Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err) | |||
| 80 | + | } else { | |||
| 81 | + | log.Printf("[codec] Set comprehensive GST_PLUGIN_FEATURE_RANK: %s", finalRank) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Set video buffer sizes if specified | |||
| 86 | + | if codecPrefs.VideoBufferSizeMB > 0 { | |||
| 87 | + | bufferSize := strconv.Itoa(codecPrefs.VideoBufferSizeMB * 1024 * 1024) | |||
| 88 | + | ||||
| 89 | + | if err := os.Setenv("GST_BUFFER_SIZE", bufferSize); err != nil { | |||
| 90 | + | log.Printf("[codec] Warning: failed to set GST_BUFFER_SIZE: %v", err) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | if err := os.Setenv("GST_QUEUE2_MAX_SIZE_BYTES", bufferSize); err != nil { | |||
| 94 | + | log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_BYTES: %v", err) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | log.Printf("[codec] Set video buffer size to %dMB", codecPrefs.VideoBufferSizeMB) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Set queue buffer time if specified | |||
| 101 | + | if codecPrefs.QueueBufferTimeSec > 0 { | |||
| 102 | + | // Convert seconds to nanoseconds for GStreamer | |||
| 103 | + | bufferTime := strconv.Itoa(codecPrefs.QueueBufferTimeSec * 1000000000) | |||
| 104 | + | ||||
| 105 | + | if err := os.Setenv("GST_QUEUE2_MAX_SIZE_TIME", bufferTime); err != nil { | |||
| 106 | + | log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_TIME: %v", err) | |||
| 107 | + | } else { | |||
| 108 | + | log.Printf("[codec] Set queue buffer time to %ds", codecPrefs.QueueBufferTimeSec) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Set additional codec-specific environment variables | |||
| 113 | + | if codecPrefs.ForceAV1 { | |||
| 114 | + | // Enable AV1 hardware decoding if available | |||
| 115 | + | if err := os.Setenv("GST_AV1_DECODER_ENABLE_HW", "1"); err != nil { | |||
| 116 | + | log.Printf("[codec] Warning: failed to set GST_AV1_DECODER_ENABLE_HW: %v", err) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // Disable video post-processing to avoid VA-API issues | |||
| 121 | + | if err := os.Setenv("GST_VAAPI_DISABLE_VPP", "1"); err != nil { | |||
| 122 | + | log.Printf("[codec] Warning: failed to set GST_VAAPI_DISABLE_VPP: %v", err) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | log.Printf("[codec] Codec configuration applied successfully") | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // BuildBlockedCodecsList converts config codec blocking preferences to a string slice | |||
| 129 | + | func BuildBlockedCodecsList(codecPrefs config.CodecConfig) []string { | |||
| 130 | + | var blocked []string | |||
| 131 | + | ||||
| 132 | + | if codecPrefs.BlockVP9 { | |||
| 133 | + | blocked = append(blocked, "vp9") | |||
| 134 | + | } | |||
| 135 | + | if codecPrefs.BlockVP8 { | |||
| 136 | + | blocked = append(blocked, "vp8") | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | return blocked | |||
| 140 | + | } | |||
A internal/app/environment/locale.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | package environment | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "log" | |
| 5 | + | "os" | |
| 6 | + | "os/exec" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // DetectAndSetKeyboardLocale attempts to determine the current keyboard layout | |
| 11 | + | // and hints it to the webkit input layer for accelerator compatibility. | |
| 12 | + | func DetectAndSetKeyboardLocale() { | |
| 13 | + | const colonSplitParts = 2 // Number of parts when splitting on colon | |
| 14 | + | ||
| 15 | + | // 0) Explicit override | |
| 16 | + | locale := os.Getenv("DUMBER_KEYBOARD_LOCALE") | |
| 17 | + | if locale == "" { | |
| 18 | + | locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix | |
| 19 | + | } | |
| 20 | + | // 1) Environment | |
| 21 | + | if locale == "" { | |
| 22 | + | locale = os.Getenv("LC_ALL") | |
| 23 | + | } | |
| 24 | + | if locale == "" { | |
| 25 | + | locale = os.Getenv("LANG") | |
| 26 | + | } | |
| 27 | + | if locale == "" { | |
| 28 | + | locale = os.Getenv("LC_CTYPE") | |
| 29 | + | } | |
| 30 | + | // Trim variants | |
| 31 | + | if i := strings.IndexByte(locale, '.'); i > 0 { | |
| 32 | + | locale = locale[:i] | |
| 33 | + | } | |
| 34 | + | if i := strings.IndexByte(locale, '@'); i > 0 { | |
| 35 | + | locale = locale[:i] | |
| 36 | + | } | |
| 37 | + | if i := strings.IndexByte(locale, '_'); i > 0 { | |
| 38 | + | locale = locale[:i] | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // 2) Try XKB env override | |
| 42 | + | if locale == "" { | |
| 43 | + | locale = os.Getenv("XKB_DEFAULT_LAYOUT") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // 3) Best-effort probe of setxkbmap/localectl (non-fatal) | |
| 47 | + | if locale == "" { | |
| 48 | + | if out, err := exec.Command("localectl", "status").Output(); err == nil { | |
| 49 | + | s := string(out) | |
| 50 | + | for _, line := range strings.Split(s, "\n") { | |
| 51 | + | line = strings.TrimSpace(line) | |
| 52 | + | if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") { | |
| 53 | + | parts := strings.SplitN(line, ":", colonSplitParts) | |
| 54 | + | if len(parts) == colonSplitParts { | |
| 55 | + | cand := strings.TrimSpace(parts[1]) | |
| 56 | + | if cand != "" { | |
| 57 | + | locale = cand | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | break | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | if locale == "" { | |
| 66 | + | if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil { | |
| 67 | + | s := string(out) | |
| 68 | + | for _, line := range strings.Split(s, "\n") { | |
| 69 | + | line = strings.TrimSpace(line) | |
| 70 | + | if strings.HasPrefix(strings.ToLower(line), "layout:") { | |
| 71 | + | parts := strings.SplitN(line, ":", colonSplitParts) | |
| 72 | + | if len(parts) == colonSplitParts { | |
| 73 | + | cand := strings.TrimSpace(parts[1]) | |
| 74 | + | if cand != "" { | |
| 75 | + | locale = cand | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | break | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | if locale == "" { | |
| 85 | + | locale = "en" | |
| 86 | + | } | |
| 87 | + | // No layout-specific remaps; log for diagnostics only | |
| 88 | + | log.Printf("[locale] keyboard locale detected: %s", locale) | |
| 89 | + | } | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | package environment | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "log" | |||
| 5 | + | "os" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // DetectAndSetKeyboardLocale attempts to determine the current keyboard layout | |||
| 11 | + | // and hints it to the webkit input layer for accelerator compatibility. | |||
| 12 | + | func DetectAndSetKeyboardLocale() { | |||
| 13 | + | const colonSplitParts = 2 // Number of parts when splitting on colon | |||
| 14 | + | ||||
| 15 | + | // 0) Explicit override | |||
| 16 | + | locale := os.Getenv("DUMBER_KEYBOARD_LOCALE") | |||
| 17 | + | if locale == "" { | |||
| 18 | + | locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix | |||
| 19 | + | } | |||
| 20 | + | // 1) Environment | |||
| 21 | + | if locale == "" { | |||
| 22 | + | locale = os.Getenv("LC_ALL") | |||
| 23 | + | } | |||
| 24 | + | if locale == "" { | |||
| 25 | + | locale = os.Getenv("LANG") | |||
| 26 | + | } | |||
| 27 | + | if locale == "" { | |||
| 28 | + | locale = os.Getenv("LC_CTYPE") | |||
| 29 | + | } | |||
| 30 | + | // Trim variants | |||
| 31 | + | if i := strings.IndexByte(locale, '.'); i > 0 { | |||
| 32 | + | locale = locale[:i] | |||
| 33 | + | } | |||
| 34 | + | if i := strings.IndexByte(locale, '@'); i > 0 { | |||
| 35 | + | locale = locale[:i] | |||
| 36 | + | } | |||
| 37 | + | if i := strings.IndexByte(locale, '_'); i > 0 { | |||
| 38 | + | locale = locale[:i] | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // 2) Try XKB env override | |||
| 42 | + | if locale == "" { | |||
| 43 | + | locale = os.Getenv("XKB_DEFAULT_LAYOUT") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // 3) Best-effort probe of setxkbmap/localectl (non-fatal) | |||
| 47 | + | if locale == "" { | |||
| 48 | + | if out, err := exec.Command("localectl", "status").Output(); err == nil { | |||
| 49 | + | s := string(out) | |||
| 50 | + | for _, line := range strings.Split(s, "\n") { | |||
| 51 | + | line = strings.TrimSpace(line) | |||
| 52 | + | if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") { | |||
| 53 | + | parts := strings.SplitN(line, ":", colonSplitParts) | |||
| 54 | + | if len(parts) == colonSplitParts { | |||
| 55 | + | cand := strings.TrimSpace(parts[1]) | |||
| 56 | + | if cand != "" { | |||
| 57 | + | locale = cand | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | break | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | if locale == "" { | |||
| 66 | + | if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil { | |||
| 67 | + | s := string(out) | |||
| 68 | + | for _, line := range strings.Split(s, "\n") { | |||
| 69 | + | line = strings.TrimSpace(line) | |||
| 70 | + | if strings.HasPrefix(strings.ToLower(line), "layout:") { | |||
| 71 | + | parts := strings.SplitN(line, ":", colonSplitParts) | |||
| 72 | + | if len(parts) == colonSplitParts { | |||
| 73 | + | cand := strings.TrimSpace(parts[1]) | |||
| 74 | + | if cand != "" { | |||
| 75 | + | locale = cand | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | break | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | if locale == "" { | |||
| 85 | + | locale = "en" | |||
| 86 | + | } | |||
| 87 | + | // No layout-specific remaps; log for diagnostics only | |||
| 88 | + | log.Printf("[locale] keyboard locale detected: %s", locale) | |||
| 89 | + | } | |||
A internal/app/environment/media.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package environment | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "log" | |
| 5 | + | "os" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // SetupMediaPipeline proactively hardens media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins. | |
| 9 | + | // Can be disabled with DUMBER_MEDIA_SAFE=0 | |
| 10 | + | func SetupMediaPipeline() { | |
| 11 | + | if os.Getenv("DUMBER_MEDIA_SAFE") != "0" { | |
| 12 | + | // Demote VAAPI-related elements so they won't be auto-picked | |
| 13 | + | if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" { | |
| 14 | + | if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0"); err != nil { | |
| 15 | + | log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | // Provide a safe audio sink to avoid autoaudiosink failures | |
| 19 | + | if os.Getenv("GST_AUDIO_SINK") == "" { | |
| 20 | + | if err := os.Setenv("GST_AUDIO_SINK", "fakesink"); err != nil { | |
| 21 | + | log.Printf("Warning: failed to set GST_AUDIO_SINK: %v", err) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | // Optionally disable media stream features that can engage complex pipelines | |
| 25 | + | if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" { | |
| 26 | + | if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil { | |
| 27 | + | log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)") | |
| 31 | + | } | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package environment | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "log" | |||
| 5 | + | "os" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // SetupMediaPipeline proactively hardens media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins. | |||
| 9 | + | // Can be disabled with DUMBER_MEDIA_SAFE=0 | |||
| 10 | + | func SetupMediaPipeline() { | |||
| 11 | + | if os.Getenv("DUMBER_MEDIA_SAFE") != "0" { | |||
| 12 | + | // Demote VAAPI-related elements so they won't be auto-picked | |||
| 13 | + | if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" { | |||
| 14 | + | if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0"); err != nil { | |||
| 15 | + | log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | // Provide a safe audio sink to avoid autoaudiosink failures | |||
| 19 | + | if os.Getenv("GST_AUDIO_SINK") == "" { | |||
| 20 | + | if err := os.Setenv("GST_AUDIO_SINK", "fakesink"); err != nil { | |||
| 21 | + | log.Printf("Warning: failed to set GST_AUDIO_SINK: %v", err) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | // Optionally disable media stream features that can engage complex pipelines | |||
| 25 | + | if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" { | |||
| 26 | + | if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil { | |||
| 27 | + | log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)") | |||
| 31 | + | } | |||
| 32 | + | } | |||
A internal/app/messaging/handler.go +182 -0
| @@ -0,0 +1,182 @@ | |||
| 1 | + | package messaging | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "log" | |
| 7 | + | neturl "net/url" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/app/constants" | |
| 10 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 11 | + | "github.com/bnema/dumber/services" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Handler processes script messages from the WebView | |
| 15 | + | type Handler struct { | |
| 16 | + | parserService *services.ParserService | |
| 17 | + | browserService *services.BrowserService | |
| 18 | + | webView *webkit.WebView | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // Message represents a script message from the WebView | |
| 22 | + | type Message struct { | |
| 23 | + | Type string `json:"type"` | |
| 24 | + | URL string `json:"url"` | |
| 25 | + | Q string `json:"q"` | |
| 26 | + | Limit int `json:"limit"` | |
| 27 | + | Value string `json:"value"` | |
| 28 | + | // Wails fetch bridge | |
| 29 | + | ID string `json:"id"` | |
| 30 | + | Payload json.RawMessage `json:"payload"` | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // NewHandler creates a new message handler | |
| 34 | + | func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler { | |
| 35 | + | return &Handler{ | |
| 36 | + | parserService: parserService, | |
| 37 | + | browserService: browserService, | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Handle processes incoming script messages | |
| 42 | + | func (h *Handler) Handle(payload string) { | |
| 43 | + | var msg Message | |
| 44 | + | if err := json.Unmarshal([]byte(payload), &msg); err != nil { | |
| 45 | + | return | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | switch msg.Type { | |
| 49 | + | case "navigate": | |
| 50 | + | h.handleNavigation(msg) | |
| 51 | + | case "query": | |
| 52 | + | h.handleQuery(msg) | |
| 53 | + | case "wails": | |
| 54 | + | h.handleWailsBridge(msg) | |
| 55 | + | case "theme": | |
| 56 | + | h.handleTheme(msg) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // SetWebView sets the WebView reference (needed for script injection) | |
| 61 | + | func (h *Handler) SetWebView(webView *webkit.WebView) { | |
| 62 | + | h.webView = webView | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // handleNavigation processes navigation requests from the frontend | |
| 66 | + | func (h *Handler) handleNavigation(msg Message) { | |
| 67 | + | ctx := context.Background() | |
| 68 | + | res, err := h.parserService.ParseInput(ctx, msg.URL) | |
| 69 | + | if err == nil { | |
| 70 | + | if _, navErr := h.browserService.Navigate(ctx, res.URL); navErr != nil { | |
| 71 | + | log.Printf("Warning: failed to navigate to %s: %v", res.URL, navErr) | |
| 72 | + | } | |
| 73 | + | if h.webView != nil { | |
| 74 | + | _ = h.webView.LoadURL(res.URL) | |
| 75 | + | if z, zerr := h.browserService.GetZoomLevel(ctx, res.URL); zerr == nil { | |
| 76 | + | _ = h.webView.SetZoom(z) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // handleQuery processes history search queries from the frontend | |
| 83 | + | func (h *Handler) handleQuery(msg Message) { | |
| 84 | + | ctx := context.Background() | |
| 85 | + | limit := msg.Limit | |
| 86 | + | if limit <= 0 || limit > 25 { | |
| 87 | + | limit = 10 | |
| 88 | + | } | |
| 89 | + | entries, err := h.browserService.SearchHistory(ctx, msg.Q, limit) | |
| 90 | + | if err != nil { | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Map to lightweight items for frontend | |
| 95 | + | type item struct { | |
| 96 | + | URL string `json:"url"` | |
| 97 | + | Favicon string `json:"favicon"` | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | buildFavicon := func(raw string) string { | |
| 101 | + | u, err := h.parserService.ParseInput(ctx, raw) | |
| 102 | + | if err != nil || u.URL == "" { | |
| 103 | + | return "" | |
| 104 | + | } | |
| 105 | + | parsed, perr := neturl.Parse(u.URL) | |
| 106 | + | if perr != nil || parsed.Host == "" { | |
| 107 | + | return "" | |
| 108 | + | } | |
| 109 | + | scheme := parsed.Scheme | |
| 110 | + | if scheme == "" { | |
| 111 | + | scheme = "https" | |
| 112 | + | } | |
| 113 | + | return scheme + "://" + parsed.Host + "/favicon.ico" | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | items := make([]item, 0, len(entries)) | |
| 117 | + | for _, e := range entries { | |
| 118 | + | items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)}) | |
| 119 | + | } | |
| 120 | + | b, _ := json.Marshal(items) | |
| 121 | + | // Update suggestions in page | |
| 122 | + | if h.webView != nil { | |
| 123 | + | _ = h.webView.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")") | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // handleWailsBridge processes Wails runtime bridge calls for homepage | |
| 128 | + | func (h *Handler) handleWailsBridge(msg Message) { | |
| 129 | + | // Payload contains { methodID, methodName?, args } | |
| 130 | + | var p struct { | |
| 131 | + | MethodID uint32 `json:"methodID"` | |
| 132 | + | MethodName string `json:"methodName"` | |
| 133 | + | Args json.RawMessage `json:"args"` | |
| 134 | + | } | |
| 135 | + | if err := json.Unmarshal(msg.Payload, &p); err != nil { | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Only implement the IDs we need | |
| 140 | + | switch p.MethodID { | |
| 141 | + | case constants.HashGetRecentHistory: // BrowserService.GetRecentHistory(limit) | |
| 142 | + | var args []interface{} | |
| 143 | + | _ = json.Unmarshal(p.Args, &args) | |
| 144 | + | limit := 50 | |
| 145 | + | if len(args) > 0 { | |
| 146 | + | if f, ok := args[0].(float64); ok { | |
| 147 | + | limit = int(f) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ctx := context.Background() | |
| 151 | + | entries, err := h.browserService.GetRecentHistory(ctx, limit) | |
| 152 | + | if err != nil { | |
| 153 | + | return | |
| 154 | + | } | |
| 155 | + | resp, _ := json.Marshal(entries) | |
| 156 | + | if h.webView != nil { | |
| 157 | + | _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")") | |
| 158 | + | } | |
| 159 | + | case constants.HashGetSearchShortcuts: // BrowserService.GetSearchShortcuts() | |
| 160 | + | ctx := context.Background() | |
| 161 | + | shortcuts, err := h.browserService.GetSearchShortcuts(ctx) | |
| 162 | + | if err != nil { | |
| 163 | + | return | |
| 164 | + | } | |
| 165 | + | resp, _ := json.Marshal(shortcuts) | |
| 166 | + | if h.webView != nil { | |
| 167 | + | _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")") | |
| 168 | + | } | |
| 169 | + | default: | |
| 170 | + | // Return empty JSON to avoid breaking UI | |
| 171 | + | if h.webView != nil { | |
| 172 | + | _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', '{}')") | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // handleTheme processes theme-related messages | |
| 178 | + | func (h *Handler) handleTheme(msg Message) { | |
| 179 | + | if msg.Value != "" { | |
| 180 | + | log.Printf("[theme] page reported color-scheme: %s", msg.Value) | |
| 181 | + | } | |
| 182 | + | } | |
| @@ -0,0 +1,182 @@ | |||||
| 1 | + | package messaging | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "log" | |||
| 7 | + | neturl "net/url" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/app/constants" | |||
| 10 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 11 | + | "github.com/bnema/dumber/services" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Handler processes script messages from the WebView | |||
| 15 | + | type Handler struct { | |||
| 16 | + | parserService *services.ParserService | |||
| 17 | + | browserService *services.BrowserService | |||
| 18 | + | webView *webkit.WebView | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // Message represents a script message from the WebView | |||
| 22 | + | type Message struct { | |||
| 23 | + | Type string `json:"type"` | |||
| 24 | + | URL string `json:"url"` | |||
| 25 | + | Q string `json:"q"` | |||
| 26 | + | Limit int `json:"limit"` | |||
| 27 | + | Value string `json:"value"` | |||
| 28 | + | // Wails fetch bridge | |||
| 29 | + | ID string `json:"id"` | |||
| 30 | + | Payload json.RawMessage `json:"payload"` | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // NewHandler creates a new message handler | |||
| 34 | + | func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler { | |||
| 35 | + | return &Handler{ | |||
| 36 | + | parserService: parserService, | |||
| 37 | + | browserService: browserService, | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Handle processes incoming script messages | |||
| 42 | + | func (h *Handler) Handle(payload string) { | |||
| 43 | + | var msg Message | |||
| 44 | + | if err := json.Unmarshal([]byte(payload), &msg); err != nil { | |||
| 45 | + | return | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | switch msg.Type { | |||
| 49 | + | case "navigate": | |||
| 50 | + | h.handleNavigation(msg) | |||
| 51 | + | case "query": | |||
| 52 | + | h.handleQuery(msg) | |||
| 53 | + | case "wails": | |||
| 54 | + | h.handleWailsBridge(msg) | |||
| 55 | + | case "theme": | |||
| 56 | + | h.handleTheme(msg) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // SetWebView sets the WebView reference (needed for script injection) | |||
| 61 | + | func (h *Handler) SetWebView(webView *webkit.WebView) { | |||
| 62 | + | h.webView = webView | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // handleNavigation processes navigation requests from the frontend | |||
| 66 | + | func (h *Handler) handleNavigation(msg Message) { | |||
| 67 | + | ctx := context.Background() | |||
| 68 | + | res, err := h.parserService.ParseInput(ctx, msg.URL) | |||
| 69 | + | if err == nil { | |||
| 70 | + | if _, navErr := h.browserService.Navigate(ctx, res.URL); navErr != nil { | |||
| 71 | + | log.Printf("Warning: failed to navigate to %s: %v", res.URL, navErr) | |||
| 72 | + | } | |||
| 73 | + | if h.webView != nil { | |||
| 74 | + | _ = h.webView.LoadURL(res.URL) | |||
| 75 | + | if z, zerr := h.browserService.GetZoomLevel(ctx, res.URL); zerr == nil { | |||
| 76 | + | _ = h.webView.SetZoom(z) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // handleQuery processes history search queries from the frontend | |||
| 83 | + | func (h *Handler) handleQuery(msg Message) { | |||
| 84 | + | ctx := context.Background() | |||
| 85 | + | limit := msg.Limit | |||
| 86 | + | if limit <= 0 || limit > 25 { | |||
| 87 | + | limit = 10 | |||
| 88 | + | } | |||
| 89 | + | entries, err := h.browserService.SearchHistory(ctx, msg.Q, limit) | |||
| 90 | + | if err != nil { | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // Map to lightweight items for frontend | |||
| 95 | + | type item struct { | |||
| 96 | + | URL string `json:"url"` | |||
| 97 | + | Favicon string `json:"favicon"` | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | buildFavicon := func(raw string) string { | |||
| 101 | + | u, err := h.parserService.ParseInput(ctx, raw) | |||
| 102 | + | if err != nil || u.URL == "" { | |||
| 103 | + | return "" | |||
| 104 | + | } | |||
| 105 | + | parsed, perr := neturl.Parse(u.URL) | |||
| 106 | + | if perr != nil || parsed.Host == "" { | |||
| 107 | + | return "" | |||
| 108 | + | } | |||
| 109 | + | scheme := parsed.Scheme | |||
| 110 | + | if scheme == "" { | |||
| 111 | + | scheme = "https" | |||
| 112 | + | } | |||
| 113 | + | return scheme + "://" + parsed.Host + "/favicon.ico" | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | items := make([]item, 0, len(entries)) | |||
| 117 | + | for _, e := range entries { | |||
| 118 | + | items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)}) | |||
| 119 | + | } | |||
| 120 | + | b, _ := json.Marshal(items) | |||
| 121 | + | // Update suggestions in page | |||
| 122 | + | if h.webView != nil { | |||
| 123 | + | _ = h.webView.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")") | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // handleWailsBridge processes Wails runtime bridge calls for homepage | |||
| 128 | + | func (h *Handler) handleWailsBridge(msg Message) { | |||
| 129 | + | // Payload contains { methodID, methodName?, args } | |||
| 130 | + | var p struct { | |||
| 131 | + | MethodID uint32 `json:"methodID"` | |||
| 132 | + | MethodName string `json:"methodName"` | |||
| 133 | + | Args json.RawMessage `json:"args"` | |||
| 134 | + | } | |||
| 135 | + | if err := json.Unmarshal(msg.Payload, &p); err != nil { | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Only implement the IDs we need | |||
| 140 | + | switch p.MethodID { | |||
| 141 | + | case constants.HashGetRecentHistory: // BrowserService.GetRecentHistory(limit) | |||
| 142 | + | var args []interface{} | |||
| 143 | + | _ = json.Unmarshal(p.Args, &args) | |||
| 144 | + | limit := 50 | |||
| 145 | + | if len(args) > 0 { | |||
| 146 | + | if f, ok := args[0].(float64); ok { | |||
| 147 | + | limit = int(f) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ctx := context.Background() | |||
| 151 | + | entries, err := h.browserService.GetRecentHistory(ctx, limit) | |||
| 152 | + | if err != nil { | |||
| 153 | + | return | |||
| 154 | + | } | |||
| 155 | + | resp, _ := json.Marshal(entries) | |||
| 156 | + | if h.webView != nil { | |||
| 157 | + | _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")") | |||
| 158 | + | } | |||
| 159 | + | case constants.HashGetSearchShortcuts: // BrowserService.GetSearchShortcuts() | |||
| 160 | + | ctx := context.Background() | |||
| 161 | + | shortcuts, err := h.browserService.GetSearchShortcuts(ctx) | |||
| 162 | + | if err != nil { | |||
| 163 | + | return | |||
| 164 | + | } | |||
| 165 | + | resp, _ := json.Marshal(shortcuts) | |||
| 166 | + | if h.webView != nil { | |||
| 167 | + | _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")") | |||
| 168 | + | } | |||
| 169 | + | default: | |||
| 170 | + | // Return empty JSON to avoid breaking UI | |||
| 171 | + | if h.webView != nil { | |||
| 172 | + | _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', '{}')") | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // handleTheme processes theme-related messages | |||
| 178 | + | func (h *Handler) handleTheme(msg Message) { | |||
| 179 | + | if msg.Value != "" { | |||
| 180 | + | log.Printf("[theme] page reported color-scheme: %s", msg.Value) | |||
| 181 | + | } | |||
| 182 | + | } | |||
A internal/logging/crash.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package logging | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "os/signal" | |
| 7 | + | "runtime" | |
| 8 | + | "runtime/debug" | |
| 9 | + | "syscall" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // SetupCrashHandler sets up signal handlers to catch crashes and log them | |
| 13 | + | func SetupCrashHandler() { | |
| 14 | + | c := make(chan os.Signal, 1) | |
| 15 | + | ||
| 16 | + | // Listen for crash signals | |
| 17 | + | signal.Notify(c, | |
| 18 | + | syscall.SIGSEGV, // Segmentation violation | |
| 19 | + | syscall.SIGABRT, // Abort signal | |
| 20 | + | syscall.SIGFPE, // Floating point exception | |
| 21 | + | syscall.SIGBUS, // Bus error | |
| 22 | + | syscall.SIGILL, // Illegal instruction | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | go func() { | |
| 26 | + | sig := <-c | |
| 27 | + | handleCrash(sig) | |
| 28 | + | }() | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // SetupPanicRecovery sets up a global panic recovery handler | |
| 32 | + | // This should be called with defer at the start of main functions | |
| 33 | + | func SetupPanicRecovery() { | |
| 34 | + | if r := recover(); r != nil { | |
| 35 | + | logPanic(r) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // handleCrash logs crash information and exits | |
| 40 | + | func handleCrash(sig os.Signal) { | |
| 41 | + | logger := GetLogger() | |
| 42 | + | if logger == nil { | |
| 43 | + | fmt.Fprintf(os.Stderr, "CRASH: Caught signal %v but no logger available\n", sig) | |
| 44 | + | os.Exit(1) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Log the crash | |
| 48 | + | crashMsg := fmt.Sprintf("CRASH: Caught fatal signal %v", sig) | |
| 49 | + | logger.writeLog(FATAL, crashMsg, "CRASH") | |
| 50 | + | ||
| 51 | + | // Log stack trace | |
| 52 | + | stack := debug.Stack() | |
| 53 | + | stackMsg := fmt.Sprintf("Stack trace:\n%s", string(stack)) | |
| 54 | + | logger.writeLog(FATAL, stackMsg, "CRASH") | |
| 55 | + | ||
| 56 | + | // Log system info | |
| 57 | + | sysInfo := fmt.Sprintf("Go version: %s, OS: %s, Arch: %s, NumCPU: %d", | |
| 58 | + | runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.NumCPU()) | |
| 59 | + | logger.writeLog(FATAL, sysInfo, "CRASH") | |
| 60 | + | ||
| 61 | + | // Log memory stats | |
| 62 | + | var m runtime.MemStats | |
| 63 | + | runtime.ReadMemStats(&m) | |
| 64 | + | memInfo := fmt.Sprintf("Memory - Alloc: %d KB, TotalAlloc: %d KB, Sys: %d KB, NumGC: %d", | |
| 65 | + | m.Alloc/1024, m.TotalAlloc/1024, m.Sys/1024, m.NumGC) | |
| 66 | + | logger.writeLog(FATAL, memInfo, "CRASH") | |
| 67 | + | ||
| 68 | + | // Force flush logs | |
| 69 | + | if logger.rotator != nil { | |
| 70 | + | logger.rotator.Close() | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Exit with error code | |
| 74 | + | os.Exit(128 + int(sig.(syscall.Signal))) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // logPanic logs panic information | |
| 78 | + | func logPanic(r any) { | |
| 79 | + | logger := GetLogger() | |
| 80 | + | if logger == nil { | |
| 81 | + | fmt.Fprintf(os.Stderr, "PANIC: %v but no logger available\n", r) | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Log the panic | |
| 86 | + | panicMsg := fmt.Sprintf("PANIC: %v", r) | |
| 87 | + | logger.writeLog(FATAL, panicMsg, "PANIC") | |
| 88 | + | ||
| 89 | + | // Log stack trace | |
| 90 | + | stack := debug.Stack() | |
| 91 | + | stackMsg := fmt.Sprintf("Stack trace:\n%s", string(stack)) | |
| 92 | + | logger.writeLog(FATAL, stackMsg, "PANIC") | |
| 93 | + | ||
| 94 | + | // Re-panic to maintain normal panic behavior | |
| 95 | + | panic(r) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // LogCrashInfo logs information that might be helpful for crash analysis | |
| 99 | + | func LogCrashInfo(context string) { | |
| 100 | + | logger := GetLogger() | |
| 101 | + | if logger == nil { | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | logger.writeLog(DEBUG, fmt.Sprintf("Crash context checkpoint: %s", context), "CRASH_DEBUG") | |
| 106 | + | } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | package logging | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "os/signal" | |||
| 7 | + | "runtime" | |||
| 8 | + | "runtime/debug" | |||
| 9 | + | "syscall" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // SetupCrashHandler sets up signal handlers to catch crashes and log them | |||
| 13 | + | func SetupCrashHandler() { | |||
| 14 | + | c := make(chan os.Signal, 1) | |||
| 15 | + | ||||
| 16 | + | // Listen for crash signals | |||
| 17 | + | signal.Notify(c, | |||
| 18 | + | syscall.SIGSEGV, // Segmentation violation | |||
| 19 | + | syscall.SIGABRT, // Abort signal | |||
| 20 | + | syscall.SIGFPE, // Floating point exception | |||
| 21 | + | syscall.SIGBUS, // Bus error | |||
| 22 | + | syscall.SIGILL, // Illegal instruction | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | go func() { | |||
| 26 | + | sig := <-c | |||
| 27 | + | handleCrash(sig) | |||
| 28 | + | }() | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // SetupPanicRecovery sets up a global panic recovery handler | |||
| 32 | + | // This should be called with defer at the start of main functions | |||
| 33 | + | func SetupPanicRecovery() { | |||
| 34 | + | if r := recover(); r != nil { | |||
| 35 | + | logPanic(r) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // handleCrash logs crash information and exits | |||
| 40 | + | func handleCrash(sig os.Signal) { | |||
| 41 | + | logger := GetLogger() | |||
| 42 | + | if logger == nil { | |||
| 43 | + | fmt.Fprintf(os.Stderr, "CRASH: Caught signal %v but no logger available\n", sig) | |||
| 44 | + | os.Exit(1) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Log the crash | |||
| 48 | + | crashMsg := fmt.Sprintf("CRASH: Caught fatal signal %v", sig) | |||
| 49 | + | logger.writeLog(FATAL, crashMsg, "CRASH") | |||
| 50 | + | ||||
| 51 | + | // Log stack trace | |||
| 52 | + | stack := debug.Stack() | |||
| 53 | + | stackMsg := fmt.Sprintf("Stack trace:\n%s", string(stack)) | |||
| 54 | + | logger.writeLog(FATAL, stackMsg, "CRASH") | |||
| 55 | + | ||||
| 56 | + | // Log system info | |||
| 57 | + | sysInfo := fmt.Sprintf("Go version: %s, OS: %s, Arch: %s, NumCPU: %d", | |||
| 58 | + | runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.NumCPU()) | |||
| 59 | + | logger.writeLog(FATAL, sysInfo, "CRASH") | |||
| 60 | + | ||||
| 61 | + | // Log memory stats | |||
| 62 | + | var m runtime.MemStats | |||
| 63 | + | runtime.ReadMemStats(&m) | |||
| 64 | + | memInfo := fmt.Sprintf("Memory - Alloc: %d KB, TotalAlloc: %d KB, Sys: %d KB, NumGC: %d", | |||
| 65 | + | m.Alloc/1024, m.TotalAlloc/1024, m.Sys/1024, m.NumGC) | |||
| 66 | + | logger.writeLog(FATAL, memInfo, "CRASH") | |||
| 67 | + | ||||
| 68 | + | // Force flush logs | |||
| 69 | + | if logger.rotator != nil { | |||
| 70 | + | logger.rotator.Close() | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Exit with error code | |||
| 74 | + | os.Exit(128 + int(sig.(syscall.Signal))) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // logPanic logs panic information | |||
| 78 | + | func logPanic(r any) { | |||
| 79 | + | logger := GetLogger() | |||
| 80 | + | if logger == nil { | |||
| 81 | + | fmt.Fprintf(os.Stderr, "PANIC: %v but no logger available\n", r) | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Log the panic | |||
| 86 | + | panicMsg := fmt.Sprintf("PANIC: %v", r) | |||
| 87 | + | logger.writeLog(FATAL, panicMsg, "PANIC") | |||
| 88 | + | ||||
| 89 | + | // Log stack trace | |||
| 90 | + | stack := debug.Stack() | |||
| 91 | + | stackMsg := fmt.Sprintf("Stack trace:\n%s", string(stack)) | |||
| 92 | + | logger.writeLog(FATAL, stackMsg, "PANIC") | |||
| 93 | + | ||||
| 94 | + | // Re-panic to maintain normal panic behavior | |||
| 95 | + | panic(r) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // LogCrashInfo logs information that might be helpful for crash analysis | |||
| 99 | + | func LogCrashInfo(context string) { | |||
| 100 | + | logger := GetLogger() | |||
| 101 | + | if logger == nil { | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | logger.writeLog(DEBUG, fmt.Sprintf("Crash context checkpoint: %s", context), "CRASH_DEBUG") | |||
| 106 | + | } | |||