v0.20.0 c741a39

scheme_handler.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"embed"
6	"encoding/json"
7	"fmt"
8	"io/fs"
9	"mime"
10	"net/http"
11	"net/url"
12	"path/filepath"
13	"strings"
14	"sync"
15
16	"github.com/bnema/dumber/internal/infrastructure/config"
17	"github.com/bnema/dumber/internal/logging"
18	"github.com/bnema/puregotk-webkit/soup"
19	"github.com/bnema/puregotk-webkit/webkit"
20	"github.com/jwijenbergh/puregotk/v4/gio"
21	"github.com/rs/zerolog"
22)
23
24// Scheme path constants
25const (
26	HomePath   = "home"
27	ConfigPath = "config"
28	ErrorPath  = "error"
29	IndexHTML  = "index.html"
30)
31
32// SchemeRequest represents a request to a custom URI scheme.
33type SchemeRequest struct {
34	inner  *webkit.URISchemeRequest
35	URI    string
36	Path   string
37	Method string
38	Scheme string
39}
40
41// SchemeResponse represents a response to a scheme request.
42type SchemeResponse struct {
43	Data        []byte
44	ContentType string
45	StatusCode  int
46}
47
48// PageHandler generates content for a specific page path.
49type PageHandler interface {
50	Handle(req *SchemeRequest) *SchemeResponse
51}
52
53// PageHandlerFunc is an adapter to allow use of ordinary functions as PageHandlers.
54type PageHandlerFunc func(req *SchemeRequest) *SchemeResponse
55
56func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
57	return f(req)
58}
59
60// DumbSchemeHandler handles dumb:// URI scheme requests.
61type DumbSchemeHandler struct {
62	handlers map[string]PageHandler
63	assets   embed.FS
64	assetDir string // subdirectory within embed.FS (e.g., "assets/webui")
65	logger   zerolog.Logger
66	mu       sync.RWMutex
67}
68
69type configAppearancePayload struct {
70	SansFont        string              `json:"sans_font"`
71	SerifFont       string              `json:"serif_font"`
72	MonospaceFont   string              `json:"monospace_font"`
73	DefaultFontSize int                 `json:"default_font_size"`
74	ColorScheme     string              `json:"color_scheme"`
75	LightPalette    config.ColorPalette `json:"light_palette"`
76	DarkPalette     config.ColorPalette `json:"dark_palette"`
77}
78
79type configPayload struct {
80	Appearance          configAppearancePayload          `json:"appearance"`
81	DefaultUIScale      float64                          `json:"default_ui_scale"`
82	DefaultSearchEngine string                           `json:"default_search_engine"`
83	SearchShortcuts     map[string]config.SearchShortcut `json:"search_shortcuts"`
84}
85
86// NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
87func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
88	log := logging.FromContext(ctx)
89
90	h := &DumbSchemeHandler{
91		handlers: make(map[string]PageHandler),
92		assetDir: "webui",
93		logger:   log.With().Str("component", "scheme-handler").Logger(),
94	}
95
96	// Register default pages
97	h.registerDefaults()
98
99	return h
100}
101
102// SetAssets sets the embedded filesystem containing webui assets.
103func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
104	h.mu.Lock()
105	defer h.mu.Unlock()
106	h.assets = assets
107	h.logger.Debug().Msg("assets filesystem configured")
108}
109
110// registerDefaults sets up default page handlers.
111func (h *DumbSchemeHandler) registerDefaults() {
112	// Error page (static fallback)
113	h.RegisterPage("/error", PageHandlerFunc(func(_ *SchemeRequest) *SchemeResponse {
114		return &SchemeResponse{
115			Data:        []byte(errorPageHTML),
116			ContentType: "text/html",
117			StatusCode:  http.StatusOK,
118		}
119	}))
120
121	// API: Get current config (used by dumb://config)
122	h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
123		if req.Method != "GET" {
124			return nil
125		}
126
127		return buildConfigResponse(config.Get())
128	}))
129
130	// API: Get default config (used by Reset Defaults in dumb://config)
131	h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
132		if req.Method != "GET" {
133			return nil
134		}
135
136		return buildConfigResponse(config.DefaultConfig())
137	}))
138}
139
140func buildConfigResponse(cfg *config.Config) *SchemeResponse {
141	resp := configPayload{
142		DefaultUIScale:      cfg.DefaultUIScale,
143		DefaultSearchEngine: cfg.DefaultSearchEngine,
144		SearchShortcuts:     cfg.SearchShortcuts,
145		Appearance: configAppearancePayload{
146			SansFont:        cfg.Appearance.SansFont,
147			SerifFont:       cfg.Appearance.SerifFont,
148			MonospaceFont:   cfg.Appearance.MonospaceFont,
149			DefaultFontSize: cfg.Appearance.DefaultFontSize,
150			ColorScheme:     cfg.Appearance.ColorScheme,
151			LightPalette:    cfg.Appearance.LightPalette,
152			DarkPalette:     cfg.Appearance.DarkPalette,
153		},
154	}
155
156	data, err := json.Marshal(resp)
157	if err != nil {
158		return &SchemeResponse{
159			Data:        []byte(fmt.Sprintf(`{"error": %q}`, err)),
160			ContentType: "application/json",
161			StatusCode:  http.StatusInternalServerError,
162		}
163	}
164
165	return &SchemeResponse{
166		Data:        data,
167		ContentType: "application/json",
168		StatusCode:  http.StatusOK,
169	}
170}
171
172// RegisterPage registers a handler for a specific path.
173func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
174	h.mu.Lock()
175	defer h.mu.Unlock()
176	h.handlers[path] = handler
177	h.logger.Debug().Str("path", path).Msg("registered page handler")
178}
179
180// HandleRequest processes a scheme request and sends the response.
181func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
182	req := webkit.URISchemeRequestNewFromInternalPtr(reqPtr)
183	if req == nil {
184		return
185	}
186
187	uri := req.GetUri()
188	schemeReq := &SchemeRequest{
189		inner:  req,
190		URI:    uri,
191		Path:   req.GetPath(),
192		Method: req.GetHttpMethod(),
193		Scheme: req.GetScheme(),
194	}
195
196	h.logger.Debug().
197		Str("uri", schemeReq.URI).
198		Str("path", schemeReq.Path).
199		Str("method", schemeReq.Method).
200		Msg("handling scheme request")
201
202	// Parse the URI to extract host and path
203	u, err := url.Parse(uri)
204	if err != nil || u.Scheme != "dumb" {
205		h.logger.Error().Err(err).Str("uri", uri).Msg("invalid URI")
206		h.sendResponse(req, &SchemeResponse{
207			Data:        []byte("Invalid URI"),
208			ContentType: "text/plain",
209			StatusCode:  http.StatusBadRequest,
210		})
211		return
212	}
213
214	// API endpoints should never be treated as static assets
215	if strings.HasPrefix(schemeReq.Path, "/api/") {
216		h.mu.RLock()
217		handler, ok := h.handlers[schemeReq.Path]
218		if !ok {
219			handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
220		}
221		h.mu.RUnlock()
222		if ok {
223			response := handler.Handle(schemeReq)
224			h.sendResponse(req, response)
225			return
226		}
227	}
228
229	// Try to serve from embedded assets
230	if response := h.handleAsset(u); response != nil {
231		h.sendResponse(req, response)
232		return
233	}
234
235	// Fall back to registered handlers
236	h.mu.RLock()
237	handler, ok := h.handlers[schemeReq.Path]
238	if !ok {
239		handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
240	}
241	h.mu.RUnlock()
242
243	var response *SchemeResponse
244	if ok {
245		response = handler.Handle(schemeReq)
246	} else {
247		response = &SchemeResponse{
248			Data:        []byte(notFoundHTML),
249			ContentType: "text/html",
250			StatusCode:  http.StatusNotFound,
251		}
252	}
253
254	h.sendResponse(req, response)
255}
256
257// handleAsset serves static assets from the embedded filesystem.
258// Returns nil if no asset was found (allowing fallback to registered handlers).
259func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
260	h.mu.RLock()
261	hasAssets := h.assets != (embed.FS{})
262	assetDir := h.assetDir
263	h.mu.RUnlock()
264
265	if !hasAssets {
266		return nil
267	}
268
269	// Determine the target file based on host and path
270	host := u.Host
271	path := strings.TrimPrefix(u.Path, "/")
272
273	var relPath string
274	switch {
275	// Home root maps to index.html.
276	case host == HomePath && (path == "" || path == "/"):
277		relPath = IndexHTML
278	// Home asset paths map directly to assets.
279	case host == HomePath && path != "":
280		relPath = path
281	// Config root maps to config.html.
282	case host == ConfigPath && (path == "" || path == "/"):
283		relPath = "config.html"
284	// Config asset paths map directly to assets.
285	case host == ConfigPath && path != "":
286		relPath = path
287	// Error root maps to error.html.
288	case host == ErrorPath && (path == "" || path == "/"):
289		relPath = "error.html"
290	// Error asset paths map directly to assets.
291	case host == ErrorPath && path != "":
292		relPath = path
293	// Opaque home form maps to index.html.
294	case u.Opaque == HomePath:
295		relPath = IndexHTML
296	// Opaque error form maps to error.html.
297	case u.Opaque == ErrorPath:
298		relPath = "error.html"
299	default:
300		// Not a recognized asset path
301		return nil
302	}
303
304	// Read the asset from embedded FS
305	fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
306	data, err := fs.ReadFile(h.assets, fullPath)
307	if err != nil {
308		h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
309		return nil
310	}
311
312	contentType := h.getMimeType(relPath)
313	h.logger.Debug().
314		Str("path", fullPath).
315		Str("content_type", contentType).
316		Int("size", len(data)).
317		Msg("serving asset")
318
319	return &SchemeResponse{
320		Data:        data,
321		ContentType: contentType,
322		StatusCode:  http.StatusOK,
323	}
324}
325
326// getMimeType determines the MIME type for a given file path.
327func (h *DumbSchemeHandler) getMimeType(filename string) string {
328	if h == nil {
329		return "application/octet-stream"
330	}
331	ext := strings.ToLower(filepath.Ext(filename))
332
333	// Try standard mime type first
334	mt := mime.TypeByExtension(ext)
335	if mt != "" {
336		return mt
337	}
338
339	// Fallbacks for common web assets
340	switch ext {
341	case ".js", ".mjs":
342		return "application/javascript"
343	case ".css":
344		return "text/css"
345	case ".svg":
346		return "image/svg+xml"
347	case ".ico":
348		return "image/x-icon"
349	case ".woff":
350		return "font/woff"
351	case ".woff2":
352		return "font/woff2"
353	case ".ttf":
354		return "font/ttf"
355	case ".otf":
356		return "font/otf"
357	case ".png":
358		return "image/png"
359	case ".jpg", ".jpeg":
360		return "image/jpeg"
361	case ".gif":
362		return "image/gif"
363	case ".webp":
364		return "image/webp"
365	case ".json":
366		return "application/json"
367	case ".xml":
368		return "application/xml"
369	case ".html", ".htm":
370		return "text/html; charset=utf-8"
371	default:
372		return "text/plain"
373	}
374}
375
376// sendResponse sends the response back to WebKit.
377func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
378	if response == nil {
379		response = &SchemeResponse{
380			Data:        []byte("Internal error"),
381			ContentType: "text/plain",
382			StatusCode:  http.StatusInternalServerError,
383		}
384	}
385
386	contentType := response.ContentType
387	if contentType == "" {
388		contentType = "text/html"
389	}
390
391	// Create MemoryInputStream from data directly
392	stream := gio.NewMemoryInputStreamFromData(response.Data, len(response.Data), nil)
393	if stream == nil {
394		h.logger.Error().Msg("failed to create MemoryInputStream for response")
395		return
396	}
397
398	// Create response object for more control
399	schemeResp := webkit.NewURISchemeResponse(&stream.InputStream, int64(len(response.Data)))
400	if schemeResp == nil {
401		h.logger.Error().Msg("failed to create URISchemeResponse")
402		return
403	}
404	schemeResp.SetContentType(contentType)
405	schemeResp.SetStatus(uint(response.StatusCode), nil)
406
407	// WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
408	// We only add CORS headers for our internal API endpoints.
409	if strings.HasPrefix(req.GetPath(), "/api/") {
410		hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
411		hdrs.Append("Access-Control-Allow-Origin", "*")
412		hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
413		hdrs.Append("Access-Control-Allow-Headers", "Content-Type")
414		hdrs.Append("Access-Control-Max-Age", "86400")
415		schemeResp.SetHttpHeaders(hdrs)
416	}
417
418	req.FinishWithResponse(schemeResp)
419}
420
421// RegisterWithContext registers the dumb:// scheme with a WebKitContext.
422func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
423	if wkCtx == nil || wkCtx.Context() == nil {
424		h.logger.Error().Msg("cannot register scheme: context is nil")
425		return
426	}
427
428	callback := webkit.URISchemeRequestCallback(func(reqPtr, _ uintptr) {
429		h.HandleRequest(reqPtr)
430	})
431
432	wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
433
434	// Mark scheme as local, secure, and CORS-enabled for proper security policies
435	secMgr := wkCtx.Context().GetSecurityManager()
436	if secMgr != nil {
437		secMgr.RegisterUriSchemeAsLocal("dumb")
438		secMgr.RegisterUriSchemeAsSecure("dumb")
439		secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
440		h.logger.Debug().Msg("dumb:// scheme registered as local, secure, and CORS-enabled")
441	}
442
443	h.logger.Info().Msg("dumb:// scheme registered")
444}
445
446// Default page templates (fallback when assets not available)
447
448const errorPageHTML = `<!DOCTYPE html>
449<html>
450<head>
451    <meta charset="utf-8">
452    <title>Error</title>
453    <style>
454        body {
455            font-family: system-ui, -apple-system, sans-serif;
456            background: #1a1a2e;
457            color: #eee;
458            display: flex;
459            align-items: center;
460            justify-content: center;
461            height: 100vh;
462            margin: 0;
463        }
464        .container {
465            text-align: center;
466        }
467        h1 { color: #e74c3c; }
468        p { color: #888; }
469    </style>
470</head>
471<body>
472    <div class="container">
473        <h1>Error</h1>
474        <p>The page could not be loaded.</p>
475    </div>
476</body>
477</html>`
478
479const notFoundHTML = `<!DOCTYPE html>
480<html>
481<head>
482    <meta charset="utf-8">
483    <title>Not Found</title>
484    <style>
485        body {
486            font-family: system-ui, -apple-system, sans-serif;
487            background: #1a1a2e;
488            color: #eee;
489            display: flex;
490            align-items: center;
491            justify-content: center;
492            height: 100vh;
493            margin: 0;
494        }
495        .container {
496            text-align: center;
497        }
498        h1 { color: #f39c12; }
499        p { color: #888; }
500    </style>
501</head>
502<body>
503    <div class="container">
504        <h1>404</h1>
505        <p>Page not found.</p>
506    </div>
507</body>
508</html>`

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close