fix(scheme-handler): serve embedded webui assets with CORS support
- commit
- bf08946eef2a07647841d045975ad5e0d4d46e17
- author
- brice · · 2025-12-16 20:33 UTC
- committer
- brice · · 2025-12-16 20:27 UTC
- parent
- 56a05c0
- signature
- unsigned
4 files · +187 -86
M assets/webui_embed.go +5 -13
| @@ -1,17 +1,9 @@ | |||
| 1 | 1 | package assets | |
| 2 | 2 | ||
| 3 | - | import _ "embed" | |
| 3 | + | import "embed" | |
| 4 | 4 | ||
| 5 | - | // ColorSchemeScript handles dark mode detection in web pages. | |
| 6 | - | // It patches window.matchMedia for prefers-color-scheme queries | |
| 7 | - | // based on the __dumber_gtk_prefers_dark flag injected by Go. | |
| 5 | + | // WebUIAssets contains the embedded webui build output (homepage and blocked pages). | |
| 6 | + | // Used by the scheme handler to serve dumb://home and dumb://blocked. | |
| 8 | 7 | // | |
| 9 | - | //go:embed webui/color-scheme.js | |
| 10 | - | var ColorSchemeScript string | |
| 11 | - | ||
| 12 | - | // Deprecated: These variables are kept for backwards compatibility during migration. | |
| 13 | - | // They will be removed once all JS handlers are deprecated. | |
| 14 | - | // The actual files may not exist - check before using. | |
| 15 | - | var MainWorldScript string | |
| 16 | - | var GUIScript string | |
| 17 | - | var ComponentStyles string | |
| 8 | + | //go:embed webui/* | |
| 9 | + | var WebUIAssets embed.FS | |
| @@ -1,17 +1,9 @@ | |||||
| 1 | package assets | 1 | package assets | ||
| 2 | 2 | ||||
| 3 | - | import _ "embed" | 3 | + | import "embed" |
| 4 | 4 | ||||
| 5 | - | // ColorSchemeScript handles dark mode detection in web pages. | 5 | + | // WebUIAssets contains the embedded webui build output (homepage and blocked pages). |
| 6 | - | // It patches window.matchMedia for prefers-color-scheme queries | 6 | + | // Used by the scheme handler to serve dumb://home and dumb://blocked. |
| 7 | - | // based on the __dumber_gtk_prefers_dark flag injected by Go. | |||
| 8 | // | 7 | // | ||
| 9 | - | //go:embed webui/color-scheme.js | 8 | + | //go:embed webui/* |
| 10 | - | var ColorSchemeScript string | 9 | + | var WebUIAssets embed.FS |
| 11 | - | ||||
| 12 | - | // Deprecated: These variables are kept for backwards compatibility during migration. | |||
| 13 | - | // They will be removed once all JS handlers are deprecated. | |||
| 14 | - | // The actual files may not exist - check before using. | |||
| 15 | - | var MainWorldScript string | |||
| 16 | - | var GUIScript string | |||
| 17 | - | var ComponentStyles string | |||
M cmd/dumber/main.go +7 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "runtime" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/dumber/assets" | |
| 10 | 11 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 12 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 12 | 13 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| @@ -83,6 +84,12 @@ func main() { | |||
| 83 | 84 | if err != nil { | |
| 84 | 85 | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | |
| 85 | 86 | } | |
| 87 | + | ||
| 88 | + | // Register dumb:// scheme handler for serving embedded webui | |
| 89 | + | schemeHandler := webkit.NewDumbSchemeHandler(ctx) | |
| 90 | + | schemeHandler.SetAssets(assets.WebUIAssets) | |
| 91 | + | schemeHandler.RegisterWithContext(wkCtx) | |
| 92 | + | ||
| 86 | 93 | settings := webkit.NewSettingsManager(ctx, cfg) | |
| 87 | 94 | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | |
| 88 | 95 | messageRouter := webkit.NewMessageRouter(ctx) | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | "runtime" | 8 | "runtime" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/bnema/dumber/assets" | |||
| 10 | "github.com/bnema/dumber/internal/application/usecase" | 11 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 11 | "github.com/bnema/dumber/internal/infrastructure/config" | 12 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 12 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 13 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| @@ -83,6 +84,12 @@ func main() { | |||||
| 83 | if err != nil { | 84 | if err != nil { | ||
| 84 | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | 85 | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | ||
| 85 | } | 86 | } | ||
| 87 | + | ||||
| 88 | + | // Register dumb:// scheme handler for serving embedded webui | |||
| 89 | + | schemeHandler := webkit.NewDumbSchemeHandler(ctx) | |||
| 90 | + | schemeHandler.SetAssets(assets.WebUIAssets) | |||
| 91 | + | schemeHandler.RegisterWithContext(wkCtx) | |||
| 92 | + | ||||
| 86 | settings := webkit.NewSettingsManager(ctx, cfg) | 93 | settings := webkit.NewSettingsManager(ctx, cfg) | ||
| 87 | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | 94 | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | ||
| 88 | messageRouter := webkit.NewMessageRouter(ctx) | 95 | messageRouter := webkit.NewMessageRouter(ctx) | ||
M internal/infrastructure/webkit/scheme_handler.go +172 -70
| @@ -2,17 +2,28 @@ package webkit | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "embed" | |
| 6 | + | "io/fs" | |
| 7 | + | "mime" | |
| 5 | 8 | "net/http" | |
| 9 | + | "net/url" | |
| 10 | + | "path/filepath" | |
| 6 | 11 | "strings" | |
| 7 | 12 | "sync" | |
| 8 | 13 | ||
| 9 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 15 | "github.com/bnema/puregotk-webkit/webkit" | |
| 11 | 16 | "github.com/jwijenbergh/puregotk/v4/gio" | |
| 12 | - | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 13 | 17 | "github.com/rs/zerolog" | |
| 14 | 18 | ) | |
| 15 | 19 | ||
| 20 | + | // Scheme path constants | |
| 21 | + | const ( | |
| 22 | + | HomePath = "home" | |
| 23 | + | BlockedPath = "blocked" | |
| 24 | + | IndexHTML = "index.html" | |
| 25 | + | ) | |
| 26 | + | ||
| 16 | 27 | // SchemeRequest represents a request to a custom URI scheme. | |
| 17 | 28 | type SchemeRequest struct { | |
| 18 | 29 | inner *webkit.URISchemeRequest | |
| @@ -44,6 +55,8 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||
| 44 | 55 | // DumbSchemeHandler handles dumb:// URI scheme requests. | |
| 45 | 56 | type DumbSchemeHandler struct { | |
| 46 | 57 | handlers map[string]PageHandler | |
| 58 | + | assets embed.FS | |
| 59 | + | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") | |
| 47 | 60 | logger zerolog.Logger | |
| 48 | 61 | mu sync.RWMutex | |
| 49 | 62 | } | |
| @@ -54,6 +67,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||
| 54 | 67 | ||
| 55 | 68 | h := &DumbSchemeHandler{ | |
| 56 | 69 | handlers: make(map[string]PageHandler), | |
| 70 | + | assetDir: "webui", | |
| 57 | 71 | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 58 | 72 | } | |
| 59 | 73 | ||
| @@ -63,27 +77,17 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||
| 63 | 77 | return h | |
| 64 | 78 | } | |
| 65 | 79 | ||
| 80 | + | // SetAssets sets the embedded filesystem containing webui assets. | |
| 81 | + | func (h *DumbSchemeHandler) SetAssets(assets embed.FS) { | |
| 82 | + | h.mu.Lock() | |
| 83 | + | defer h.mu.Unlock() | |
| 84 | + | h.assets = assets | |
| 85 | + | h.logger.Debug().Msg("assets filesystem configured") | |
| 86 | + | } | |
| 87 | + | ||
| 66 | 88 | // registerDefaults sets up default page handlers. | |
| 67 | 89 | func (h *DumbSchemeHandler) registerDefaults() { | |
| 68 | - | // New tab page | |
| 69 | - | h.RegisterPage("/newtab", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |
| 70 | - | return &SchemeResponse{ | |
| 71 | - | Data: []byte(newTabHTML), | |
| 72 | - | ContentType: "text/html", | |
| 73 | - | StatusCode: http.StatusOK, | |
| 74 | - | } | |
| 75 | - | })) | |
| 76 | - | ||
| 77 | - | // Homepage (alias for newtab) | |
| 78 | - | h.RegisterPage("/", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |
| 79 | - | return &SchemeResponse{ | |
| 80 | - | Data: []byte(newTabHTML), | |
| 81 | - | ContentType: "text/html", | |
| 82 | - | StatusCode: http.StatusOK, | |
| 83 | - | } | |
| 84 | - | })) | |
| 85 | - | ||
| 86 | - | // Error page | |
| 90 | + | // Error page (static fallback) | |
| 87 | 91 | h.RegisterPage("/error", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |
| 88 | 92 | return &SchemeResponse{ | |
| 89 | 93 | Data: []byte(errorPageHTML), | |
| @@ -108,9 +112,10 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||
| 108 | 112 | return | |
| 109 | 113 | } | |
| 110 | 114 | ||
| 115 | + | uri := req.GetUri() | |
| 111 | 116 | schemeReq := &SchemeRequest{ | |
| 112 | 117 | inner: req, | |
| 113 | - | URI: req.GetUri(), | |
| 118 | + | URI: uri, | |
| 114 | 119 | Path: req.GetPath(), | |
| 115 | 120 | Method: req.GetHttpMethod(), | |
| 116 | 121 | Scheme: req.GetScheme(), | |
| @@ -122,11 +127,28 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||
| 122 | 127 | Str("method", schemeReq.Method). | |
| 123 | 128 | Msg("handling scheme request") | |
| 124 | 129 | ||
| 125 | - | // Look up handler | |
| 130 | + | // Parse the URI to extract host and path | |
| 131 | + | u, err := url.Parse(uri) | |
| 132 | + | if err != nil || u.Scheme != "dumb" { | |
| 133 | + | h.logger.Error().Err(err).Str("uri", uri).Msg("invalid URI") | |
| 134 | + | h.sendResponse(req, &SchemeResponse{ | |
| 135 | + | Data: []byte("Invalid URI"), | |
| 136 | + | ContentType: "text/plain", | |
| 137 | + | StatusCode: http.StatusBadRequest, | |
| 138 | + | }) | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Try to serve from embedded assets first | |
| 143 | + | if response := h.handleAsset(u); response != nil { | |
| 144 | + | h.sendResponse(req, response) | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Fall back to registered handlers | |
| 126 | 149 | h.mu.RLock() | |
| 127 | 150 | handler, ok := h.handlers[schemeReq.Path] | |
| 128 | 151 | if !ok { | |
| 129 | - | // Try without leading slash | |
| 130 | 152 | handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")] | |
| 131 | 153 | } | |
| 132 | 154 | h.mu.RUnlock() | |
| @@ -142,10 +164,119 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||
| 142 | 164 | } | |
| 143 | 165 | } | |
| 144 | 166 | ||
| 145 | - | // Send response | |
| 146 | 167 | h.sendResponse(req, response) | |
| 147 | 168 | } | |
| 148 | 169 | ||
| 170 | + | // handleAsset serves static assets from the embedded filesystem. | |
| 171 | + | // Returns nil if no asset was found (allowing fallback to registered handlers). | |
| 172 | + | func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |
| 173 | + | h.mu.RLock() | |
| 174 | + | hasAssets := h.assets != (embed.FS{}) | |
| 175 | + | assetDir := h.assetDir | |
| 176 | + | h.mu.RUnlock() | |
| 177 | + | ||
| 178 | + | if !hasAssets { | |
| 179 | + | return nil | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // Determine the target file based on host and path | |
| 183 | + | host := u.Host | |
| 184 | + | path := strings.TrimPrefix(u.Path, "/") | |
| 185 | + | ||
| 186 | + | var relPath string | |
| 187 | + | switch { | |
| 188 | + | // dumb://home or dumb://home/ → index.html | |
| 189 | + | case host == HomePath && (path == "" || path == "/"): | |
| 190 | + | relPath = IndexHTML | |
| 191 | + | // dumb://home/<asset> → serve asset | |
| 192 | + | case host == HomePath && path != "": | |
| 193 | + | relPath = path | |
| 194 | + | // dumb://blocked or dumb://blocked/ → blocked.html | |
| 195 | + | case host == BlockedPath && (path == "" || path == "/"): | |
| 196 | + | relPath = "blocked.html" | |
| 197 | + | // dumb://blocked/<asset> → serve asset | |
| 198 | + | case host == BlockedPath && path != "": | |
| 199 | + | relPath = path | |
| 200 | + | // dumb:home (opaque form) → index.html | |
| 201 | + | case u.Opaque == HomePath: | |
| 202 | + | relPath = IndexHTML | |
| 203 | + | // dumb:blocked (opaque form) → blocked.html | |
| 204 | + | case u.Opaque == BlockedPath: | |
| 205 | + | relPath = "blocked.html" | |
| 206 | + | default: | |
| 207 | + | // Not a recognized asset path | |
| 208 | + | return nil | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // Read the asset from embedded FS | |
| 212 | + | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | |
| 213 | + | data, err := fs.ReadFile(h.assets, fullPath) | |
| 214 | + | if err != nil { | |
| 215 | + | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | |
| 216 | + | return nil | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | contentType := h.getMimeType(relPath) | |
| 220 | + | h.logger.Debug(). | |
| 221 | + | Str("path", fullPath). | |
| 222 | + | Str("content_type", contentType). | |
| 223 | + | Int("size", len(data)). | |
| 224 | + | Msg("serving asset") | |
| 225 | + | ||
| 226 | + | return &SchemeResponse{ | |
| 227 | + | Data: data, | |
| 228 | + | ContentType: contentType, | |
| 229 | + | StatusCode: http.StatusOK, | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // getMimeType determines the MIME type for a given file path. | |
| 234 | + | func (h *DumbSchemeHandler) getMimeType(filename string) string { | |
| 235 | + | ext := strings.ToLower(filepath.Ext(filename)) | |
| 236 | + | ||
| 237 | + | // Try standard mime type first | |
| 238 | + | mt := mime.TypeByExtension(ext) | |
| 239 | + | if mt != "" { | |
| 240 | + | return mt | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Fallbacks for common web assets | |
| 244 | + | switch ext { | |
| 245 | + | case ".js", ".mjs": | |
| 246 | + | return "application/javascript" | |
| 247 | + | case ".css": | |
| 248 | + | return "text/css" | |
| 249 | + | case ".svg": | |
| 250 | + | return "image/svg+xml" | |
| 251 | + | case ".ico": | |
| 252 | + | return "image/x-icon" | |
| 253 | + | case ".woff": | |
| 254 | + | return "font/woff" | |
| 255 | + | case ".woff2": | |
| 256 | + | return "font/woff2" | |
| 257 | + | case ".ttf": | |
| 258 | + | return "font/ttf" | |
| 259 | + | case ".otf": | |
| 260 | + | return "font/otf" | |
| 261 | + | case ".png": | |
| 262 | + | return "image/png" | |
| 263 | + | case ".jpg", ".jpeg": | |
| 264 | + | return "image/jpeg" | |
| 265 | + | case ".gif": | |
| 266 | + | return "image/gif" | |
| 267 | + | case ".webp": | |
| 268 | + | return "image/webp" | |
| 269 | + | case ".json": | |
| 270 | + | return "application/json" | |
| 271 | + | case ".xml": | |
| 272 | + | return "application/xml" | |
| 273 | + | case ".html", ".htm": | |
| 274 | + | return "text/html; charset=utf-8" | |
| 275 | + | default: | |
| 276 | + | return "text/plain" | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 149 | 280 | // sendResponse sends the response back to WebKit. | |
| 150 | 281 | func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) { | |
| 151 | 282 | if response == nil { | |
| @@ -156,27 +287,28 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||
| 156 | 287 | } | |
| 157 | 288 | } | |
| 158 | 289 | ||
| 159 | - | // Create GBytes from response data | |
| 160 | - | gbytes := glib.NewBytes(response.Data, uint(len(response.Data))) | |
| 161 | - | if gbytes == nil { | |
| 162 | - | h.logger.Error().Msg("failed to create GBytes for response") | |
| 163 | - | return | |
| 290 | + | contentType := response.ContentType | |
| 291 | + | if contentType == "" { | |
| 292 | + | contentType = "text/html" | |
| 164 | 293 | } | |
| 165 | 294 | ||
| 166 | - | // Create MemoryInputStream from GBytes | |
| 167 | - | stream := gio.NewMemoryInputStreamFromBytes(gbytes) | |
| 295 | + | // Create MemoryInputStream from data directly | |
| 296 | + | stream := gio.NewMemoryInputStreamFromData(response.Data, len(response.Data), nil) | |
| 168 | 297 | if stream == nil { | |
| 169 | 298 | h.logger.Error().Msg("failed to create MemoryInputStream for response") | |
| 170 | 299 | return | |
| 171 | 300 | } | |
| 172 | 301 | ||
| 173 | - | // Finish the request with the stream | |
| 174 | - | contentType := response.ContentType | |
| 175 | - | if contentType == "" { | |
| 176 | - | contentType = "text/html" | |
| 302 | + | // Create response object for more control | |
| 303 | + | schemeResp := webkit.NewURISchemeResponse(&stream.InputStream, int64(len(response.Data))) | |
| 304 | + | if schemeResp == nil { | |
| 305 | + | h.logger.Error().Msg("failed to create URISchemeResponse") | |
| 306 | + | return | |
| 177 | 307 | } | |
| 308 | + | schemeResp.SetContentType(contentType) | |
| 309 | + | schemeResp.SetStatus(uint(response.StatusCode), nil) | |
| 178 | 310 | ||
| 179 | - | req.Finish(&stream.InputStream, int64(len(response.Data)), &contentType) | |
| 311 | + | req.FinishWithResponse(schemeResp) | |
| 180 | 312 | } | |
| 181 | 313 | ||
| 182 | 314 | // RegisterWithContext registers the dumb:// scheme with a WebKitContext. | |
| @@ -192,49 +324,19 @@ func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) { | |||
| 192 | 324 | ||
| 193 | 325 | wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil) | |
| 194 | 326 | ||
| 195 | - | // Mark scheme as local and secure for proper security policies | |
| 327 | + | // Mark scheme as local, secure, and CORS-enabled for proper security policies | |
| 196 | 328 | secMgr := wkCtx.Context().GetSecurityManager() | |
| 197 | 329 | if secMgr != nil { | |
| 198 | 330 | secMgr.RegisterUriSchemeAsLocal("dumb") | |
| 199 | 331 | secMgr.RegisterUriSchemeAsSecure("dumb") | |
| 332 | + | secMgr.RegisterUriSchemeAsCorsEnabled("dumb") | |
| 333 | + | h.logger.Debug().Msg("dumb:// scheme registered as local, secure, and CORS-enabled") | |
| 200 | 334 | } | |
| 201 | 335 | ||
| 202 | 336 | h.logger.Info().Msg("dumb:// scheme registered") | |
| 203 | 337 | } | |
| 204 | 338 | ||
| 205 | - | // Default page templates | |
| 206 | - | ||
| 207 | - | const newTabHTML = `<!DOCTYPE html> | |
| 208 | - | <html> | |
| 209 | - | <head> | |
| 210 | - | <meta charset="utf-8"> | |
| 211 | - | <title>New Tab</title> | |
| 212 | - | <style> | |
| 213 | - | body { | |
| 214 | - | font-family: system-ui, -apple-system, sans-serif; | |
| 215 | - | background: #1a1a2e; | |
| 216 | - | color: #eee; | |
| 217 | - | display: flex; | |
| 218 | - | align-items: center; | |
| 219 | - | justify-content: center; | |
| 220 | - | height: 100vh; | |
| 221 | - | margin: 0; | |
| 222 | - | } | |
| 223 | - | .container { | |
| 224 | - | text-align: center; | |
| 225 | - | } | |
| 226 | - | h1 { | |
| 227 | - | font-weight: 300; | |
| 228 | - | color: #888; | |
| 229 | - | } | |
| 230 | - | </style> | |
| 231 | - | </head> | |
| 232 | - | <body> | |
| 233 | - | <div class="container"> | |
| 234 | - | <h1>dumber</h1> | |
| 235 | - | </div> | |
| 236 | - | </body> | |
| 237 | - | </html>` | |
| 339 | + | // Default page templates (fallback when assets not available) | |
| 238 | 340 | ||
| 239 | 341 | const errorPageHTML = `<!DOCTYPE html> | |
| 240 | 342 | <html> | |
| @@ -2,17 +2,28 @@ package webkit | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "embed" | |||
| 6 | + | "io/fs" | |||
| 7 | + | "mime" | |||
| 5 | "net/http" | 8 | "net/http" | ||
| 9 | + | "net/url" | |||
| 10 | + | "path/filepath" | |||
| 6 | "strings" | 11 | "strings" | ||
| 7 | "sync" | 12 | "sync" | ||
| 8 | 13 | ||||
| 9 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | "github.com/bnema/puregotk-webkit/webkit" | 15 | "github.com/bnema/puregotk-webkit/webkit" | ||
| 11 | "github.com/jwijenbergh/puregotk/v4/gio" | 16 | "github.com/jwijenbergh/puregotk/v4/gio" | ||
| 12 | - | "github.com/jwijenbergh/puregotk/v4/glib" | |||
| 13 | "github.com/rs/zerolog" | 17 | "github.com/rs/zerolog" | ||
| 14 | ) | 18 | ) | ||
| 15 | 19 | ||||
| 20 | + | // Scheme path constants | |||
| 21 | + | const ( | |||
| 22 | + | HomePath = "home" | |||
| 23 | + | BlockedPath = "blocked" | |||
| 24 | + | IndexHTML = "index.html" | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 16 | // SchemeRequest represents a request to a custom URI scheme. | 27 | // SchemeRequest represents a request to a custom URI scheme. | ||
| 17 | type SchemeRequest struct { | 28 | type SchemeRequest struct { | ||
| 18 | inner *webkit.URISchemeRequest | 29 | inner *webkit.URISchemeRequest | ||
| @@ -44,6 +55,8 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||||
| 44 | // DumbSchemeHandler handles dumb:// URI scheme requests. | 55 | // DumbSchemeHandler handles dumb:// URI scheme requests. | ||
| 45 | type DumbSchemeHandler struct { | 56 | type DumbSchemeHandler struct { | ||
| 46 | handlers map[string]PageHandler | 57 | handlers map[string]PageHandler | ||
| 58 | + | assets embed.FS | |||
| 59 | + | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") | |||
| 47 | logger zerolog.Logger | 60 | logger zerolog.Logger | ||
| 48 | mu sync.RWMutex | 61 | mu sync.RWMutex | ||
| 49 | } | 62 | } | ||
| @@ -54,6 +67,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||||
| 54 | 67 | ||||
| 55 | h := &DumbSchemeHandler{ | 68 | h := &DumbSchemeHandler{ | ||
| 56 | handlers: make(map[string]PageHandler), | 69 | handlers: make(map[string]PageHandler), | ||
| 70 | + | assetDir: "webui", | |||
| 57 | logger: log.With().Str("component", "scheme-handler").Logger(), | 71 | logger: log.With().Str("component", "scheme-handler").Logger(), | ||
| 58 | } | 72 | } | ||
| 59 | 73 | ||||
| @@ -63,27 +77,17 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||||
| 63 | return h | 77 | return h | ||
| 64 | } | 78 | } | ||
| 65 | 79 | ||||
| 80 | + | // SetAssets sets the embedded filesystem containing webui assets. | |||
| 81 | + | func (h *DumbSchemeHandler) SetAssets(assets embed.FS) { | |||
| 82 | + | h.mu.Lock() | |||
| 83 | + | defer h.mu.Unlock() | |||
| 84 | + | h.assets = assets | |||
| 85 | + | h.logger.Debug().Msg("assets filesystem configured") | |||
| 86 | + | } | |||
| 87 | + | ||||
| 66 | // registerDefaults sets up default page handlers. | 88 | // registerDefaults sets up default page handlers. | ||
| 67 | func (h *DumbSchemeHandler) registerDefaults() { | 89 | func (h *DumbSchemeHandler) registerDefaults() { | ||
| 68 | - | // New tab page | 90 | + | // Error page (static fallback) |
| 69 | - | h.RegisterPage("/newtab", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |||
| 70 | - | return &SchemeResponse{ | |||
| 71 | - | Data: []byte(newTabHTML), | |||
| 72 | - | ContentType: "text/html", | |||
| 73 | - | StatusCode: http.StatusOK, | |||
| 74 | - | } | |||
| 75 | - | })) | |||
| 76 | - | ||||
| 77 | - | // Homepage (alias for newtab) | |||
| 78 | - | h.RegisterPage("/", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |||
| 79 | - | return &SchemeResponse{ | |||
| 80 | - | Data: []byte(newTabHTML), | |||
| 81 | - | ContentType: "text/html", | |||
| 82 | - | StatusCode: http.StatusOK, | |||
| 83 | - | } | |||
| 84 | - | })) | |||
| 85 | - | ||||
| 86 | - | // Error page | |||
| 87 | h.RegisterPage("/error", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | 91 | h.RegisterPage("/error", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | ||
| 88 | return &SchemeResponse{ | 92 | return &SchemeResponse{ | ||
| 89 | Data: []byte(errorPageHTML), | 93 | Data: []byte(errorPageHTML), | ||
| @@ -108,9 +112,10 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||||
| 108 | return | 112 | return | ||
| 109 | } | 113 | } | ||
| 110 | 114 | ||||
| 115 | + | uri := req.GetUri() | |||
| 111 | schemeReq := &SchemeRequest{ | 116 | schemeReq := &SchemeRequest{ | ||
| 112 | inner: req, | 117 | inner: req, | ||
| 113 | - | URI: req.GetUri(), | 118 | + | URI: uri, |
| 114 | Path: req.GetPath(), | 119 | Path: req.GetPath(), | ||
| 115 | Method: req.GetHttpMethod(), | 120 | Method: req.GetHttpMethod(), | ||
| 116 | Scheme: req.GetScheme(), | 121 | Scheme: req.GetScheme(), | ||
| @@ -122,11 +127,28 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||||
| 122 | Str("method", schemeReq.Method). | 127 | Str("method", schemeReq.Method). | ||
| 123 | Msg("handling scheme request") | 128 | Msg("handling scheme request") | ||
| 124 | 129 | ||||
| 125 | - | // Look up handler | 130 | + | // Parse the URI to extract host and path |
| 131 | + | u, err := url.Parse(uri) | |||
| 132 | + | if err != nil || u.Scheme != "dumb" { | |||
| 133 | + | h.logger.Error().Err(err).Str("uri", uri).Msg("invalid URI") | |||
| 134 | + | h.sendResponse(req, &SchemeResponse{ | |||
| 135 | + | Data: []byte("Invalid URI"), | |||
| 136 | + | ContentType: "text/plain", | |||
| 137 | + | StatusCode: http.StatusBadRequest, | |||
| 138 | + | }) | |||
| 139 | + | return | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // Try to serve from embedded assets first | |||
| 143 | + | if response := h.handleAsset(u); response != nil { | |||
| 144 | + | h.sendResponse(req, response) | |||
| 145 | + | return | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // Fall back to registered handlers | |||
| 126 | h.mu.RLock() | 149 | h.mu.RLock() | ||
| 127 | handler, ok := h.handlers[schemeReq.Path] | 150 | handler, ok := h.handlers[schemeReq.Path] | ||
| 128 | if !ok { | 151 | if !ok { | ||
| 129 | - | // Try without leading slash | |||
| 130 | handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")] | 152 | handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")] | ||
| 131 | } | 153 | } | ||
| 132 | h.mu.RUnlock() | 154 | h.mu.RUnlock() | ||
| @@ -142,10 +164,119 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||||
| 142 | } | 164 | } | ||
| 143 | } | 165 | } | ||
| 144 | 166 | ||||
| 145 | - | // Send response | |||
| 146 | h.sendResponse(req, response) | 167 | h.sendResponse(req, response) | ||
| 147 | } | 168 | } | ||
| 148 | 169 | ||||
| 170 | + | // handleAsset serves static assets from the embedded filesystem. | |||
| 171 | + | // Returns nil if no asset was found (allowing fallback to registered handlers). | |||
| 172 | + | func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 173 | + | h.mu.RLock() | |||
| 174 | + | hasAssets := h.assets != (embed.FS{}) | |||
| 175 | + | assetDir := h.assetDir | |||
| 176 | + | h.mu.RUnlock() | |||
| 177 | + | ||||
| 178 | + | if !hasAssets { | |||
| 179 | + | return nil | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // Determine the target file based on host and path | |||
| 183 | + | host := u.Host | |||
| 184 | + | path := strings.TrimPrefix(u.Path, "/") | |||
| 185 | + | ||||
| 186 | + | var relPath string | |||
| 187 | + | switch { | |||
| 188 | + | // dumb://home or dumb://home/ → index.html | |||
| 189 | + | case host == HomePath && (path == "" || path == "/"): | |||
| 190 | + | relPath = IndexHTML | |||
| 191 | + | // dumb://home/<asset> → serve asset | |||
| 192 | + | case host == HomePath && path != "": | |||
| 193 | + | relPath = path | |||
| 194 | + | // dumb://blocked or dumb://blocked/ → blocked.html | |||
| 195 | + | case host == BlockedPath && (path == "" || path == "/"): | |||
| 196 | + | relPath = "blocked.html" | |||
| 197 | + | // dumb://blocked/<asset> → serve asset | |||
| 198 | + | case host == BlockedPath && path != "": | |||
| 199 | + | relPath = path | |||
| 200 | + | // dumb:home (opaque form) → index.html | |||
| 201 | + | case u.Opaque == HomePath: | |||
| 202 | + | relPath = IndexHTML | |||
| 203 | + | // dumb:blocked (opaque form) → blocked.html | |||
| 204 | + | case u.Opaque == BlockedPath: | |||
| 205 | + | relPath = "blocked.html" | |||
| 206 | + | default: | |||
| 207 | + | // Not a recognized asset path | |||
| 208 | + | return nil | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // Read the asset from embedded FS | |||
| 212 | + | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | |||
| 213 | + | data, err := fs.ReadFile(h.assets, fullPath) | |||
| 214 | + | if err != nil { | |||
| 215 | + | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | |||
| 216 | + | return nil | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | contentType := h.getMimeType(relPath) | |||
| 220 | + | h.logger.Debug(). | |||
| 221 | + | Str("path", fullPath). | |||
| 222 | + | Str("content_type", contentType). | |||
| 223 | + | Int("size", len(data)). | |||
| 224 | + | Msg("serving asset") | |||
| 225 | + | ||||
| 226 | + | return &SchemeResponse{ | |||
| 227 | + | Data: data, | |||
| 228 | + | ContentType: contentType, | |||
| 229 | + | StatusCode: http.StatusOK, | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // getMimeType determines the MIME type for a given file path. | |||
| 234 | + | func (h *DumbSchemeHandler) getMimeType(filename string) string { | |||
| 235 | + | ext := strings.ToLower(filepath.Ext(filename)) | |||
| 236 | + | ||||
| 237 | + | // Try standard mime type first | |||
| 238 | + | mt := mime.TypeByExtension(ext) | |||
| 239 | + | if mt != "" { | |||
| 240 | + | return mt | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // Fallbacks for common web assets | |||
| 244 | + | switch ext { | |||
| 245 | + | case ".js", ".mjs": | |||
| 246 | + | return "application/javascript" | |||
| 247 | + | case ".css": | |||
| 248 | + | return "text/css" | |||
| 249 | + | case ".svg": | |||
| 250 | + | return "image/svg+xml" | |||
| 251 | + | case ".ico": | |||
| 252 | + | return "image/x-icon" | |||
| 253 | + | case ".woff": | |||
| 254 | + | return "font/woff" | |||
| 255 | + | case ".woff2": | |||
| 256 | + | return "font/woff2" | |||
| 257 | + | case ".ttf": | |||
| 258 | + | return "font/ttf" | |||
| 259 | + | case ".otf": | |||
| 260 | + | return "font/otf" | |||
| 261 | + | case ".png": | |||
| 262 | + | return "image/png" | |||
| 263 | + | case ".jpg", ".jpeg": | |||
| 264 | + | return "image/jpeg" | |||
| 265 | + | case ".gif": | |||
| 266 | + | return "image/gif" | |||
| 267 | + | case ".webp": | |||
| 268 | + | return "image/webp" | |||
| 269 | + | case ".json": | |||
| 270 | + | return "application/json" | |||
| 271 | + | case ".xml": | |||
| 272 | + | return "application/xml" | |||
| 273 | + | case ".html", ".htm": | |||
| 274 | + | return "text/html; charset=utf-8" | |||
| 275 | + | default: | |||
| 276 | + | return "text/plain" | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | ||||
| 149 | // sendResponse sends the response back to WebKit. | 280 | // sendResponse sends the response back to WebKit. | ||
| 150 | func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) { | 281 | func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) { | ||
| 151 | if response == nil { | 282 | if response == nil { | ||
| @@ -156,27 +287,28 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||||
| 156 | } | 287 | } | ||
| 157 | } | 288 | } | ||
| 158 | 289 | ||||
| 159 | - | // Create GBytes from response data | 290 | + | contentType := response.ContentType |
| 160 | - | gbytes := glib.NewBytes(response.Data, uint(len(response.Data))) | 291 | + | if contentType == "" { |
| 161 | - | if gbytes == nil { | 292 | + | contentType = "text/html" |
| 162 | - | h.logger.Error().Msg("failed to create GBytes for response") | |||
| 163 | - | return | |||
| 164 | } | 293 | } | ||
| 165 | 294 | ||||
| 166 | - | // Create MemoryInputStream from GBytes | 295 | + | // Create MemoryInputStream from data directly |
| 167 | - | stream := gio.NewMemoryInputStreamFromBytes(gbytes) | 296 | + | stream := gio.NewMemoryInputStreamFromData(response.Data, len(response.Data), nil) |
| 168 | if stream == nil { | 297 | if stream == nil { | ||
| 169 | h.logger.Error().Msg("failed to create MemoryInputStream for response") | 298 | h.logger.Error().Msg("failed to create MemoryInputStream for response") | ||
| 170 | return | 299 | return | ||
| 171 | } | 300 | } | ||
| 172 | 301 | ||||
| 173 | - | // Finish the request with the stream | 302 | + | // Create response object for more control |
| 174 | - | contentType := response.ContentType | 303 | + | schemeResp := webkit.NewURISchemeResponse(&stream.InputStream, int64(len(response.Data))) |
| 175 | - | if contentType == "" { | 304 | + | if schemeResp == nil { |
| 176 | - | contentType = "text/html" | 305 | + | h.logger.Error().Msg("failed to create URISchemeResponse") |
| 306 | + | return | |||
| 177 | } | 307 | } | ||
| 308 | + | schemeResp.SetContentType(contentType) | |||
| 309 | + | schemeResp.SetStatus(uint(response.StatusCode), nil) | |||
| 178 | 310 | ||||
| 179 | - | req.Finish(&stream.InputStream, int64(len(response.Data)), &contentType) | 311 | + | req.FinishWithResponse(schemeResp) |
| 180 | } | 312 | } | ||
| 181 | 313 | ||||
| 182 | // RegisterWithContext registers the dumb:// scheme with a WebKitContext. | 314 | // RegisterWithContext registers the dumb:// scheme with a WebKitContext. | ||
| @@ -192,49 +324,19 @@ func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) { | |||||
| 192 | 324 | ||||
| 193 | wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil) | 325 | wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil) | ||
| 194 | 326 | ||||
| 195 | - | // Mark scheme as local and secure for proper security policies | 327 | + | // Mark scheme as local, secure, and CORS-enabled for proper security policies |
| 196 | secMgr := wkCtx.Context().GetSecurityManager() | 328 | secMgr := wkCtx.Context().GetSecurityManager() | ||
| 197 | if secMgr != nil { | 329 | if secMgr != nil { | ||
| 198 | secMgr.RegisterUriSchemeAsLocal("dumb") | 330 | secMgr.RegisterUriSchemeAsLocal("dumb") | ||
| 199 | secMgr.RegisterUriSchemeAsSecure("dumb") | 331 | secMgr.RegisterUriSchemeAsSecure("dumb") | ||
| 332 | + | secMgr.RegisterUriSchemeAsCorsEnabled("dumb") | |||
| 333 | + | h.logger.Debug().Msg("dumb:// scheme registered as local, secure, and CORS-enabled") | |||
| 200 | } | 334 | } | ||
| 201 | 335 | ||||
| 202 | h.logger.Info().Msg("dumb:// scheme registered") | 336 | h.logger.Info().Msg("dumb:// scheme registered") | ||
| 203 | } | 337 | } | ||
| 204 | 338 | ||||
| 205 | - | // Default page templates | 339 | + | // Default page templates (fallback when assets not available) |
| 206 | - | ||||
| 207 | - | const newTabHTML = `<!DOCTYPE html> | |||
| 208 | - | <html> | |||
| 209 | - | <head> | |||
| 210 | - | <meta charset="utf-8"> | |||
| 211 | - | <title>New Tab</title> | |||
| 212 | - | <style> | |||
| 213 | - | body { | |||
| 214 | - | font-family: system-ui, -apple-system, sans-serif; | |||
| 215 | - | background: #1a1a2e; | |||
| 216 | - | color: #eee; | |||
| 217 | - | display: flex; | |||
| 218 | - | align-items: center; | |||
| 219 | - | justify-content: center; | |||
| 220 | - | height: 100vh; | |||
| 221 | - | margin: 0; | |||
| 222 | - | } | |||
| 223 | - | .container { | |||
| 224 | - | text-align: center; | |||
| 225 | - | } | |||
| 226 | - | h1 { | |||
| 227 | - | font-weight: 300; | |||
| 228 | - | color: #888; | |||
| 229 | - | } | |||
| 230 | - | </style> | |||
| 231 | - | </head> | |||
| 232 | - | <body> | |||
| 233 | - | <div class="container"> | |||
| 234 | - | <h1>dumber</h1> | |||
| 235 | - | </div> | |||
| 236 | - | </body> | |||
| 237 | - | </html>` | |||
| 238 | 340 | ||||
| 239 | const errorPageHTML = `<!DOCTYPE html> | 341 | const errorPageHTML = `<!DOCTYPE html> | ||
| 240 | <html> | 342 | <html> | ||
M internal/ui/app.go +3 -3
| @@ -204,8 +204,8 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 204 | 204 | a.navCoord.SetOmniboxProvider(a) | |
| 205 | 205 | log.Debug().Msg("omnibox config stored, provider set") | |
| 206 | 206 | ||
| 207 | - | // Create an initial tab using coordinator | |
| 208 | - | if _, err := a.tabCoord.Create(ctx, "https://duckduckgo.com"); err != nil { | |
| 207 | + | // Create an initial tab using coordinator - opens dumb://home homepage | |
| 208 | + | if _, err := a.tabCoord.Create(ctx, "dumb://home"); err != nil { | |
| 209 | 209 | log.Error().Err(err).Msg("failed to create initial tab") | |
| 210 | 210 | } | |
| 211 | 211 | ||
| @@ -375,7 +375,7 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |||
| 375 | 375 | } | |
| 376 | 376 | ||
| 377 | 377 | // Set the workspace | |
| 378 | - | if err := wsView.SetWorkspace(tab.Workspace); err != nil { | |
| 378 | + | if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil { | |
| 379 | 379 | log.Error().Err(err).Msg("failed to set workspace in view") | |
| 380 | 380 | return | |
| 381 | 381 | } | |
| @@ -204,8 +204,8 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 204 | a.navCoord.SetOmniboxProvider(a) | 204 | a.navCoord.SetOmniboxProvider(a) | ||
| 205 | log.Debug().Msg("omnibox config stored, provider set") | 205 | log.Debug().Msg("omnibox config stored, provider set") | ||
| 206 | 206 | ||||
| 207 | - | // Create an initial tab using coordinator | 207 | + | // Create an initial tab using coordinator - opens dumb://home homepage |
| 208 | - | if _, err := a.tabCoord.Create(ctx, "https://duckduckgo.com"); err != nil { | 208 | + | if _, err := a.tabCoord.Create(ctx, "dumb://home"); err != nil { |
| 209 | log.Error().Err(err).Msg("failed to create initial tab") | 209 | log.Error().Err(err).Msg("failed to create initial tab") | ||
| 210 | } | 210 | } | ||
| 211 | 211 | ||||
| @@ -375,7 +375,7 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |||||
| 375 | } | 375 | } | ||
| 376 | 376 | ||||
| 377 | // Set the workspace | 377 | // Set the workspace | ||
| 378 | - | if err := wsView.SetWorkspace(tab.Workspace); err != nil { | 378 | + | if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil { |
| 379 | log.Error().Err(err).Msg("failed to set workspace in view") | 379 | log.Error().Err(err).Msg("failed to set workspace in view") | ||
| 380 | return | 380 | return | ||
| 381 | } | 381 | } | ||